@im-nobsidian/core 0.3.0 → 0.3.1

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
@@ -659,6 +659,16 @@ var StateDB = class _StateDB {
659
659
  }
660
660
  };
661
661
 
662
+ // src/utils/notion-file-url.ts
663
+ function isNotionHostedFileUrl(url) {
664
+ return url.includes("secure.notion-static.com") || url.includes("prod-files-secure") || // 신형 파일 URL (2024+): https://file.notion.so/f/f/<space>/<file>/<name>?table=block&...
665
+ url.includes("file.notion.so");
666
+ }
667
+ var NOTION_ATTACHMENT_URI_RE = /^attachment:([0-9a-f-]+):(.+)$/;
668
+ function isNotionAttachmentUri(url) {
669
+ return NOTION_ATTACHMENT_URI_RE.test(url);
670
+ }
671
+
662
672
  // src/notion/property-mapper.ts
663
673
  var DATE_REGEX = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
664
674
  var WIKILINK_REGEX = /^\[\[(.+?)(?:\|.+?)?\]\]$/;
@@ -773,6 +783,16 @@ var PropertyMapper = class {
773
783
  }
774
784
  case "files": {
775
785
  const fileList = Array.isArray(value) ? value : [value];
786
+ const urls = fileList.map((f) => {
787
+ if (typeof f === "object" && f !== null && "url" in f) {
788
+ return String(f.url ?? "");
789
+ }
790
+ return String(f);
791
+ });
792
+ const hasProtected = urls.some(
793
+ (u) => WIKILINK_REGEX.test(u.trim()) || isNotionHostedFileUrl(u)
794
+ );
795
+ if (hasProtected) return null;
776
796
  const filesArr = fileList.map((f) => {
777
797
  if (typeof f === "object" && f !== null && "url" in f) {
778
798
  const obj = f;
@@ -1108,18 +1128,60 @@ var NotionClient = class _NotionClient {
1108
1128
  return titleArr?.[0]?.plain_text ?? "";
1109
1129
  }
1110
1130
  /**
1111
- * 발견된 DB 가 **동기화 가능한지**(접근 가능한 data source 가 있는지) 1회 호출로 판별한다.
1131
+ * 발견된 DB 가 **동기화 가능한지**(접근 가능한 data source 가 있는지) 판별한다.
1112
1132
  * 신 모델(2025-09-03)에서 링크드 DB·미공유 데이터 소스·삭제 DB 는 `data_sources` 가
1113
1133
  * 비어, 행 조회(`dataSources.query`)가 404 로 실패하고 그 전에 생성된 빈 폴더/`.base` 만
1114
1134
  * 남긴다. 발견 단계에서 미리 걸러 **빈 폴더/.base 오염 + 매 pull 의 404 노이즈**를 차단한다.
1115
1135
  * 제목은 함께 반환해 호출처가 추가 조회 없이 폴더명을 잡게 한다.
1136
+ *
1137
+ * F25: 원본이 같은 공유 범위에 있으면 linked view 컨테이너도 `data_sources` 가 **채워져**
1138
+ * 온다(실측 — 워크스페이스 8건). 이때 data source 의 parent database 는 원본이므로,
1139
+ * 첫 소스의 parent 가 자신이 아니면 `linkedOriginalDbId` 로 알려 컨테이너가 원본과 같은
1140
+ * 행 집합을 이중 pull(폴더 릴레이 재배치 churn)하는 것을 발견 단계에서 차단한다.
1116
1141
  */
1117
1142
  async getDatabaseSyncability(databaseId) {
1118
1143
  const db = await this.withRateLimit(
1119
1144
  () => this.client.databases.retrieve({ database_id: databaseId })
1120
1145
  );
1121
1146
  const title = Array.isArray(db.title) ? db.title[0]?.plain_text ?? "" : "";
1122
- return { title, queryable: (db.data_sources?.length ?? 0) > 0 };
1147
+ const firstDsId = db.data_sources?.[0]?.id;
1148
+ if (!firstDsId) return { title, queryable: false };
1149
+ try {
1150
+ const ds = await this.withRateLimit(
1151
+ () => this.client.dataSources.retrieve({ data_source_id: firstDsId })
1152
+ );
1153
+ const ownerDbId = ds.parent?.database_id;
1154
+ if (ownerDbId && normalizeNotionId(ownerDbId) !== normalizeNotionId(databaseId)) {
1155
+ return { title, queryable: true, linkedOriginalDbId: ownerDbId };
1156
+ }
1157
+ } catch {
1158
+ }
1159
+ return { title, queryable: true };
1160
+ }
1161
+ /**
1162
+ * linked database view 컨테이너를 원본 DB 로 해소한다.
1163
+ * 컨테이너 자체는 `data_sources` 가 비어 행 조회가 불가능하지만(getDatabaseSyncability
1164
+ * queryable=false), Views API 의 뷰 상세에는 원본 `data_source_id` 가 실리고 그 data source
1165
+ * 의 parent 가 원본 database 다. 해소 실패(뷰 없음/권한 없음/자기참조)는 null — 호출처가
1166
+ * 기존대로 접근 불가 처리한다.
1167
+ */
1168
+ async resolveLinkedDatabase(databaseId) {
1169
+ try {
1170
+ const views = await this.listDatabaseViews(databaseId);
1171
+ for (const view of views) {
1172
+ if (!view.dataSourceId) continue;
1173
+ const ds = await this.withRateLimit(
1174
+ () => this.client.dataSources.retrieve({ data_source_id: view.dataSourceId })
1175
+ );
1176
+ const originalDbId = ds.parent?.database_id;
1177
+ if (!originalDbId) continue;
1178
+ if (originalDbId.replace(/-/g, "") === databaseId.replace(/-/g, "")) continue;
1179
+ return { originalDbId, viewName: view.name ?? "" };
1180
+ }
1181
+ return null;
1182
+ } catch {
1183
+ return null;
1184
+ }
1123
1185
  }
1124
1186
  async getDatabaseSchema(databaseId) {
1125
1187
  const db = await this.fetchDatabaseModern(databaseId);
@@ -1329,7 +1391,17 @@ var NotionClient = class _NotionClient {
1329
1391
  await this.withRateLimit(() => this.client.blocks.delete({ block_id: blockId }));
1330
1392
  }
1331
1393
  // ─── File Upload ───
1394
+ /** single_part 업로드 상한 (Notion API 규격 20MB) */
1395
+ static SINGLE_PART_MAX_BYTES = 20 * 1024 * 1024;
1396
+ /** multi_part 파트 크기 — 규격상 마지막 파트 제외 5~20MB, 10MB 고정 사용 */
1397
+ static MULTI_PART_CHUNK_BYTES = 10 * 1024 * 1024;
1332
1398
  async uploadFile(fileData, filename, contentType) {
1399
+ if (fileData.type !== contentType) {
1400
+ fileData = new Blob([fileData], { type: contentType });
1401
+ }
1402
+ if (fileData.size > _NotionClient.SINGLE_PART_MAX_BYTES) {
1403
+ return this.uploadFileMultiPart(fileData, filename, contentType);
1404
+ }
1333
1405
  const upload = await this.withRateLimit(
1334
1406
  () => this.client.fileUploads.create({ filename, content_type: contentType })
1335
1407
  );
@@ -1348,6 +1420,34 @@ var NotionClient = class _NotionClient {
1348
1420
  }
1349
1421
  return fileUploadId;
1350
1422
  }
1423
+ /** 20MB 초과 파일의 multi_part 업로드 — part_number 는 API 규격상 문자열("1"부터) */
1424
+ async uploadFileMultiPart(fileData, filename, contentType) {
1425
+ const chunkSize = _NotionClient.MULTI_PART_CHUNK_BYTES;
1426
+ const numberOfParts = Math.ceil(fileData.size / chunkSize);
1427
+ const upload = await this.withRateLimit(
1428
+ () => this.client.fileUploads.create({
1429
+ mode: "multi_part",
1430
+ number_of_parts: numberOfParts,
1431
+ filename,
1432
+ content_type: contentType
1433
+ })
1434
+ );
1435
+ const fileUploadId = upload.id;
1436
+ for (let part = 1; part <= numberOfParts; part++) {
1437
+ const chunk = fileData.slice((part - 1) * chunkSize, part * chunkSize, contentType);
1438
+ await this.withRateLimit(
1439
+ () => this.client.fileUploads.send({
1440
+ file_upload_id: fileUploadId,
1441
+ part_number: String(part),
1442
+ file: { data: chunk, filename }
1443
+ })
1444
+ );
1445
+ }
1446
+ await this.withRateLimit(
1447
+ () => this.client.fileUploads.complete({ file_upload_id: fileUploadId })
1448
+ );
1449
+ return fileUploadId;
1450
+ }
1351
1451
  // ─── Search & Navigation ───
1352
1452
  async search(params) {
1353
1453
  const response = await this.withRateLimit(
@@ -2224,7 +2324,23 @@ var COLUMN_LIST_START = compactMarker("column-list:start");
2224
2324
  var COLUMN_LIST_END = compactMarker("column-list:end");
2225
2325
  var COLUMN_SEP = compactMarker("column");
2226
2326
  var TOC_MARKER = compactMarker("toc");
2327
+ function syncedStartMarker(kind, url) {
2328
+ return compactMarker(`synced:start:kind=${kind}&url=${encodeURIComponent(url)}`);
2329
+ }
2330
+ var SYNCED_END = compactMarker("synced:end");
2227
2331
  var BREADCRUMB_MARKER = compactMarker("breadcrumb");
2332
+ function calloutStyleMarker(params) {
2333
+ const kv = [];
2334
+ if (params.icon) kv.push(`icon=${encodeURIComponent(params.icon)}`);
2335
+ if (params.color) kv.push(`color=${encodeURIComponent(params.color)}`);
2336
+ return compactMarker(`callout-style:${kv.join("&")}`);
2337
+ }
2338
+ function toggleColorMarker(color) {
2339
+ return compactMarker(`toggle-color:${color}`);
2340
+ }
2341
+ function blockColorMarker(color) {
2342
+ return compactMarker(`block-color:${color}`);
2343
+ }
2228
2344
  var PROPERTIES_TAG = `# ${MARKER_BRAND}:properties`;
2229
2345
  var MARKER_BRAND_RE = MARKER_BRAND;
2230
2346
 
@@ -2673,6 +2789,7 @@ var InlineAnnotationPreserver = class {
2673
2789
 
2674
2790
  // src/converter/pre-processors/comment-stripper.ts
2675
2791
  var COMMENT_REGEX = /%%([\s\S]*?)%%/g;
2792
+ var HTML_COMMENT_REGEX = /<!--([\s\S]*?)-->/g;
2676
2793
  var CommentStripper = class {
2677
2794
  name = "CommentStripper";
2678
2795
  // FrontmatterExtractor(10) 뒤 — YAML 값 속 `%%` 를 본문 주석으로 오인하지 않는다.
@@ -2682,21 +2799,31 @@ var CommentStripper = class {
2682
2799
  return { content: input.content, metadata: input.metadata };
2683
2800
  }
2684
2801
  const markers = input.metadata.preserveMarkers ? [...input.metadata.preserveMarkers] : [];
2685
- const content = mapOutsideCodeFences(
2686
- input.content,
2687
- (segment) => segment.replace(COMMENT_REGEX, (match, body, offset) => {
2688
- const trimmed = body.trimStart();
2689
- if (trimmed.startsWith(`${MARKER_BRAND}:`) || trimmed.startsWith("/")) {
2690
- return match;
2802
+ const content = mapOutsideCodeFences(input.content, (segment) => {
2803
+ const afterObsidian = segment.replace(
2804
+ COMMENT_REGEX,
2805
+ (match, body, offset) => {
2806
+ const trimmed = body.trimStart();
2807
+ if (trimmed.startsWith(`${MARKER_BRAND}:`) || trimmed.startsWith("/")) {
2808
+ return match;
2809
+ }
2810
+ markers.push({
2811
+ type: "comment",
2812
+ params: { text: body, __anchor: computeAnchor(segment, offset) },
2813
+ startIndex: offset
2814
+ });
2815
+ return "";
2691
2816
  }
2817
+ );
2818
+ return afterObsidian.replace(HTML_COMMENT_REGEX, (_match, body, offset) => {
2692
2819
  markers.push({
2693
2820
  type: "comment",
2694
- params: { text: body, __anchor: computeAnchor(segment, offset) },
2821
+ params: { text: body, style: "html", __anchor: computeAnchor(afterObsidian, offset) },
2695
2822
  startIndex: offset
2696
2823
  });
2697
2824
  return "";
2698
- })
2699
- );
2825
+ });
2826
+ });
2700
2827
  return {
2701
2828
  content,
2702
2829
  metadata: { ...input.metadata, preserveMarkers: markers }
@@ -2947,7 +3074,8 @@ var PreserveMarkerInjector = class {
2947
3074
  renderMarker(marker) {
2948
3075
  const params = marker.params ?? {};
2949
3076
  if (marker.type === "comment") {
2950
- return params.text ? `%%${params.text}%%` : null;
3077
+ if (!params.text) return null;
3078
+ return params.style === "html" ? `<!--${params.text}-->` : `%%${params.text}%%`;
2951
3079
  }
2952
3080
  if (params.__raw !== void 0) {
2953
3081
  return spacedMarker(`${marker.type}:${params.__raw}`);
@@ -4387,6 +4515,13 @@ var ImageHandler = class {
4387
4515
  (s) => /^https?:\/\//.test(s.url) && this.isNotionImageUrl(s.url)
4388
4516
  );
4389
4517
  const internalMatches = embeds.filter((s) => INTERNAL_FILE_URL_RE.test(s.url));
4518
+ for (const span of embeds) {
4519
+ if (isNotionAttachmentUri(span.url)) {
4520
+ getLogger().warn(
4521
+ `[Im-Nobsidian] \uC811\uADFC \uBD88\uAC00 \uCCA8\uBD80(attachment:) \uAC10\uC9C0 \u2014 Notion \uD1B5\uD569\uC5D0 \uACF5\uC720\uB418\uC9C0 \uC54A\uC740 \uC6D0\uBCF8\uC758 \uD30C\uC77C\uC774\uB77C \uB2E4\uC6B4\uB85C\uB4DC\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. \uC6D0\uBB38\uC744 \uBCF4\uC874\uD569\uB2C8\uB2E4: ${pageTitle}`
4522
+ );
4523
+ }
4524
+ }
4390
4525
  if (notionMatches.length === 0 && internalMatches.length === 0) {
4391
4526
  return { content: markdown, downloads: [] };
4392
4527
  }
@@ -4672,7 +4807,9 @@ var ImageHandler = class {
4672
4807
  const MAX_FILE_SIZE = this.maxFileSizeBytes;
4673
4808
  if (contentLength && parseInt(contentLength, 10) > MAX_FILE_SIZE) {
4674
4809
  const sizeMB = Math.round(parseInt(contentLength, 10) / 1024 / 1024);
4675
- getLogger().debug(`[Im-Nobsidian] \uD30C\uC77C \uC2A4\uD0B5 \u2014 \uB108\uBB34 \uD07C (${sizeMB}MB): ${caption}`);
4810
+ getLogger().info(
4811
+ `[Im-Nobsidian] \uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uAC74\uB108\uB700 \u2014 \uD06C\uAE30 \uC0C1\uD55C \uCD08\uACFC (${sizeMB}MB > ${Math.round(MAX_FILE_SIZE / 1024 / 1024)}MB), Notion \uC6D0\uBCF8 \uB9C1\uD06C \uC720\uC9C0: ${caption}`
4812
+ );
4676
4813
  return { originalUrl: url, localPath: "", hash: "", size: 0 };
4677
4814
  }
4678
4815
  const buffer = Buffer.from(await response.arrayBuffer());
@@ -4694,7 +4831,26 @@ var ImageHandler = class {
4694
4831
  return this.isNotionFileUrl(url);
4695
4832
  }
4696
4833
  isNotionFileUrl(url) {
4697
- return url.includes("secure.notion-static.com") || url.includes("prod-files-secure") || url.includes("s3.us-west-2.amazonaws.com/secure.notion-static.com");
4834
+ return isNotionHostedFileUrl(url);
4835
+ }
4836
+ /**
4837
+ * notion-hosted 파일 URL 하나를 로컬 첨부로 로컬라이즈한다(P3-A).
4838
+ * DB 행의 `files` 속성처럼 본문 밖에 있는 서명 URL 용 — 서명 URL 은 약 1시간 뒤
4839
+ * 만료되어 frontmatter 에 남으면 깨진 링크가 되고, push 로 되밀면 Notion 원본이
4840
+ * external(만료 URL)로 오염된다(실측). notion-hosted 가 아닌 URL(사용자가 넣은
4841
+ * 진짜 외부 링크)은 다운로드 대상이 아니므로 null 을 반환한다.
4842
+ *
4843
+ * @returns 로컬 첨부 경로(`attachments/...`), 다운로드 대상이 아니거나 실패 시 null
4844
+ */
4845
+ async localizeNotionFileUrl(url, caption) {
4846
+ if (!/^https?:\/\//.test(url) || !this.isNotionFileUrl(url)) return null;
4847
+ try {
4848
+ const download = await this.downloadFile(url, caption, caption);
4849
+ return download.localPath || null;
4850
+ } catch (error) {
4851
+ getLogger().warn(`[Im-Nobsidian] \uC18D\uC131 \uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328 (${caption}): ${error}`);
4852
+ return null;
4853
+ }
4698
4854
  }
4699
4855
  guessExtension(url) {
4700
4856
  const pathname = new URL(url).pathname;
@@ -4721,6 +4877,28 @@ var ImageHandler = class {
4721
4877
  return "";
4722
4878
  }
4723
4879
  };
4880
+
4881
+ // src/sync/stale-db-artifacts.ts
4882
+ var ARTIFACT_SUFFIXES = [".base", ".notion.json"];
4883
+ function isDbArtifactPath(path) {
4884
+ return ARTIFACT_SUFFIXES.some((suffix) => path.endsWith(suffix));
4885
+ }
4886
+ function selectStaleDbArtifacts(files, localFolder, keepPaths) {
4887
+ const prefix = `${localFolder}/`;
4888
+ const stale = [];
4889
+ for (const file of files) {
4890
+ const path = file.path;
4891
+ if (!path.startsWith(prefix)) continue;
4892
+ const rel = path.slice(prefix.length);
4893
+ if (rel.includes("/")) continue;
4894
+ if (!ARTIFACT_SUFFIXES.some((suffix) => rel.endsWith(suffix))) continue;
4895
+ if (keepPaths.has(path)) continue;
4896
+ stale.push(path);
4897
+ }
4898
+ return stale;
4899
+ }
4900
+
4901
+ // src/sync/file-handler.ts
4724
4902
  var EXTENSION_TO_BLOCK_TYPE = {
4725
4903
  ".png": "image",
4726
4904
  ".jpg": "image",
@@ -4829,7 +5007,7 @@ var FileHandler = class {
4829
5007
  stateDb;
4830
5008
  sema;
4831
5009
  async pushFilesForFolder(folderPageId, folderPath) {
4832
- const allFiles = await this.vaultFs.listNonMarkdownFiles();
5010
+ const allFiles = await this.listUploadableFiles();
4833
5011
  const folderFiles = allFiles.filter((f) => getFolderPath(f.path) === folderPath);
4834
5012
  if (folderFiles.length === 0) return [];
4835
5013
  const results = [];
@@ -4853,8 +5031,18 @@ var FileHandler = class {
4853
5031
  }
4854
5032
  return results;
4855
5033
  }
4856
- async pushAllFiles() {
5034
+ /**
5035
+ * 업로드 후보 목록 — 도구 내부 산출물(`.base`/`.notion.json`)은 제외한다.
5036
+ * node 측 VaultFS 는 과거 `.base` 를 워커에서 빼는 식으로 우회했지만, Obsidian
5037
+ * 어댑터는 모든 파일을 반환하므로 플러그인 push 가 사이드카를 Notion 첨부로
5038
+ * 오염시켰다. 소비자인 여기서 일괄 차단해 구현체 간 동작을 통일한다.
5039
+ */
5040
+ async listUploadableFiles() {
4857
5041
  const allFiles = await this.vaultFs.listNonMarkdownFiles();
5042
+ return allFiles.filter((f) => !isDbArtifactPath(f.path));
5043
+ }
5044
+ async pushAllFiles() {
5045
+ const allFiles = await this.listUploadableFiles();
4858
5046
  if (allFiles.length === 0) return [];
4859
5047
  const filesByFolder = /* @__PURE__ */ new Map();
4860
5048
  for (const file of allFiles) {
@@ -5083,6 +5271,44 @@ function resolveDbRowPath(localFolder, safeName, pageId, lookupByPath) {
5083
5271
  return `${localFolder}/${safeName} (${raw}).md`;
5084
5272
  }
5085
5273
 
5274
+ // src/utils/db-folder-path.ts
5275
+ function resolveDbFolderPath(folder, dbId, ownerOf) {
5276
+ const usable = (candidate) => {
5277
+ const owner = ownerOf(candidate);
5278
+ return owner === null || notionIdsEqual(owner, dbId);
5279
+ };
5280
+ if (usable(folder)) return folder;
5281
+ const raw = dbId.replace(/-/g, "");
5282
+ for (const len of [8, 16, 32]) {
5283
+ const candidate = `${folder} (${raw.slice(0, len)})`;
5284
+ if (usable(candidate)) return candidate;
5285
+ }
5286
+ return `${folder} (${raw})`;
5287
+ }
5288
+ function repairDbFolderCollisions(configs, takenFolders) {
5289
+ const owner = new Map(takenFolders ?? []);
5290
+ let repaired = 0;
5291
+ for (const config of configs) {
5292
+ const resolved = resolveDbFolderPath(
5293
+ config.localFolder,
5294
+ config.databaseId,
5295
+ (f) => owner.get(f) ?? null
5296
+ );
5297
+ if (resolved !== config.localFolder) {
5298
+ config.localFolder = resolved;
5299
+ repaired++;
5300
+ }
5301
+ owner.set(resolved, config.databaseId);
5302
+ }
5303
+ return repaired;
5304
+ }
5305
+ function isDirectDbRowPath(localFolder, path) {
5306
+ const prefix = localFolder.endsWith("/") ? localFolder : `${localFolder}/`;
5307
+ if (!path.startsWith(prefix)) return false;
5308
+ const rel = path.slice(prefix.length);
5309
+ return rel.length > 0 && !rel.includes("/");
5310
+ }
5311
+
5086
5312
  // src/utils/wikilink-title.ts
5087
5313
  function wikilinkTitleFromPath(obsidianPath) {
5088
5314
  return obsidianPath.replace(/\.md$/i, "").split("/").pop() ?? "";
@@ -5422,23 +5648,6 @@ function sortKeys(value) {
5422
5648
  return value;
5423
5649
  }
5424
5650
 
5425
- // src/sync/stale-db-artifacts.ts
5426
- var ARTIFACT_SUFFIXES = [".base", ".notion.json"];
5427
- function selectStaleDbArtifacts(files, localFolder, keepPaths) {
5428
- const prefix = `${localFolder}/`;
5429
- const stale = [];
5430
- for (const file of files) {
5431
- const path = file.path;
5432
- if (!path.startsWith(prefix)) continue;
5433
- const rel = path.slice(prefix.length);
5434
- if (rel.includes("/")) continue;
5435
- if (!ARTIFACT_SUFFIXES.some((suffix) => rel.endsWith(suffix))) continue;
5436
- if (keepPaths.has(path)) continue;
5437
- stale.push(path);
5438
- }
5439
- return stale;
5440
- }
5441
-
5442
5651
  // src/converter/enhanced-md-converter.ts
5443
5652
  var NOTION_CALLOUT_RE = /^::: callout\n([\s\S]*?)\n:::/gm;
5444
5653
  var NOTION_PAGE_MENTION_RE = /<mention-page id="([^"]+)">([\s\S]*?)<\/mention-page>/g;
@@ -5468,12 +5677,13 @@ function notionEnhancedToObsidian(enhanced) {
5468
5677
  result = convertNotionTables(result);
5469
5678
  result = convertSpans(result);
5470
5679
  result = convertDatabaseBlocks(result);
5471
- result = cleanInlineColorAttrs(result);
5680
+ result = convertBlockColorAttrs(result);
5472
5681
  result = removeEmptyBlocks(result);
5473
5682
  result = unescapePipes(result);
5474
5683
  result = unescapeNotionChars(result);
5475
5684
  result = unescapeWikilinkBrackets(result);
5476
5685
  result = ensureCalloutContinuity(result);
5686
+ result = separateAdjacentCallouts(result);
5477
5687
  return result;
5478
5688
  }
5479
5689
  function unescapeWikilinkBrackets(content) {
@@ -5487,9 +5697,12 @@ function obsidianToNotionEnhanced(obsidian) {
5487
5697
  result = restoreMediaTags(result);
5488
5698
  result = restoreUnknownBlocks(result);
5489
5699
  result = restoreColorSpans(result);
5700
+ result = restoreBlockColorMarkers(result);
5490
5701
  result = restoreUnderlineSpans(result);
5491
5702
  result = convertMentionPageIdToUrl(result);
5492
5703
  result = restoreWikilinkPreserveLinks(result);
5704
+ result = restoreSyncedBlocks(result);
5705
+ result = reassembleColumns(result);
5493
5706
  return result;
5494
5707
  }
5495
5708
  var MENTION_PAGE_ID_RE = /<mention-page id="([^"]+)">[\s\S]*?<\/mention-page>/g;
@@ -5582,29 +5795,36 @@ ${fence}
5582
5795
  }
5583
5796
  );
5584
5797
  }
5585
- var NO_CONTAINER_BODY = "(?:(?!<details>|<callout|<columns>)[\\s\\S])*?";
5586
- var SUMMARY_TITLE = "((?:(?!<\\/summary>|<details>|<summary>)[\\s\\S])*?)";
5798
+ var NO_CONTAINER_BODY = "(?:(?!<details|<callout|<columns)[\\s\\S])*?";
5799
+ var SUMMARY_TITLE = "((?:(?!<\\/summary>|<details|<summary>)[\\s\\S])*?)";
5587
5800
  var INNERMOST_DETAILS_RE = new RegExp(
5588
- `([\\t ]*)<details>\\s*<summary>${SUMMARY_TITLE}<\\/summary>(${NO_CONTAINER_BODY})<\\/details>`,
5801
+ `([\\t ]*)<details([^>]*)>[ \\t]*\\n?(?:\\s*<summary>${SUMMARY_TITLE}<\\/summary>)?(${NO_CONTAINER_BODY})<\\/details>`,
5589
5802
  "g"
5590
5803
  );
5591
5804
  var INNERMOST_CALLOUT_RE = new RegExp(
5592
- `([\\t ]*)<callout[^>]*>\\n?(${NO_CONTAINER_BODY})<\\/callout>`,
5805
+ `([\\t ]*)<callout([^>]*)>\\n?(${NO_CONTAINER_BODY})<\\/callout>`,
5593
5806
  "g"
5594
5807
  );
5595
5808
  var INNERMOST_COLUMNS_RE = new RegExp(
5596
- `([\\t ]*)<columns>\\n?(${NO_CONTAINER_BODY})<\\/columns>`,
5809
+ `([\\t ]*)<columns[^>]*>\\n?(${NO_CONTAINER_BODY})<\\/columns>`,
5597
5810
  "g"
5598
5811
  );
5599
- var COLUMN_WRAP_RE = /[\t ]*<column>\n?([\s\S]*?)[\t ]*<\/column>\n?/g;
5812
+ var COLUMN_WRAP_RE = /[\t ]*<column(?!s)[^>]*>\n?([\s\S]*?)[\t ]*<\/column>\n?/g;
5600
5813
  function reindentLines(text, indent) {
5601
5814
  if (!indent) return text;
5602
5815
  return text.split("\n").map((line) => line === "" ? line : indent + line).join("\n");
5603
5816
  }
5604
- function toggleToCallout(title, rawBody) {
5817
+ function parseDesignAttrs(attrs) {
5818
+ const icon = /\bicon="([^"]*)"/.exec(attrs)?.[1];
5819
+ const color = /\bcolor="([^"]*)"/.exec(attrs)?.[1];
5820
+ return { icon: icon || void 0, color: color || void 0 };
5821
+ }
5822
+ function toggleToCallout(title, rawBody, color) {
5605
5823
  const body = dedentContainerBody(rawBody);
5824
+ const head = ["> [!toggle]-", title.trim(), color ? toggleColorMarker(color) : ""].filter((part) => part !== "").join(" ");
5825
+ if (body.trim() === "") return head;
5606
5826
  const calloutBody = body.split("\n").map((line) => line.trim() ? `> ${line}` : ">").join("\n");
5607
- return `> [!toggle]- ${title.trim()}
5827
+ return `${head}
5608
5828
  ${calloutBody}`;
5609
5829
  }
5610
5830
  function flattenColumns(body) {
@@ -5618,20 +5838,22 @@ function flattenColumns(body) {
5618
5838
  if (col.trim() !== "") cols.push(col);
5619
5839
  }
5620
5840
  if (!matched) return dedentContainerBody(body);
5621
- return cols.join("\n\n");
5841
+ if (cols.length === 0) return "";
5842
+ return [COLUMN_LIST_START, ...cols.map((c) => `${COLUMN_SEP}
5843
+ ${c}`), COLUMN_LIST_END].join("\n");
5622
5844
  }
5623
5845
  function convertContainers(content) {
5624
5846
  let result = content;
5625
5847
  let safety = 0;
5626
- while (safety++ < 2e3 && (result.includes("<details>") || result.includes("<callout") || result.includes("<columns>"))) {
5848
+ while (safety++ < 2e3 && (result.includes("<details") || result.includes("<callout") || result.includes("<columns"))) {
5627
5849
  const before = result;
5628
5850
  result = result.replace(
5629
5851
  INNERMOST_DETAILS_RE,
5630
- (_m, indent, title, body) => reindentLines(toggleToCallout(title, body), indent)
5852
+ (_m, indent, attrs, title, body) => reindentLines(toggleToCallout(title ?? "", body, parseDesignAttrs(attrs).color), indent)
5631
5853
  );
5632
5854
  result = result.replace(
5633
5855
  INNERMOST_CALLOUT_RE,
5634
- (_m, indent, body) => reindentLines(calloutBodyToObsidian(body), indent)
5856
+ (_m, indent, attrs, body) => reindentLines(calloutBodyToObsidian(body, parseDesignAttrs(attrs)), indent)
5635
5857
  );
5636
5858
  result = result.replace(
5637
5859
  INNERMOST_COLUMNS_RE,
@@ -5639,16 +5861,38 @@ function convertContainers(content) {
5639
5861
  );
5640
5862
  if (result === before) break;
5641
5863
  }
5642
- return result;
5864
+ return stripQuotedColumnMarkers(result);
5865
+ }
5866
+ var QUOTED_COLUMN_EDGE_RE = new RegExp(
5867
+ `^(?:>[ \\t]?)+[ \\t]*%%${MARKER_BRAND_RE}:column-list:(?:start|end)%%[ \\t]*\\n?`,
5868
+ "gm"
5869
+ );
5870
+ var QUOTED_COLUMN_SEP_RE = new RegExp(
5871
+ `^((?:>[ \\t]?)+)[ \\t]*%%${MARKER_BRAND_RE}:column%%[ \\t]*$`,
5872
+ "gm"
5873
+ );
5874
+ var QUOTED_COLUMN_INLINE_RE = new RegExp(
5875
+ `[ \\t]*%%${MARKER_BRAND_RE}:column(?:-list:(?:start|end))?%%`,
5876
+ "g"
5877
+ );
5878
+ function stripQuotedColumnMarkers(content) {
5879
+ return content.replace(QUOTED_COLUMN_EDGE_RE, "").replace(QUOTED_COLUMN_SEP_RE, (_m, prefix) => prefix.trimEnd()).replace(
5880
+ /^(?:>[ \t]?).*%%.*$/gm,
5881
+ (line) => line.replace(QUOTED_COLUMN_INLINE_RE, "").trimEnd()
5882
+ );
5643
5883
  }
5644
- function calloutBodyToObsidian(body) {
5884
+ function calloutBodyToObsidian(body, style) {
5645
5885
  const lines = dedentContainerBody(body).split("\n").filter((l, i, arr) => !(i === 0 && l === "") && !(i === arr.length - 1 && l === ""));
5646
5886
  const firstLine = lines[0] ?? "";
5647
- const emojiMatch = /^([\p{Emoji}️‍]+)\s*(.*)/u.exec(firstLine);
5648
- const type = emojiMatch ? emojiToCalloutType(emojiMatch[1]) : "note";
5887
+ const emojiMatch = style?.icon || style?.color ? null : /^([\p{Emoji}️‍]+)\s*(.*)/u.exec(firstLine);
5888
+ const rawIcon = style?.icon ?? (emojiMatch ? emojiMatch[1] : void 0);
5889
+ const icon = rawIcon !== void 0 && /^https?:\/\//i.test(rawIcon) ? void 0 : rawIcon;
5890
+ const type = icon ? emojiToCalloutType(icon) : "note";
5649
5891
  const title = emojiMatch ? emojiMatch[2] : firstLine;
5650
5892
  const rest = lines.slice(1).join("\n").trim();
5651
- const calloutTitle = title ? `> [!${type}] ${title}` : `> [!${type}]`;
5893
+ const needsMarker = Boolean(style?.color) || icon !== void 0 && calloutTypeToEmoji(type) !== icon;
5894
+ const styleTail = needsMarker ? ` ${calloutStyleMarker({ icon, color: style?.color })}` : "";
5895
+ const calloutTitle = title ? `> [!${type}] ${title}${styleTail}` : `> [!${type}]${styleTail}`;
5652
5896
  const calloutBody = rest ? "\n" + rest.split("\n").map((line) => line.trim() ? `> ${line}` : ">").join("\n") : "";
5653
5897
  return calloutTitle + calloutBody;
5654
5898
  }
@@ -5661,7 +5905,7 @@ function convertPageMentions(content) {
5661
5905
  return `[[${cleaned}]]`;
5662
5906
  });
5663
5907
  result = result.replace(
5664
- /<mention-page\s+url="https?:\/\/(?:www\.)?notion\.so\/([a-f0-9]{32})"[^>]*?(?:\/>|>[\s\S]*?<\/mention-page>)/g,
5908
+ /<mention-page\s+url="https?:\/\/(?:[a-z]+\.)?notion\.(?:so|com)\/(?:p\/)?([a-f0-9]{32})"[^>]*?(?:\/>|>[\s\S]*?<\/mention-page>)/g,
5665
5909
  (_match, id) => `[[notion:${id}]]`
5666
5910
  );
5667
5911
  return result;
@@ -5691,19 +5935,52 @@ function preserveUnknownBlocks(content) {
5691
5935
  return result;
5692
5936
  }
5693
5937
  function convertSyncedBlockRef(content) {
5938
+ const unwrap = (match, openRe, closeRe) => match.replace(openRe, "").replace(closeRe, "").split("\n").map((l) => l.replace(/^\t/, "")).join("\n").trim();
5694
5939
  let result = content.replace(
5695
- /<synced_block_reference[^>]*>[\s\S]*?<\/synced_block_reference>/g,
5696
- (match) => {
5697
- const inner = match.replace(/<synced_block_reference[^>]*>\n?/, "").replace(/<\/synced_block_reference>/, "").split("\n").map((l) => l.replace(/^\t/, "")).join("\n").trim();
5698
- return inner;
5940
+ /<synced_block_reference([^>]*)>[\s\S]*?<\/synced_block_reference>/g,
5941
+ (match, attrs) => {
5942
+ const inner = unwrap(match, /<synced_block_reference[^>]*>\n?/, /<\/synced_block_reference>/);
5943
+ const url = /url="([^"]*)"/.exec(attrs)?.[1];
5944
+ if (!url) return inner;
5945
+ return `${syncedStartMarker("ref", url)}
5946
+ ${inner}
5947
+ ${SYNCED_END}`;
5699
5948
  }
5700
5949
  );
5701
5950
  result = result.replace(
5702
- /<synced_block[^>]*>\n?([\s\S]*?)<\/synced_block>/g,
5703
- (_match, inner) => inner.trim()
5951
+ /<synced_block([^>]*)>[\s\S]*?<\/synced_block>/g,
5952
+ (match, attrs) => {
5953
+ const inner = unwrap(match, /<synced_block[^>]*>\n?/, /<\/synced_block>/);
5954
+ const url = /url="([^"]*)"/.exec(attrs)?.[1];
5955
+ if (!url) return inner;
5956
+ return `${syncedStartMarker("orig", url)}
5957
+ ${inner}
5958
+ ${SYNCED_END}`;
5959
+ }
5704
5960
  );
5705
5961
  return result;
5706
5962
  }
5963
+ var SYNCED_MARKER_PAIR_RE = new RegExp(
5964
+ `%%${MARKER_BRAND_RE}:synced:start:(.*?)%%\\n?([\\s\\S]*?)%%${MARKER_BRAND_RE}:synced:end%%`,
5965
+ "g"
5966
+ );
5967
+ function restoreSyncedBlocks(content) {
5968
+ return content.replace(SYNCED_MARKER_PAIR_RE, (_match, params, body) => {
5969
+ const kind = /kind=(ref|orig)/.exec(params)?.[1] ?? "orig";
5970
+ const encoded = /url=([^&]*)/.exec(params)?.[1] ?? "";
5971
+ let url = encoded;
5972
+ try {
5973
+ url = decodeURIComponent(encoded);
5974
+ } catch {
5975
+ }
5976
+ if (!url) return body.trim();
5977
+ const tag = kind === "ref" ? "synced_block_reference" : "synced_block";
5978
+ const indented = body.trim().split("\n").map((l) => l.length > 0 ? ` ${l}` : l).join("\n");
5979
+ return `<${tag} url="${url}">
5980
+ ${indented}
5981
+ </${tag}>`;
5982
+ });
5983
+ }
5707
5984
  function convertMediaTags(content) {
5708
5985
  let result = content;
5709
5986
  result = result.replace(NOTION_AUDIO_RE, (_match, src, caption) => {
@@ -5746,17 +6023,21 @@ function convertSpans(content) {
5746
6023
  }
5747
6024
  return result;
5748
6025
  }
6026
+ var TOGGLE_COLOR_MARKER_RE = new RegExp(`\\s*%%${MARKER_BRAND_RE}:toggle-color:([a-z_]+)%%`);
5749
6027
  function convertTogglesToHtml(content) {
5750
- const calloutToggleRe = /^> \[!toggle\]-\s*(.+)\n((?:>.*\n?)*)/gm;
6028
+ const calloutToggleRe = /^> \[!toggle\]-[ \t]*(.*)\n?((?:>.*\n?)*)/gm;
5751
6029
  let result = content;
5752
6030
  let prev = "";
5753
6031
  let safety = 0;
5754
6032
  while (result !== prev && safety++ < 100) {
5755
6033
  prev = result;
5756
6034
  result = result.replace(calloutToggleRe, (_match, title, body) => {
6035
+ const colorMatch = TOGGLE_COLOR_MARKER_RE.exec(title);
6036
+ const cleanTitle = colorMatch ? title.replace(TOGGLE_COLOR_MARKER_RE, "") : title;
6037
+ const attrs = colorMatch ? ` color="${colorMatch[1]}"` : "";
5757
6038
  const bodyText = body.split("\n").map((line) => line.replace(/^>\s?/, "")).join("\n").trim();
5758
- return `<details>
5759
- <summary>${title.trim()}</summary>
6039
+ return `<details${attrs}>
6040
+ <summary>${cleanTitle.trim()}</summary>
5760
6041
 
5761
6042
  ${bodyText}
5762
6043
 
@@ -5783,6 +6064,7 @@ ${body}
5783
6064
  result = result.slice(0, startIdx) + html + result.slice(endIdx + TOGGLE_END.length);
5784
6065
  }
5785
6066
  result = result.replace(startRe, "").replace(endRe, "");
6067
+ result = result.replace(/^(<\/details>)(?!\n|$)/gm, "$1\n");
5786
6068
  return result;
5787
6069
  }
5788
6070
  function findMatchingEnd(content, startFrom) {
@@ -5803,29 +6085,53 @@ function findMatchingEnd(content, startFrom) {
5803
6085
  }
5804
6086
  return -1;
5805
6087
  }
6088
+ var CALLOUT_STYLE_MARKER_RE = new RegExp(`\\s*%%${MARKER_BRAND_RE}:callout-style:(.*?)%%`);
6089
+ function parseCalloutStyleParams(params) {
6090
+ const style = {};
6091
+ for (const kv of params.split("&")) {
6092
+ const eq = kv.indexOf("=");
6093
+ if (eq <= 0) continue;
6094
+ const key = kv.slice(0, eq);
6095
+ const value = decodeURIComponent(kv.slice(eq + 1));
6096
+ if (key === "icon") style.icon = value;
6097
+ else if (key === "color") style.color = value;
6098
+ }
6099
+ return style;
6100
+ }
5806
6101
  function convertObsidianCallouts(content) {
5807
6102
  const lines = content.split("\n");
5808
6103
  const result = [];
5809
6104
  let i = 0;
5810
6105
  while (i < lines.length) {
5811
- const headerMatch = /^> \[!(\w+)\]([-+])?\s*(.*)/.exec(lines[i]);
5812
- if (headerMatch) {
5813
- const type = headerMatch[1];
5814
- const title = headerMatch[3];
5815
- const emoji = calloutTypeToEmoji(type) ?? "\u{1F4A1}";
5816
- const calloutTitle = title ? `${emoji} ${title}` : emoji;
6106
+ const headerMatch = /^> \[!(\w+)\]([-+])?[ \t]*(.*)$/.exec(lines[i]);
6107
+ const type = headerMatch?.[1]?.toLowerCase();
6108
+ if (headerMatch && type !== "toggle" && type !== "tab") {
6109
+ let title = headerMatch[3];
6110
+ let style = {};
6111
+ const styleMatch = CALLOUT_STYLE_MARKER_RE.exec(title);
6112
+ if (styleMatch) {
6113
+ style = parseCalloutStyleParams(styleMatch[1]);
6114
+ title = title.replace(CALLOUT_STYLE_MARKER_RE, "");
6115
+ } else {
6116
+ style.icon = calloutTypeToEmoji(type);
6117
+ }
5817
6118
  const bodyLines = [];
5818
6119
  i++;
5819
- while (i < lines.length && lines[i].startsWith("> ")) {
5820
- bodyLines.push(lines[i].slice(2));
6120
+ while (i < lines.length && (lines[i] === ">" || lines[i].startsWith("> "))) {
6121
+ bodyLines.push(lines[i] === ">" ? "" : lines[i].slice(2));
5821
6122
  i++;
5822
6123
  }
5823
- result.push("::: callout");
5824
- result.push(calloutTitle);
5825
- if (bodyLines.length > 0) {
5826
- result.push(...bodyLines);
6124
+ const innerConverted = convertObsidianCallouts(convertTogglesToHtml(bodyLines.join("\n")));
6125
+ const attrs = `${style.icon ? ` icon="${style.icon}"` : ""}${style.color ? ` color="${style.color}"` : ""}`;
6126
+ result.push(`<callout${attrs}>`);
6127
+ const cleanTitle = title.trim();
6128
+ if (cleanTitle) result.push(` ${cleanTitle}`);
6129
+ if (innerConverted.trim() !== "") {
6130
+ for (const line of innerConverted.split("\n")) {
6131
+ result.push(line === "" ? "" : ` ${line}`);
6132
+ }
5827
6133
  }
5828
- result.push(":::");
6134
+ result.push("</callout>");
5829
6135
  } else {
5830
6136
  result.push(lines[i]);
5831
6137
  i++;
@@ -5869,12 +6175,38 @@ function convertPageLinks(content) {
5869
6175
  }
5870
6176
  function convertDatabaseBlocks(content) {
5871
6177
  return content.replace(
5872
- /<database[^>]*>([\s\S]*?)<\/database>/g,
5873
- (_match, title) => `**${title.trim()}** *(Notion DB)*`
6178
+ /<database\b([^>]*)>([\s\S]*?)<\/database>/g,
6179
+ (_match, attrs, title) => {
6180
+ const clean = title.trim();
6181
+ const placeholder = `**${clean}** *(Notion DB)*`;
6182
+ const idMatch = /\burl="[^"]*?([a-f0-9]{32})[^"]*"/.exec(attrs);
6183
+ if (!idMatch?.[1]) return placeholder;
6184
+ return `${placeholder}${compactMarker(
6185
+ `child-database:id=${idMatch[1]}&title=${encodeURIComponent(clean)}`
6186
+ )}`;
6187
+ }
5874
6188
  );
5875
6189
  }
5876
- function cleanInlineColorAttrs(content) {
5877
- return content.replace(/\s*\{color="[^"]*"\}/g, "");
6190
+ var TRAILING_BLOCK_COLOR_RE = /[ \t]*\{color="([a-z_]+)"\}(?=[ \t]*$)/gm;
6191
+ function convertBlockColorAttrs(content) {
6192
+ return content.replace(TRAILING_BLOCK_COLOR_RE, (_m, color) => ` ${blockColorMarker(color)}`).replace(/\s*\{color="[^"]*"\}/g, "");
6193
+ }
6194
+ var CALLOUT_HEAD_RE = /^((?:> )*)> \[!\w+\][-+]?/;
6195
+ function separateAdjacentCallouts(content) {
6196
+ const lines = content.split("\n");
6197
+ const out = [];
6198
+ for (const line of lines) {
6199
+ const head = CALLOUT_HEAD_RE.exec(line);
6200
+ if (head && out.length > 0) {
6201
+ const parentPrefix = head[1];
6202
+ const prev = out[out.length - 1];
6203
+ if (prev.startsWith(`${parentPrefix}>`)) {
6204
+ out.push(parentPrefix.trimEnd());
6205
+ }
6206
+ }
6207
+ out.push(line);
6208
+ }
6209
+ return out.join("\n");
5878
6210
  }
5879
6211
  function removeEmptyBlocks(content) {
5880
6212
  return content.replace(NOTION_EMPTY_BLOCK_RE, "\n");
@@ -5939,26 +6271,23 @@ var OBSIDIAN_MEDIA_PDF_RE = /\[📄\s*([^\]]*)\]\(([^)]+)\)/g;
5939
6271
  var OBSIDIAN_MEDIA_FILE_RE = /\[📎\s*([^\]]*)\]\(([^)]+)\)/g;
5940
6272
  function restoreMediaTags(content) {
5941
6273
  let result = content;
5942
- result = result.replace(OBSIDIAN_MEDIA_AUDIO_RE, (_match, caption, src) => {
5943
- return `<audio src="${src}">${caption}</audio>`;
5944
- });
5945
- result = result.replace(OBSIDIAN_MEDIA_VIDEO_RE, (_match, caption, src) => {
5946
- return `<video src="${src}">${caption}</video>`;
5947
- });
5948
- result = result.replace(OBSIDIAN_MEDIA_PDF_RE, (_match, caption, src) => {
5949
- return `<pdf src="${src}">${caption}</pdf>`;
5950
- });
5951
- result = result.replace(OBSIDIAN_MEDIA_FILE_RE, (_match, caption, src) => {
5952
- return `<file src="${src}">${caption}</file>`;
5953
- });
6274
+ const restore = (tag) => (_match, caption, src) => {
6275
+ const cap = caption.trim() === tag ? "" : caption;
6276
+ return `<${tag} src="${src}">${cap}</${tag}>`;
6277
+ };
6278
+ result = result.replace(OBSIDIAN_MEDIA_AUDIO_RE, restore("audio"));
6279
+ result = result.replace(OBSIDIAN_MEDIA_VIDEO_RE, restore("video"));
6280
+ result = result.replace(OBSIDIAN_MEDIA_PDF_RE, restore("pdf"));
6281
+ result = result.replace(OBSIDIAN_MEDIA_FILE_RE, restore("file"));
5954
6282
  return result;
5955
6283
  }
5956
6284
  var OBSIDIAN_TAB_RE = /^> \[!tab\]\s*(.+)\n((?:> .*\n?)*)/gm;
5957
6285
  function restoreTabBlocks(content) {
5958
- return content.replace(OBSIDIAN_TAB_RE, (_match, title, body) => {
6286
+ const result = content.replace(OBSIDIAN_TAB_RE, (_match, title, body) => {
5959
6287
  const unquoted = body.split("\n").map((line) => line.replace(/^> /, "")).join("\n").trim();
5960
6288
  return `<tab title="${title}">${unquoted}</tab>`;
5961
6289
  });
6290
+ return result.replace(/(<\/tab>)(?!\n|$)/g, "$1\n");
5962
6291
  }
5963
6292
  var OBSIDIAN_UNKNOWN_RE = new RegExp(
5964
6293
  `(?:\\[[^\\]]*\\]\\([^)]*\\))?%%${MARKER_BRAND_RE}:unknown:id=([^&]+)&type=([^%]+)%%`,
@@ -5991,6 +6320,42 @@ function restoreUnderlineSpans(content) {
5991
6320
  return `<span underline="true">${text}</span>`;
5992
6321
  });
5993
6322
  }
6323
+ var OBSIDIAN_BLOCK_COLOR_RE = new RegExp(
6324
+ `[ \\t]*%%${MARKER_BRAND_RE}:block-color:([a-z_]+)%%`,
6325
+ "g"
6326
+ );
6327
+ function restoreBlockColorMarkers(content) {
6328
+ return content.replace(OBSIDIAN_BLOCK_COLOR_RE, (_m, color) => ` {color="${color}"}`);
6329
+ }
6330
+ var COLUMN_REGION_RE = new RegExp(
6331
+ `^${escapeRegex3(COLUMN_LIST_START)}[ \\t]*\\n([\\s\\S]*?)\\n?^${escapeRegex3(COLUMN_LIST_END)}[ \\t]*$\\n?`,
6332
+ "gm"
6333
+ );
6334
+ function indentColumnLines(text) {
6335
+ return text.split("\n").map((line) => line === "" ? "" : ` ${line}`).join("\n");
6336
+ }
6337
+ function reassembleColumns(content) {
6338
+ const sepRe = new RegExp(`^${escapeRegex3(COLUMN_SEP)}[ \\t]*$`, "m");
6339
+ let result = content.replace(COLUMN_REGION_RE, (_m, inner) => {
6340
+ const cols = inner.split(new RegExp(sepRe.source, "gm")).map((c) => c.replace(/^\n/, "").replace(/\n$/, "")).filter((c) => c.trim() !== "");
6341
+ if (cols.length === 0) return "";
6342
+ const parts = cols.map((c) => `<column>
6343
+ ${indentColumnLines(c)}
6344
+ </column>`).join("\n");
6345
+ return `<columns>
6346
+ ${indentColumnLines(parts)}
6347
+ </columns>
6348
+ `;
6349
+ });
6350
+ result = result.replace(
6351
+ new RegExp(
6352
+ `^[>\\t ]*%%${MARKER_BRAND_RE}:(?:column-list:start|column-list:end|column)%%[ \\t]*\\n?`,
6353
+ "gm"
6354
+ ),
6355
+ ""
6356
+ );
6357
+ return result;
6358
+ }
5994
6359
  function unescapePipes(content) {
5995
6360
  const lines = content.split("\n");
5996
6361
  const result = [];
@@ -6054,6 +6419,14 @@ var DatabaseSyncer = class {
6054
6419
  propertyMapper = new PropertyMapper();
6055
6420
  baseFileGenerator = new BaseFileGenerator();
6056
6421
  sidecarGenerator = new SidecarGenerator();
6422
+ /**
6423
+ * 이번 프로세스가 실제로 기록한 .base 경로/DB 제목 (databaseId nohyph → info).
6424
+ * placeholder 임베드 재작성(F22)의 SSOT — 폴더명(하이픈 새니타이즈)과 .base 파일명
6425
+ * (sanitizeFileName: 공백·점 보존)은 규칙이 달라 localFolder 로 추측한 경로는 깨진
6426
+ * 임베드가 된다. generateBaseFile 은 매 pull 모든 DB 에 대해 실행되므로 pull 종료
6427
+ * 시점에는 성공한 DB 전체가 채워져 있다.
6428
+ */
6429
+ baseFileInfo = /* @__PURE__ */ new Map();
6057
6430
  async pullAll() {
6058
6431
  const databases = this.config.notion.databases;
6059
6432
  if (!databases || databases.length === 0) {
@@ -6108,16 +6481,33 @@ var DatabaseSyncer = class {
6108
6481
  }
6109
6482
  return { created, updated, conflicts: [], failed, writtenPaths: [] };
6110
6483
  }
6111
- async pullDatabase(dbConfig) {
6484
+ async pullDatabase(dbConfig, opts) {
6485
+ const pages = await this.notionClient.queryAllDatabasePages(
6486
+ dbConfig.databaseId,
6487
+ dbConfig.pullFilter
6488
+ );
6489
+ const parent = pages[0]?.parent;
6490
+ const ownerDbId = parent?.database_id;
6491
+ if (ownerDbId && !notionIdsEqual(ownerDbId, dbConfig.databaseId)) {
6492
+ const ownerFolder = opts?.resolveDbFolder?.(ownerDbId) ?? null;
6493
+ if (ownerFolder !== null) {
6494
+ const viewsConfig2 = await this.pullDatabaseViews(dbConfig);
6495
+ await this.generateBaseFile(dbConfig, viewsConfig2, ownerFolder);
6496
+ return {
6497
+ created: 0,
6498
+ updated: 0,
6499
+ conflicts: [],
6500
+ failed: [],
6501
+ writtenPaths: [],
6502
+ linkedOriginalDbId: ownerDbId
6503
+ };
6504
+ }
6505
+ }
6112
6506
  const schema = await this.notionClient.getDatabaseSchema(dbConfig.databaseId);
6113
6507
  this.propertyMapper.loadSchema(schema);
6114
6508
  await this.vaultFs.ensureFolder(dbConfig.localFolder);
6115
6509
  const viewsConfig = await this.pullDatabaseViews(dbConfig);
6116
6510
  await this.generateBaseFile(dbConfig, viewsConfig);
6117
- const pages = await this.notionClient.queryAllDatabasePages(
6118
- dbConfig.databaseId,
6119
- dbConfig.pullFilter
6120
- );
6121
6511
  let created = 0;
6122
6512
  let updated = 0;
6123
6513
  const conflicts = [];
@@ -6127,7 +6517,8 @@ var DatabaseSyncer = class {
6127
6517
  try {
6128
6518
  const record = this.stateDb.getByNotionId(page.id);
6129
6519
  if (record) {
6130
- if (page.last_edited_time === record.notionLastEdited) continue;
6520
+ const misplaced = !isDirectDbRowPath(dbConfig.localFolder, record.obsidianPath);
6521
+ if (page.last_edited_time === record.notionLastEdited && !misplaced) continue;
6131
6522
  const outcome = await this.pullDatabasePage(page, dbConfig);
6132
6523
  if (outcome.action === "written") {
6133
6524
  updated++;
@@ -6185,7 +6576,7 @@ var DatabaseSyncer = class {
6185
6576
  return null;
6186
6577
  }
6187
6578
  }
6188
- async generateBaseFile(dbConfig, viewsConfig) {
6579
+ async generateBaseFile(dbConfig, viewsConfig, rowsFolder) {
6189
6580
  try {
6190
6581
  const schemaFull = await this.notionClient.getDatabaseSchemaFull(dbConfig.databaseId);
6191
6582
  const dbName = viewsConfig?.databaseName || await this.notionClient.getDatabaseTitle(dbConfig.databaseId) || dbConfig.localFolder.split("/").pop() || "Database";
@@ -6200,11 +6591,12 @@ var DatabaseSyncer = class {
6200
6591
  databaseName: dbName,
6201
6592
  schema: schemaFull,
6202
6593
  viewsConfig: resolvedViews,
6203
- folderPath: dbConfig.localFolder
6594
+ folderPath: rowsFolder ?? dbConfig.localFolder
6204
6595
  });
6205
6596
  const safeName = sanitizeFileName(dbName);
6206
6597
  const basePath = `${dbConfig.localFolder}/${safeName}.base`;
6207
6598
  await this.vaultFs.writeFile(basePath, baseContent);
6599
+ this.baseFileInfo.set(dbConfig.databaseId.replace(/-/g, ""), { basePath, title: dbName });
6208
6600
  getLogger().debug(`[DB Sync] .base \uD30C\uC77C \uC0DD\uC131: ${basePath}`);
6209
6601
  await this.generateSidecar(dbConfig, dbName, safeName, schemaFull, resolvedViews);
6210
6602
  await this.cleanupStaleDbArtifacts(dbConfig.localFolder, safeName);
@@ -6247,7 +6639,10 @@ var DatabaseSyncer = class {
6247
6639
  viewsConfig: resolvedViews
6248
6640
  });
6249
6641
  const sidecarPath = `${dbConfig.localFolder}/${safeName}.notion.json`;
6250
- await this.vaultFs.writeFile(sidecarPath, this.sidecarGenerator.serialize(sidecar));
6642
+ const serialized = this.sidecarGenerator.serialize(sidecar);
6643
+ const existing = await this.vaultFs.readFile(sidecarPath).catch(() => null);
6644
+ if (existing === serialized) return;
6645
+ await this.vaultFs.writeFile(sidecarPath, serialized);
6251
6646
  if (sidecar.degraded.length > 0) {
6252
6647
  const unrep = sidecar.degraded.filter((d) => d.kind === "view-unrepresentable").length;
6253
6648
  const dropped = sidecar.degraded.length - unrep;
@@ -6259,6 +6654,42 @@ var DatabaseSyncer = class {
6259
6654
  getLogger().warn(`[DB Sync] \uC0AC\uC774\uB4DC\uCE74 \uC0DD\uC131 \uC2E4\uD328 (\uACC4\uC18D \uC9C4\uD589):`, error);
6260
6655
  }
6261
6656
  }
6657
+ /**
6658
+ * DB 행 `files` 속성의 notion-hosted 서명 URL 을 로컬 첨부로 로컬라이즈한다(P3-A).
6659
+ * 서명 URL 은 약 1시간 뒤 만료되어 frontmatter 에 남으면 깨진 링크가 되고, push 로
6660
+ * 되밀면 Notion 원본이 만료 URL 의 external 파일로 오염된다(실측). 다운로드 성공분은
6661
+ * `[[attachments/..]]` 위키링크로 대체한다 — Bases 카드 `image:` 는 위키링크 스칼라를
6662
+ * 렌더하므로 갤러리 커버도 유지된다. 원본 파일명은 raw 속성의 `name` 에서 취한다
6663
+ * (extractValue 는 URL 만 남긴다). 사용자가 넣은 진짜 외부 URL 은 그대로 둔다.
6664
+ */
6665
+ async localizeFileProperties(rawProps, properties, safeName) {
6666
+ for (const [key, rawProp] of Object.entries(rawProps)) {
6667
+ const prop = rawProp;
6668
+ if (prop?.type !== "files" || !Array.isArray(prop.files) || prop.files.length === 0) {
6669
+ continue;
6670
+ }
6671
+ const localized = [];
6672
+ let changed = false;
6673
+ for (const f of prop.files) {
6674
+ const url = f.type === "file" ? f.file?.url : f.external?.url;
6675
+ if (!url) continue;
6676
+ const caption = f.name?.trim() || `${safeName}-${key}`;
6677
+ try {
6678
+ const localPath = await this.imageHandler.localizeNotionFileUrl(url, caption);
6679
+ if (localPath) {
6680
+ localized.push(`[[${localPath}]]`);
6681
+ changed = true;
6682
+ } else {
6683
+ localized.push(url);
6684
+ }
6685
+ } catch {
6686
+ localized.push(url);
6687
+ }
6688
+ }
6689
+ if (!changed || localized.length === 0) continue;
6690
+ properties[key] = localized.length === 1 ? localized[0] : localized;
6691
+ }
6692
+ }
6262
6693
  async pullDatabasePage(page, dbConfig) {
6263
6694
  const title = this.notionClient.extractTitle(page);
6264
6695
  const safeName = sanitizeFileName(title);
@@ -6266,23 +6697,16 @@ var DatabaseSyncer = class {
6266
6697
  page.properties
6267
6698
  );
6268
6699
  properties.title = title;
6700
+ await this.localizeFileProperties(
6701
+ page.properties,
6702
+ properties,
6703
+ safeName
6704
+ );
6269
6705
  const cover = this.notionClient.extractCover(page);
6270
6706
  const icon = this.notionClient.extractIcon(page);
6271
6707
  if (cover) {
6272
- try {
6273
- const coverResult = await this.imageHandler.downloadAllImages(
6274
- `![cover](${cover.url})`,
6275
- `${safeName}-cover`
6276
- );
6277
- const resolved = coverResult.content.match(/!\[cover\]\((.+?)\)/)?.[1];
6278
- if (resolved && !/^https?:\/\//i.test(resolved)) {
6279
- properties.cover = `[[${resolved}]]`;
6280
- } else {
6281
- properties.cover = cover.url;
6282
- }
6283
- } catch {
6284
- properties.cover = cover.url;
6285
- }
6708
+ const local = await this.imageHandler.localizeNotionFileUrl(cover.url, `${safeName}-cover`);
6709
+ properties.cover = local ? `[[${local}]]` : cover.url;
6286
6710
  }
6287
6711
  if (icon) {
6288
6712
  properties.icon = icon.value;
@@ -6312,9 +6736,10 @@ var DatabaseSyncer = class {
6312
6736
  }
6313
6737
  }
6314
6738
  const existingRecord = this.stateDb.getByNotionId(page.id);
6739
+ const recordPath = existingRecord?.obsidianPath;
6315
6740
  let filePath;
6316
- if (existingRecord?.obsidianPath) {
6317
- filePath = existingRecord.obsidianPath;
6741
+ if (recordPath && isDirectDbRowPath(dbConfig.localFolder, recordPath)) {
6742
+ filePath = recordPath;
6318
6743
  } else {
6319
6744
  filePath = resolveDbRowPath(
6320
6745
  dbConfig.localFolder,
@@ -6336,7 +6761,7 @@ var DatabaseSyncer = class {
6336
6761
  if (existingRecord) {
6337
6762
  let localContent = "";
6338
6763
  try {
6339
- localContent = await this.vaultFs.readFile(filePath);
6764
+ localContent = await this.vaultFs.readFile(recordPath ?? filePath);
6340
6765
  } catch {
6341
6766
  localContent = "";
6342
6767
  }
@@ -6353,17 +6778,26 @@ var DatabaseSyncer = class {
6353
6778
  strategy: this.config.sync.conflictStrategy
6354
6779
  });
6355
6780
  if (resolution.action === "skip") {
6356
- return { action: "skipped", path: filePath };
6781
+ return { action: "skipped", path: recordPath ?? filePath };
6357
6782
  }
6358
6783
  if (resolution.action === "conflict") {
6359
6784
  this.stateDb.updateStatus(existingRecord.id, "conflict");
6360
- return { action: "conflict", path: filePath, conflict: resolution.conflict };
6785
+ return {
6786
+ action: "conflict",
6787
+ path: recordPath ?? filePath,
6788
+ conflict: resolution.conflict
6789
+ };
6361
6790
  }
6362
6791
  }
6363
6792
  await this.vaultFs.ensureFolder(dbConfig.localFolder);
6364
6793
  await this.vaultFs.writeFile(filePath, finalContent);
6794
+ const rehomed = existingRecord && recordPath && recordPath !== filePath;
6365
6795
  const hash = computeHash(finalContent);
6366
6796
  this.stateDb.transaction(() => {
6797
+ if (rehomed) {
6798
+ this.stateDb.updatePath(existingRecord.id, filePath);
6799
+ this.stateDb.deleteWikilink(recordPath);
6800
+ }
6367
6801
  this.stateDb.upsert({
6368
6802
  obsidianPath: filePath,
6369
6803
  notionPageId: page.id,
@@ -6383,6 +6817,12 @@ var DatabaseSyncer = class {
6383
6817
  aliases: extractAliases(properties)
6384
6818
  });
6385
6819
  });
6820
+ if (rehomed) {
6821
+ try {
6822
+ await this.vaultFs.deleteFile(recordPath);
6823
+ } catch {
6824
+ }
6825
+ }
6386
6826
  return { action: "written", path: filePath };
6387
6827
  }
6388
6828
  async pushDatabase(dbConfig) {
@@ -6820,8 +7260,76 @@ function resolveFrontmatterRelations(content, idToTitle) {
6820
7260
  return { content: stringifyFrontmatter(parsed.content, resolved), count };
6821
7261
  }
6822
7262
 
7263
+ // src/utils/inline-db-refs.ts
7264
+ function extractInlineDbIds(markdown) {
7265
+ const ids = /* @__PURE__ */ new Set();
7266
+ const tagRe = /<database\b[^>]*\burl="[^"]*?([a-f0-9]{32})[^"]*"[^>]*>/g;
7267
+ let m;
7268
+ while ((m = tagRe.exec(markdown)) !== null) {
7269
+ if (m[1]) ids.add(m[1]);
7270
+ }
7271
+ const markerRe = new RegExp(`${MARKER_BRAND_RE}:child-database:id=([a-f0-9-]{32,36})`, "g");
7272
+ while ((m = markerRe.exec(markdown)) !== null) {
7273
+ if (m[1]) ids.add(m[1].replace(/-/g, ""));
7274
+ }
7275
+ return [...ids];
7276
+ }
7277
+
7278
+ // src/sync/db-placeholder-rewriter.ts
7279
+ var ID32 = "[a-f0-9]{32}";
7280
+ var IDUUID = "[a-f0-9-]{32,36}";
7281
+ var ENC_TITLE = "(?:&title=(.*?))?";
7282
+ var INLINE_PLACEHOLDER_RE = new RegExp(
7283
+ `\\*\\*([^\\n*]*)\\*\\*\\s*\\*\\(Notion DB\\)\\*\\s*%%\\s*${MARKER_BRAND_RE}:child-database:id=(${ID32})${ENC_TITLE}\\s*%%`,
7284
+ "g"
7285
+ );
7286
+ var CALLOUT_PLACEHOLDER_RE = new RegExp(
7287
+ `^((?:[ \\t]*>)+[ \\t]*)\\[!database\\][ \\t]*([^\\n]*)\\n(?:[ \\t]*>)+[ \\t]*%%\\s*${MARKER_BRAND_RE}:child-database:id=(${IDUUID})${ENC_TITLE}\\s*%%[ \\t]*$`,
7288
+ "gm"
7289
+ );
7290
+ function decodeTitle(encoded) {
7291
+ if (!encoded) return "";
7292
+ try {
7293
+ return decodeURIComponent(encoded);
7294
+ } catch {
7295
+ return encoded;
7296
+ }
7297
+ }
7298
+ function toAlias(...candidates) {
7299
+ for (const c of candidates) {
7300
+ const cleaned = (c ?? "").replace(/[|[\]\n]/g, "").trim();
7301
+ if (cleaned) return cleaned;
7302
+ }
7303
+ return "";
7304
+ }
7305
+ function rewriteDbPlaceholders(content, resolve) {
7306
+ let rewrites = 0;
7307
+ let result = content.replace(
7308
+ INLINE_PLACEHOLDER_RE,
7309
+ (match, inlineTitle, id, encTitle) => {
7310
+ const target = resolve(id);
7311
+ if (!target) return match;
7312
+ const alias = toAlias(decodeTitle(encTitle), inlineTitle, target.title, "Database");
7313
+ rewrites++;
7314
+ return `![[${target.basePath}|${alias}]]`;
7315
+ }
7316
+ );
7317
+ result = result.replace(
7318
+ CALLOUT_PLACEHOLDER_RE,
7319
+ (match, prefix, calloutTitle, id, encTitle) => {
7320
+ const target = resolve(id.replace(/-/g, ""));
7321
+ if (!target) return match;
7322
+ const alias = toAlias(decodeTitle(encTitle), calloutTitle, target.title, "Database");
7323
+ rewrites++;
7324
+ return `${prefix}![[${target.basePath}|${alias}]]`;
7325
+ }
7326
+ );
7327
+ return { content: result, rewrites };
7328
+ }
7329
+
6823
7330
  // src/sync/orchestrator.ts
6824
7331
  var INACCESSIBLE_DBS_META_KEY = "inaccessible_dbs";
7332
+ var LINKED_DBS_META_KEY = "linked_dbs";
6825
7333
  var INCREMENTAL_SAFETY_WINDOW_MS = 15 * 6e4;
6826
7334
  function strategyToChoice(strategy) {
6827
7335
  switch (strategy) {
@@ -7399,7 +7907,20 @@ var SyncOrchestrator = class _SyncOrchestrator {
7399
7907
  let dbTitle;
7400
7908
  try {
7401
7909
  const info = await this.notionClient.getDatabaseSyncability(dbId);
7910
+ if (info.linkedOriginalDbId) {
7911
+ getLogger().info(
7912
+ `[Im-Nobsidian] DB ${dbId}: linked view \uCEE8\uD14C\uC774\uB108(\uC6D0\uBCF8 ${info.linkedOriginalDbId}) \u2192 \uC6D0\uBCF8\uC73C\uB85C \uD574\uC18C(F25)`
7913
+ );
7914
+ return { kind: "linked", originalDbId: info.linkedOriginalDbId };
7915
+ }
7402
7916
  if (!info.queryable) {
7917
+ const linked = await this.notionClient.resolveLinkedDatabase(dbId);
7918
+ if (linked) {
7919
+ getLogger().info(
7920
+ `[Im-Nobsidian] DB ${dbId}: linked view("${linked.viewName}") \u2192 \uC6D0\uBCF8 ${linked.originalDbId} \uB85C \uD574\uC18C`
7921
+ );
7922
+ return { kind: "linked", originalDbId: linked.originalDbId };
7923
+ }
7403
7924
  return { kind: "inaccessible" };
7404
7925
  }
7405
7926
  dbTitle = info.title;
@@ -7414,8 +7935,9 @@ var SyncOrchestrator = class _SyncOrchestrator {
7414
7935
  const obsPath = parentEntry.obsidianPath;
7415
7936
  if (obsPath.endsWith(".md")) {
7416
7937
  const parts = obsPath.split("/");
7417
- parts.pop();
7418
- parentFolder = parts.length > 0 ? parts.join("/") : obsPath.replace(/\.md$/, "");
7938
+ const fileBase = (parts.pop() ?? "").replace(/\.md$/, "");
7939
+ const dirName = parts[parts.length - 1] ?? "";
7940
+ parentFolder = fileBase === dirName ? parts.join("/") : [...parts, fileBase].filter(Boolean).join("/");
7419
7941
  } else {
7420
7942
  parentFolder = obsPath;
7421
7943
  }
@@ -7449,6 +7971,19 @@ var SyncOrchestrator = class _SyncOrchestrator {
7449
7971
  return /* @__PURE__ */ new Set();
7450
7972
  }
7451
7973
  }
7974
+ /** linked view 컨테이너 → 원본 DB 매핑을 상태 메타에서 로드한다(nohyph → nohyph). */
7975
+ loadLinkedDbMap() {
7976
+ const raw = this.stateDb.getMeta(LINKED_DBS_META_KEY);
7977
+ if (!raw) return /* @__PURE__ */ new Map();
7978
+ try {
7979
+ const obj = JSON.parse(raw);
7980
+ return new Map(
7981
+ Object.entries(obj).map(([k, v]) => [k.replace(/-/g, ""), v.replace(/-/g, "")])
7982
+ );
7983
+ } catch {
7984
+ return /* @__PURE__ */ new Map();
7985
+ }
7986
+ }
7452
7987
  async pullDiscoveredDatabases(writtenPaths, failed, conflicts, forceRediscovery = false) {
7453
7988
  if (this.isDatabaseMode) return { created: 0, updated: 0 };
7454
7989
  let created = 0;
@@ -7471,6 +8006,23 @@ var SyncOrchestrator = class _SyncOrchestrator {
7471
8006
  ...dbConfigs.map((c) => c.databaseId.replace(/-/g, ""))
7472
8007
  ]);
7473
8008
  let changed = false;
8009
+ const folderOwner = new Map(
8010
+ (this.config.notion.databases ?? []).map((d) => [d.localFolder, d.databaseId])
8011
+ );
8012
+ const repairedCount = repairDbFolderCollisions(dbConfigs, folderOwner);
8013
+ for (const c of dbConfigs) folderOwner.set(c.localFolder, c.databaseId);
8014
+ if (repairedCount > 0) {
8015
+ changed = true;
8016
+ getLogger().info(`[Im-Nobsidian] \uB3D9\uBA85 DB \uD3F4\uB354 \uCDA9\uB3CC ${repairedCount}\uAC74 \uBD84\uB9AC(F24)`);
8017
+ }
8018
+ const claimFolder = (config) => {
8019
+ config.localFolder = resolveDbFolderPath(
8020
+ config.localFolder,
8021
+ config.databaseId,
8022
+ (f) => folderOwner.get(f) ?? null
8023
+ );
8024
+ folderOwner.set(config.localFolder, config.databaseId);
8025
+ };
7474
8026
  const inaccessibleIds = this.loadInaccessibleDbIds();
7475
8027
  let inaccessibleChanged = false;
7476
8028
  const degrade = (rawId) => {
@@ -7483,52 +8035,119 @@ var SyncOrchestrator = class _SyncOrchestrator {
7483
8035
  `[Im-Nobsidian] DB ${rawId}: \uC811\uADFC \uAC00\uB2A5\uD55C data source \uC5C6\uC74C(\uB9C1\uD06C\uB4DC/\uBBF8\uACF5\uC720/\uC0AD\uC81C \uCD94\uC815) \u2014 \uB3D9\uAE30\uD654 \uB300\uC0C1\uC5D0\uC11C \uC81C\uC678(\uC81C\uBAA9\uC740 \uBD80\uBAA8 \uD398\uC774\uC9C0\uC5D0 \uBCF4\uC874)`
7484
8036
  );
7485
8037
  };
7486
- if (dbConfigs.length === 0 || forceRediscovery) {
7487
- const discovered = await this.discoverChildDatabases();
7488
- for (const { dbId, parentPageId } of discovered) {
7489
- const nohyph = dbId.replace(/-/g, "");
7490
- if (knownIds.has(nohyph) || inaccessibleIds.has(nohyph)) continue;
7491
- const outcome = await this.buildDiscoveredDbConfig(dbId, parentPageId);
7492
- knownIds.add(nohyph);
7493
- if (outcome.kind === "ok") {
7494
- dbConfigs.push(outcome.config);
8038
+ if (forceRediscovery && inaccessibleIds.size > 0) {
8039
+ getLogger().info(
8040
+ `[Im-Nobsidian] --force: \uC811\uADFC \uBD88\uAC00 DB ${inaccessibleIds.size}\uAC1C denylist \uC7AC\uAC80\uC99D`
8041
+ );
8042
+ inaccessibleIds.clear();
8043
+ inaccessibleChanged = true;
8044
+ }
8045
+ const linkedMap = this.loadLinkedDbMap();
8046
+ let linkedChanged = false;
8047
+ const register = async (dbId, parentPageId) => {
8048
+ const nohyph = dbId.replace(/-/g, "");
8049
+ if (knownIds.has(nohyph) || inaccessibleIds.has(nohyph)) return;
8050
+ const registerOriginal = async (origNohyph) => {
8051
+ if (knownIds.has(origNohyph) || inaccessibleIds.has(origNohyph)) return;
8052
+ const origId = normalizeNotionId(origNohyph);
8053
+ const outcome2 = await this.buildDiscoveredDbConfig(origId, parentPageId);
8054
+ knownIds.add(origNohyph);
8055
+ if (outcome2.kind === "ok") {
8056
+ claimFolder(outcome2.config);
8057
+ dbConfigs.push(outcome2.config);
7495
8058
  changed = true;
7496
- } else if (outcome.kind === "inaccessible") {
7497
- degrade(dbId);
8059
+ } else if (outcome2.kind !== "error") {
8060
+ degrade(origId);
7498
8061
  }
8062
+ };
8063
+ const knownOriginal = linkedMap.get(nohyph);
8064
+ if (knownOriginal) {
8065
+ await registerOriginal(knownOriginal);
8066
+ return;
7499
8067
  }
7500
- }
7501
- for (const [nohyph, parentPageId] of this._inlineDbRefs) {
7502
- if (knownIds.has(nohyph) || inaccessibleIds.has(nohyph)) continue;
7503
- const dbId = normalizeNotionId(nohyph);
7504
8068
  const outcome = await this.buildDiscoveredDbConfig(dbId, parentPageId);
7505
8069
  knownIds.add(nohyph);
7506
8070
  if (outcome.kind === "ok") {
8071
+ claimFolder(outcome.config);
7507
8072
  dbConfigs.push(outcome.config);
7508
8073
  changed = true;
8074
+ } else if (outcome.kind === "linked") {
8075
+ const origNohyph = outcome.originalDbId.replace(/-/g, "");
8076
+ linkedMap.set(nohyph, origNohyph);
8077
+ linkedChanged = true;
8078
+ await registerOriginal(origNohyph);
7509
8079
  } else if (outcome.kind === "inaccessible") {
7510
8080
  degrade(dbId);
7511
8081
  }
8082
+ };
8083
+ if (dbConfigs.length === 0 || forceRediscovery) {
8084
+ const discovered = await this.discoverChildDatabases();
8085
+ for (const { dbId, parentPageId } of discovered) {
8086
+ await register(dbId, parentPageId);
8087
+ }
8088
+ }
8089
+ for (const [nohyph, parentPageId] of this._inlineDbRefs) {
8090
+ await register(normalizeNotionId(nohyph), parentPageId);
7512
8091
  }
7513
8092
  const stillSyncable = [];
7514
- for (const dbConfig of dbConfigs) {
7515
- if (inaccessibleIds.has(dbConfig.databaseId.replace(/-/g, ""))) continue;
7516
- try {
7517
- const dbResult = await this.databaseSyncer.pullDatabase(dbConfig);
7518
- created += dbResult.created;
7519
- updated += dbResult.updated;
7520
- conflicts.push(...dbResult.conflicts);
7521
- failed.push(...dbResult.failed);
7522
- writtenPaths.push(...dbResult.writtenPaths);
7523
- stillSyncable.push(dbConfig);
7524
- } catch (error) {
7525
- if (isNotionObjectNotFound(error)) {
7526
- degrade(dbConfig.databaseId);
7527
- } else {
7528
- getLogger().warn(`[Im-Nobsidian] DB ${dbConfig.databaseId} \uB3D9\uAE30\uD654 \uC2E4\uD328:`, error);
8093
+ let queue = [...dbConfigs];
8094
+ const MAX_DISCOVERY_ROUNDS = 4;
8095
+ const resolveDbFolder = (dbId) => {
8096
+ const key = dbId.replace(/-/g, "");
8097
+ const hit = dbConfigs.find((c) => c.databaseId.replace(/-/g, "") === key) ?? (this.config.notion.databases ?? []).find((c) => c.databaseId.replace(/-/g, "") === key);
8098
+ return hit?.localFolder ?? null;
8099
+ };
8100
+ for (let round = 1; queue.length > 0; round++) {
8101
+ const roundRowPaths = [];
8102
+ for (const dbConfig of queue) {
8103
+ if (inaccessibleIds.has(dbConfig.databaseId.replace(/-/g, ""))) continue;
8104
+ try {
8105
+ const dbResult = await this.databaseSyncer.pullDatabase(dbConfig, { resolveDbFolder });
8106
+ if (dbResult.linkedOriginalDbId) {
8107
+ linkedMap.set(
8108
+ dbConfig.databaseId.replace(/-/g, ""),
8109
+ dbResult.linkedOriginalDbId.replace(/-/g, "")
8110
+ );
8111
+ linkedChanged = true;
8112
+ changed = true;
8113
+ getLogger().info(
8114
+ `[Im-Nobsidian] DB ${dbConfig.databaseId}: linked view \uCEE8\uD14C\uC774\uB108 \uAC10\uC9C0 \u2192 \uD589\uC740 \uC6D0\uBCF8 ${dbResult.linkedOriginalDbId} \uD3F4\uB354\uAC00 \uB2E8\uC77C \uC18C\uC720(F25)`
8115
+ );
8116
+ continue;
8117
+ }
8118
+ created += dbResult.created;
8119
+ updated += dbResult.updated;
8120
+ conflicts.push(...dbResult.conflicts);
8121
+ failed.push(...dbResult.failed);
8122
+ writtenPaths.push(...dbResult.writtenPaths);
8123
+ roundRowPaths.push(...dbResult.writtenPaths);
7529
8124
  stillSyncable.push(dbConfig);
8125
+ } catch (error) {
8126
+ if (isNotionObjectNotFound(error)) {
8127
+ degrade(dbConfig.databaseId);
8128
+ } else {
8129
+ getLogger().warn(`[Im-Nobsidian] DB ${dbConfig.databaseId} \uB3D9\uAE30\uD654 \uC2E4\uD328:`, error);
8130
+ stillSyncable.push(dbConfig);
8131
+ }
7530
8132
  }
7531
8133
  }
8134
+ const before = dbConfigs.length;
8135
+ for (const ref of await this.collectRowInlineRefs(roundRowPaths)) {
8136
+ await register(ref.dbId, ref.parentPageId);
8137
+ }
8138
+ queue = dbConfigs.slice(before);
8139
+ if (queue.length > 0 && round >= MAX_DISCOVERY_ROUNDS) {
8140
+ getLogger().warn(
8141
+ `[Im-Nobsidian] \uC911\uCCA9 DB \uBC1C\uACAC \uB77C\uC6B4\uB4DC \uD55C\uB3C4(${MAX_DISCOVERY_ROUNDS}) \uB3C4\uB2EC \u2014 ${queue.length}\uAC1C\uB294 \uB2E4\uC74C pull \uC5D0\uC11C \uB3D9\uAE30\uD654`
8142
+ );
8143
+ stillSyncable.push(...queue);
8144
+ break;
8145
+ }
8146
+ if (queue.length > 0) {
8147
+ getLogger().info(
8148
+ `[Im-Nobsidian] DB row \uBCF8\uBB38\uC5D0\uC11C \uC911\uCCA9 DB ${queue.length}\uAC1C \uCD94\uAC00 \uBC1C\uACAC(\uB77C\uC6B4\uB4DC ${round + 1})`
8149
+ );
8150
+ }
7532
8151
  }
7533
8152
  if (changed || inaccessibleChanged || stillSyncable.length !== dbConfigs.length) {
7534
8153
  this.stateDb.setMeta("discovered_dbs", JSON.stringify(stillSyncable));
@@ -7536,11 +8155,68 @@ var SyncOrchestrator = class _SyncOrchestrator {
7536
8155
  if (inaccessibleChanged) {
7537
8156
  this.stateDb.setMeta(INACCESSIBLE_DBS_META_KEY, JSON.stringify([...inaccessibleIds]));
7538
8157
  }
8158
+ if (linkedChanged) {
8159
+ this.stateDb.setMeta(LINKED_DBS_META_KEY, JSON.stringify(Object.fromEntries(linkedMap)));
8160
+ }
8161
+ await this.rewriteDbPlaceholderEmbeds(writtenPaths, stillSyncable, linkedMap);
7539
8162
  } catch (error) {
7540
8163
  getLogger().warn("[Im-Nobsidian] child_database \uC790\uB3D9 \uBC1C\uACAC \uC2E4\uD328:", error);
7541
8164
  }
7542
8165
  return { created, updated };
7543
8166
  }
8167
+ /**
8168
+ * pull 산출 md 의 인라인 DB placeholder(`**제목** *(Notion DB)*` + 보존 마커)를
8169
+ * `![[<실제 .base 경로>|제목]]` 임베드로 재작성한다(F22) — folder note 본문에서
8170
+ * DB 가 텍스트 한 줄이 아니라 실제 Bases 뷰로 보이게 하는 마지막 조각. linked view
8171
+ * 컨테이너는 linkedMap 을 거쳐 원본 DB 의 .base 로 향한다. .base 경로는 DatabaseSyncer
8172
+ * 가 실제 기록한 경로(baseFileInfo)가 SSOT — 폴더명과 .base 파일명은 새니타이즈 규칙이
8173
+ * 달라(공백→하이픈 vs 공백 보존) localFolder 로 추측하면 깨진 임베드가 된다(E2E 실측
8174
+ * 91/158건). 최종적으로 .base 실존까지 확인해, 없으면 placeholder 를 마커째 보존한다.
8175
+ */
8176
+ async rewriteDbPlaceholderEmbeds(paths, dbConfigs, linkedMap) {
8177
+ const targets = /* @__PURE__ */ new Map();
8178
+ const addTarget = (databaseId, localFolder) => {
8179
+ const nohyph = databaseId.replace(/-/g, "");
8180
+ if (targets.has(nohyph)) return;
8181
+ const info = this.databaseSyncer.baseFileInfo.get(nohyph);
8182
+ if (info) {
8183
+ targets.set(nohyph, { basePath: info.basePath, title: info.title });
8184
+ return;
8185
+ }
8186
+ const safeName = localFolder.split("/").pop() ?? localFolder;
8187
+ targets.set(nohyph, { basePath: `${localFolder}/${safeName}.base`, title: safeName });
8188
+ };
8189
+ for (const cfg of dbConfigs) addTarget(cfg.databaseId, cfg.localFolder);
8190
+ for (const d of this.config.notion.databases ?? []) addTarget(d.databaseId, d.localFolder);
8191
+ for (const [key, t] of [...targets]) {
8192
+ if (!await this.vaultFs.exists(t.basePath)) targets.delete(key);
8193
+ }
8194
+ if (targets.size === 0) return;
8195
+ const resolve = (nohyph) => targets.get(nohyph) ?? targets.get(linkedMap.get(nohyph) ?? "") ?? null;
8196
+ let total = 0;
8197
+ for (const filePath of paths) {
8198
+ if (!filePath.endsWith(".md")) continue;
8199
+ try {
8200
+ const content = await this.vaultFs.readFile(filePath);
8201
+ const { content: next, rewrites } = rewriteDbPlaceholders(content, resolve);
8202
+ if (rewrites === 0) continue;
8203
+ await this.vaultFs.writeFile(filePath, next);
8204
+ total += rewrites;
8205
+ const record = this.stateDb.getByPath(filePath);
8206
+ if (record) {
8207
+ const stat2 = await this.vaultFs.getFileStat(filePath);
8208
+ this.stateDb.transaction(() => {
8209
+ this.stateDb.updateHash(record.id, computeHash(next), Buffer.from(next, "utf-8"));
8210
+ if (stat2) this.stateDb.updateStatCache(record.id, stat2.mtime, stat2.size);
8211
+ });
8212
+ }
8213
+ } catch {
8214
+ }
8215
+ }
8216
+ if (total > 0) {
8217
+ getLogger().info(`[Im-Nobsidian] \uC778\uB77C\uC778 DB \uC784\uBCA0\uB4DC \uC7AC\uC791\uC131: ${total}\uAC74`);
8218
+ }
8219
+ }
7544
8220
  async resolveNotionLinks(paths) {
7545
8221
  let totalResolved = 0;
7546
8222
  const allRecords = this.stateDb.getAll();
@@ -8417,20 +9093,42 @@ var SyncOrchestrator = class _SyncOrchestrator {
8417
9093
  } catch {
8418
9094
  }
8419
9095
  }
8420
- return { content: await this.blockConverter.notionBlocksToMarkdown(pageId), compact: false };
9096
+ const fallback = await this.blockConverter.notionBlocksToMarkdown(pageId);
9097
+ this.collectInlineDbRefs(pageId, fallback);
9098
+ return { content: fallback, compact: false };
8421
9099
  }
8422
- // Markdown API는 인라인 데이터베이스를 다음처럼 렌더한다(컬럼/synced_block 내부 포함):
8423
- // <database url="https://www.notion.so/<id-nohyph>" inline="true"
8424
- // data-source-url="collection://<ds-id>">Title</database>
8425
- // url 안의 32자리 hex 가 databaseId 이므로, 별도 블록 트리 재귀 없이 이 태그만 파싱해
8426
- // 깊이 중첩된 child_database 까지 폴더+.base 동기화 대상으로 등록한다.
9100
+ // Markdown API는 인라인 데이터베이스를 <database url="..." ...>Title</database> 로 렌더한다
9101
+ // (컬럼/콜아웃/synced_block 내부 포함). url 의 32자리 hex 가 databaseId 이므로 블록 트리
9102
+ // 재귀 없이 이 신호만으로 깊이 중첩된 child_database 를 발견한다. url 호스트는
9103
+ // www.notion.so / app.notion.com/p 두 형태가 실측돼 고정하지 않는다 — 규칙은
9104
+ // extractInlineDbIds(blocks-API 폴백 마커 겸용) 참조.
8427
9105
  collectInlineDbRefs(parentPageId, rawMarkdown) {
8428
- const re = /<database\b[^>]*\burl="https:\/\/www\.notion\.so\/([a-f0-9]{32})"[^>]*>/g;
8429
- let m;
8430
- while ((m = re.exec(rawMarkdown)) !== null) {
8431
- const dbId = m[1];
8432
- if (dbId) this._inlineDbRefs.set(dbId, parentPageId);
9106
+ for (const dbId of extractInlineDbIds(rawMarkdown)) {
9107
+ this._inlineDbRefs.set(dbId, parentPageId);
9108
+ }
9109
+ }
9110
+ /**
9111
+ * 이번 라운드에 기록된 DB row md 를 읽어 본문의 인라인 DB 참조를 수집한다.
9112
+ * row 페이지는 DatabaseSyncer 자체 파이프라인으로 변환돼 fetchPageMarkdown 의
9113
+ * _inlineDbRefs 수집을 타지 않는다 — 디스크에 남은 보존 마커가 유일한 발견 신호다.
9114
+ * 부모 Notion 페이지 id 는 state DB 역조회(getByPath)로 얻는다(row upsert 직후라 항상 존재).
9115
+ */
9116
+ async collectRowInlineRefs(rowPaths) {
9117
+ const refs = [];
9118
+ for (const path of rowPaths) {
9119
+ if (!path.endsWith(".md")) continue;
9120
+ try {
9121
+ const ids = extractInlineDbIds(await this.vaultFs.readFile(path));
9122
+ if (ids.length === 0) continue;
9123
+ const record = this.stateDb.getByPath(path);
9124
+ if (!record?.notionPageId) continue;
9125
+ for (const id of ids) {
9126
+ refs.push({ dbId: normalizeNotionId(id), parentPageId: record.notionPageId });
9127
+ }
9128
+ } catch {
9129
+ }
8433
9130
  }
9131
+ return refs;
8434
9132
  }
8435
9133
  // pull 시 url 기반 page mention 은 `[[notion:<id>]]` 로 1차 변환된다(notionEnhancedToObsidian).
8436
9134
  // 이를 state DB 역조회로 원래 `[[제목]]` 위키링크로 복원해 push↔pull 라운드트립을 수렴시킨다.
@@ -8713,7 +9411,7 @@ var NodeVaultFS = class {
8713
9411
  if (this.isExcluded(relativePath)) continue;
8714
9412
  if (entry.isDirectory()) {
8715
9413
  await this.walkDirNonMd(fullPath, result);
8716
- } else if (entry.isFile() && !entry.name.endsWith(".md") && !entry.name.endsWith(".base")) {
9414
+ } else if (entry.isFile() && !entry.name.endsWith(".md")) {
8717
9415
  const fileStat = await stat(fullPath);
8718
9416
  result.push({
8719
9417
  path: relativePath,