import { Injectable, Logger } from '@nestjs/common';
import {
OutlineItem,
MeetingNote,
DevLogEntry,
TodoItem,
} from './logseq.types';
/**
* 파싱된 블록 구조
* Logseq에 개별 블록으로 추가할 수 있는 형태
*/
export interface ParsedBlock {
content: string;
children?: ParsedBlock[];
}
/**
* 블록 콘텐츠 검증 결과
*/
export interface BlockValidationResult {
isValid: boolean;
issues: string[];
/** 문제가 있는 콘텐츠 패턴들 */
problematicPatterns: {
type: 'multiple-lists' | 'multiple-headings' | 'mixed-content';
content: string;
}[];
}
/**
* Logseq 포맷터
*
* Logseq 아웃라이너에 최적화된 포맷으로 콘텐츠를 변환합니다.
*
* ⚠️ 중요: Logseq는 하나의 블록에 하나의 내용만 지원합니다.
* - 여러 줄의 리스트나 헤딩을 하나의 블록에 넣으면 표시되지 않음
* - 각 줄은 개별 블록으로 추가해야 함
* - 들여쓰기는 자식 블록으로 표현
*
* Logseq 포맷 규칙:
* - 속성은 `key:: value` 형식
* - Task 마커: TODO, DOING, DONE, LATER, NOW
* - 우선순위: [#A], [#B], [#C]
* - 페이지 참조: [[페이지명]]
* - 블록 참조: ((uuid))
*/
@Injectable()
export class LogseqFormatter {
private readonly logger = new Logger(LogseqFormatter.name);
/**
* 단일 블록 콘텐츠가 Logseq 제약조건을 위반하는지 검사
*
* Logseq 블록 제약조건:
* - 하나의 블록에 여러 unordered list 불가
* - 하나의 블록에 여러 헤딩 불가
* - 하나의 블록에 리스트와 헤딩 혼합 불가
*
* @param content 검사할 블록 콘텐츠
* @returns 검증 결과
*/
validateBlockContent(content: string): BlockValidationResult {
const result: BlockValidationResult = {
isValid: true,
issues: [],
problematicPatterns: [],
};
const lines = content.split('\n').filter((line) => line.trim());
// 여러 줄이 없으면 기본적으로 유효
if (lines.length <= 1) {
return result;
}
// 각 줄의 타입 분석
let headingCount = 0;
let listCount = 0;
const listMarkerPattern = /^[-*+]\s+/;
const numberedListPattern = /^\d+\.\s+/;
const headingPattern = /^#{1,6}\s+/;
for (const line of lines) {
const trimmed = line.trim();
if (headingPattern.test(trimmed)) {
headingCount++;
} else if (
listMarkerPattern.test(trimmed) ||
numberedListPattern.test(trimmed)
) {
listCount++;
}
}
// 여러 헤딩 감지
if (headingCount > 1) {
result.isValid = false;
result.issues.push(
`블록에 ${headingCount}개의 헤딩이 포함되어 있습니다. Logseq는 하나의 블록에 하나의 헤딩만 지원합니다.`,
);
result.problematicPatterns.push({
type: 'multiple-headings',
content: content.substring(0, 100),
});
}
// 여러 리스트 아이템 감지
if (listCount > 1) {
result.isValid = false;
result.issues.push(
`블록에 ${listCount}개의 리스트 아이템이 포함되어 있습니다. Logseq는 하나의 블록에 여러 리스트 아이템을 지원하지 않습니다.`,
);
result.problematicPatterns.push({
type: 'multiple-lists',
content: content.substring(0, 100),
});
}
// 헤딩과 리스트 혼합 감지
if (headingCount > 0 && listCount > 0) {
result.isValid = false;
result.issues.push(
`블록에 헤딩과 리스트가 혼합되어 있습니다. 각각 별도의 블록으로 분리해야 합니다.`,
);
result.problematicPatterns.push({
type: 'mixed-content',
content: content.substring(0, 100),
});
}
return result;
}
/**
* 블록 배열 전체를 검증하고 필요시 분리
*
* @param blocks 검증할 블록 배열
* @returns 검증 및 수정된 블록 배열
*/
validateAndFixBlocks(blocks: ParsedBlock[]): ParsedBlock[] {
const result: ParsedBlock[] = [];
for (const block of blocks) {
const validation = this.validateBlockContent(block.content);
if (validation.isValid) {
// 유효한 블록은 그대로 추가 (자식만 재귀 검증)
const fixedBlock: ParsedBlock = { content: block.content };
if (block.children && block.children.length > 0) {
fixedBlock.children = this.validateAndFixBlocks(block.children);
}
result.push(fixedBlock);
} else {
// 유효하지 않은 블록은 분리
this.logger.warn(
`Logseq 호환성 문제 감지, 블록 분리: ${validation.issues.join(', ')}`,
);
const splitBlocks = this.splitInvalidBlock(block);
result.push(...splitBlocks);
}
}
return result;
}
/**
* 유효하지 않은 블록을 여러 블록으로 분리
*/
private splitInvalidBlock(block: ParsedBlock): ParsedBlock[] {
// 콘텐츠를 줄 단위로 분리하여 다시 파싱
const reparse = this.parseToBlocks(block.content);
// 원래 블록에 자식이 있었다면 마지막 블록에 추가
if (block.children && block.children.length > 0 && reparse.length > 0) {
const lastBlock = reparse[reparse.length - 1];
const validatedChildren = this.validateAndFixBlocks(block.children);
if (!lastBlock.children) {
lastBlock.children = validatedChildren;
} else {
lastBlock.children.push(...validatedChildren);
}
}
return reparse;
}
/**
* 안전하게 콘텐츠를 블록으로 변환 (검증 포함)
*
* parseToBlocks와 달리 최종 결과가 Logseq 호환되는지 보장합니다.
*
* @param content 변환할 콘텐츠
* @returns Logseq 호환 블록 배열
*/
safeParseToBlocks(content: string): ParsedBlock[] {
// 1단계: 기본 파싱
const parsed = this.parseToBlocks(content);
// 2단계: 검증 및 수정
return this.validateAndFixBlocks(parsed);
}
/**
* 콘텐츠를 파싱하여 계층적 블록 구조로 변환
* 이 구조를 사용하여 각 블록을 개별적으로 추가해야 함
*
* ⚠️ 중요: Logseq는 하나의 블록에 하나의 논리적 단위만 지원합니다.
* - 여러 헤딩이나 리스트가 하나의 블록에 있으면 제대로 표시되지 않음
* - 각 헤딩, 리스트 아이템은 반드시 개별 블록으로 분리
*
* 지원하는 포맷:
* - Markdown 헤딩 (# ~ ######) → **볼드** (개별 블록)
* - 리스트 아이템 (-, *, +, 1.) → 헤딩의 자식 블록
* - 탭/스페이스 들여쓰기 → 계층 구조
* - 체크박스 ([x], [ ]) → DONE/TODO
* - 속성 (key:: value) → 그대로 유지
*/
parseToBlocks(content: string): ParsedBlock[] {
// 전처리: 일관된 포맷으로 정규화
const normalizedContent = this.normalizeContent(content);
const lines = normalizedContent.split('\n');
const result: ParsedBlock[] = [];
// 현재 섹션(헤딩) 추적
let currentSection: ParsedBlock | null = null;
// 현재 리스트 부모 추적 (들여쓰기 기반)
const listStack: { indent: number; block: ParsedBlock }[] = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
// 원본 들여쓰기 레벨 계산
const rawIndentLevel = this.calculateIndentLevel(line);
// 줄 타입 분석
const lineType = this.analyzeLineType(trimmed);
// 콘텐츠 처리
const blockContent = this.processLineContent(trimmed);
const newBlock: ParsedBlock = { content: blockContent };
// 헤딩: 항상 최상위 레벨 (새 섹션 시작)
if (lineType === 'heading') {
result.push(newBlock);
currentSection = newBlock;
listStack.length = 0;
continue;
}
// 리스트 아이템 또는 체크박스
if (lineType === 'list-item' || lineType === 'checkbox') {
if (rawIndentLevel === 0) {
// 들여쓰기 없는 리스트: 현재 섹션의 자식
if (currentSection) {
if (!currentSection.children) currentSection.children = [];
currentSection.children.push(newBlock);
listStack.length = 0;
listStack.push({ indent: 1, block: newBlock });
} else {
// 섹션이 없으면 최상위로
result.push(newBlock);
listStack.length = 0;
listStack.push({ indent: 0, block: newBlock });
}
} else {
// 들여쓰기 있는 리스트: 적절한 부모의 자식
while (
listStack.length > 0 &&
listStack[listStack.length - 1].indent >= rawIndentLevel
) {
listStack.pop();
}
if (listStack.length > 0) {
const parent = listStack[listStack.length - 1].block;
if (!parent.children) parent.children = [];
parent.children.push(newBlock);
} else if (currentSection) {
if (!currentSection.children) currentSection.children = [];
currentSection.children.push(newBlock);
} else {
result.push(newBlock);
}
listStack.push({ indent: rawIndentLevel, block: newBlock });
}
continue;
}
// 속성: 현재 섹션에 붙거나 최상위로
if (lineType === 'property') {
if (currentSection) {
if (!currentSection.children) currentSection.children = [];
currentSection.children.push(newBlock);
} else {
result.push(newBlock);
}
continue;
}
// 일반 텍스트
if (rawIndentLevel > 0) {
// 들여쓰기 있으면 현재 섹션의 자식
if (currentSection) {
if (!currentSection.children) currentSection.children = [];
currentSection.children.push(newBlock);
} else {
result.push(newBlock);
}
} else {
// 들여쓰기 없는 일반 텍스트: 현재 섹션의 자식 또는 최상위
if (currentSection) {
if (!currentSection.children) currentSection.children = [];
currentSection.children.push(newBlock);
} else {
result.push(newBlock);
// 일반 텍스트는 새 섹션으로 설정
currentSection = newBlock;
}
listStack.length = 0;
}
}
return result;
}
/**
* 줄의 타입을 분석
*/
private analyzeLineType(
trimmed: string,
): 'heading' | 'list-item' | 'checkbox' | 'property' | 'text' {
// Markdown 헤딩
if (/^#{1,6}\s+/.test(trimmed)) {
return 'heading';
}
// 리스트 아이템 (-, *, +)
if (/^[-*+]\s+/.test(trimmed)) {
return 'list-item';
}
// 번호 리스트
if (/^\d+\.\s+/.test(trimmed)) {
return 'list-item';
}
// 체크박스
if (/^\[[ x]\]\s+/i.test(trimmed)) {
return 'checkbox';
}
// 속성 (key:: value)
if (/^[\w-]+::\s*/.test(trimmed)) {
return 'property';
}
return 'text';
}
/**
* 콘텐츠 전처리 - 일관된 포맷으로 정규화
*/
private normalizeContent(content: string): string {
let result = content;
// 1. Windows 줄바꿈을 Unix 스타일로 변환
result = result.replace(/\r\n/g, '\n');
// 2. 연속된 빈 줄을 하나로 통합
result = result.replace(/\n{3,}/g, '\n\n');
// 3. Markdown 코드 블록 내용 보존 (나중에 처리)
// 현재는 코드 블록을 그대로 유지
return result;
}
/**
* 들여쓰기 레벨 계산
* 탭 = 1레벨, 스페이스 2~4개 = 1레벨
*/
private calculateIndentLevel(line: string): number {
let level = 0;
let i = 0;
while (i < line.length) {
if (line[i] === '\t') {
level++;
i++;
} else if (line[i] === ' ') {
// 2~4개의 스페이스를 1레벨로 계산
let spaces = 0;
while (i < line.length && line[i] === ' ' && spaces < 4) {
spaces++;
i++;
}
if (spaces >= 2) {
level++;
}
} else {
break;
}
}
return level;
}
/**
* 한 줄의 콘텐츠를 Logseq 포맷으로 변환
*/
private processLineContent(trimmed: string): string {
let content = trimmed;
// 0. 리스트 마커 제거 먼저 (-, *, +) - 체크박스 처리 전에
const listMatch = content.match(/^[-*+]\s+(.+)$/);
if (listMatch) {
content = listMatch[1];
}
// 1. Markdown 헤딩을 볼드로 변환
const headingMatch = content.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
content = `**${headingMatch[2]}**`;
return this.processInlineFormatting(content);
}
// 2. 체크박스 변환 ([x] → DONE, [ ] → TODO)
const checkboxDoneMatch = content.match(/^\[x\]\s+(.+)$/i);
if (checkboxDoneMatch) {
content = `DONE ${checkboxDoneMatch[1]}`;
return this.processInlineFormatting(content);
}
const checkboxTodoMatch = content.match(/^\[\s?\]\s+(.+)$/);
if (checkboxTodoMatch) {
content = `TODO ${checkboxTodoMatch[1]}`;
return this.processInlineFormatting(content);
}
// 3. 번호 매긴 리스트 처리 (1., 2., 등)
const numberedListMatch = content.match(/^\d+\.\s+(.+)$/);
if (numberedListMatch) {
content = numberedListMatch[1];
return this.processInlineFormatting(content);
}
// 4. 속성 형식은 그대로 유지 (key:: value)
if (content.includes('::')) {
return content;
}
return this.processInlineFormatting(content);
}
/**
* 일반 마크다운을 Logseq 아웃라이너 포맷으로 변환
* @deprecated parseToBlocks()를 사용하세요
*/
toLogseqFormat(content: string): string {
const lines = content.split('\n');
const result: string[] = [];
let currentIndent = 0;
for (const line of lines) {
const trimmed = line.trim();
// 빈 줄 무시
if (!trimmed) continue;
// Markdown 헤딩을 볼드 블록으로 변환
const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
const level = headingMatch[1].length;
const text = headingMatch[2];
// 헤딩 레벨에 따라 들여쓰기 조정 (H1=0, H2=1, ...)
currentIndent = Math.max(0, level - 1);
const indent = '\t'.repeat(currentIndent);
result.push(`${indent}**${text}**`);
continue;
}
// 기존 리스트 아이템 처리 (-, *, +)
const listMatch = trimmed.match(/^[-*+]\s+(.+)$/);
if (listMatch) {
const text = listMatch[1];
const originalIndent = this.countLeadingTabs(line);
const indent = '\t'.repeat(currentIndent + originalIndent);
result.push(`${indent}${this.processInlineFormatting(text)}`);
continue;
}
// 탭으로 들여쓰기된 콘텐츠
const tabMatch = line.match(/^(\t+)(.+)$/);
if (tabMatch) {
const tabs = tabMatch[1];
const text = tabMatch[2].trim();
result.push(`${tabs}${this.processInlineFormatting(text)}`);
continue;
}
// 일반 텍스트
const indent = '\t'.repeat(currentIndent);
result.push(`${indent}${this.processInlineFormatting(trimmed)}`);
}
return result.join('\n');
}
/**
* 계층적 데이터를 Logseq 블록 포맷으로 변환
*/
toBlockContent(data: OutlineItem[], level = 0): string {
const lines: string[] = [];
for (const item of data) {
const indent = '\t'.repeat(level);
let line = `${indent}${item.content}`;
// 속성 추가
if (item.properties) {
for (const [key, value] of Object.entries(item.properties)) {
lines.push(line);
line = `${indent}${key}:: ${value}`;
}
}
lines.push(line);
// 자식 항목 재귀 처리
if (item.children && item.children.length > 0) {
lines.push(this.toBlockContent(item.children, level + 1));
}
}
return lines.join('\n');
}
/**
* 회의 노트를 Logseq 포맷으로 변환
*/
formatMeetingNote(meeting: MeetingNote): string {
const lines: string[] = [];
// 날짜 속성
if (meeting.date) {
lines.push(`date:: [[${meeting.date}]]`);
}
// 타입 속성
lines.push(`type:: [[Meeting]]`);
// 참석자
if (meeting.attendees && meeting.attendees.length > 0) {
lines.push(`attendees:: ${meeting.attendees.join(', ')}`);
}
// 빈 줄 후 내용 시작
lines.push('');
// 안건별 내용
for (const topic of meeting.topics) {
lines.push(`**${topic.title}**`);
for (const item of topic.items) {
lines.push(this.formatOutlineItem(item, 1));
}
}
// 액션 아이템
if (meeting.actionItems && meeting.actionItems.length > 0) {
lines.push('');
lines.push('**📌 액션 아이템**');
for (const action of meeting.actionItems) {
const priority = action.priority ? `[#${action.priority}] ` : '';
const assignee = action.assignee ? ` @${action.assignee}` : '';
lines.push(`\tTODO ${priority}${action.content}${assignee}`);
}
}
return lines.join('\n');
}
/**
* 개별 아웃라인 항목을 Logseq 포맷으로 변환
*/
formatOutlineItem(item: OutlineItem, level = 0): string {
const indent = '\t'.repeat(level);
const lines: string[] = [];
// Task 마커 처리
let content = item.content;
if (item.status) {
content = `${item.status} ${content}`;
}
// 우선순위 처리
if (item.priority) {
content = `[#${item.priority}] ${content}`;
}
// 강조 처리 (이탤릭으로 표시된 항목)
if (item.emphasized) {
content = `*${content}*`;
}
// 볼드 처리 (굵게 표시된 항목)
if (item.bold) {
content = `**${content}**`;
}
lines.push(`${indent}${content}`);
// 속성 추가
if (item.properties) {
for (const [key, value] of Object.entries(item.properties)) {
lines.push(`${indent}${key}:: ${value}`);
}
}
// 자식 항목 재귀 처리
if (item.children && item.children.length > 0) {
for (const child of item.children) {
lines.push(this.formatOutlineItem(child, level + 1));
}
}
return lines.join('\n');
}
/**
* 개발 로그를 Logseq 포맷으로 변환
*/
formatDevLog(log: DevLogEntry): string {
const lines: string[] = [];
// 속성
lines.push(`type:: [[DevLog]]`);
lines.push(`category:: ${log.category}`);
if (log.files && log.files.length > 0) {
lines.push(`files:: ${log.files.join(', ')}`);
}
lines.push('');
lines.push(`**${log.title}**`);
lines.push(`\t${log.description}`);
return lines.join('\n');
}
/**
* TODO 항목을 Logseq 포맷으로 변환
*/
formatTodo(todo: TodoItem): string {
let line = 'TODO';
if (todo.priority) {
line += ` [#${todo.priority}]`;
}
line += ` ${todo.content}`;
const lines = [line];
if (todo.tags && todo.tags.length > 0) {
const tagsStr = todo.tags.map((t) => `#${t}`).join(' ');
lines[0] += ` ${tagsStr}`;
}
return lines.join('\n');
}
/**
* 마크다운 테이블을 Logseq 친화적 포맷으로 변환
* (테이블은 코드 블록으로 감싸거나 리스트로 변환)
*/
convertMarkdownTable(tableContent: string): ParsedBlock[] {
const lines = tableContent.trim().split('\n');
const result: ParsedBlock[] = [];
// 헤더 행 파싱
const headerLine = lines[0];
if (!headerLine) return result;
const headers = headerLine
.split('|')
.map((h) => h.trim())
.filter((h) => h.length > 0);
// 구분선 스킵 (|---|---|)
const dataStartIndex = lines[1]?.includes('---') ? 2 : 1;
// 테이블 제목 블록
result.push({ content: `**📊 테이블 데이터**` });
// 각 행을 계층적 블록으로 변환
for (let i = dataStartIndex; i < lines.length; i++) {
const row = lines[i];
const cells = row
.split('|')
.map((c) => c.trim())
.filter((c) => c.length > 0);
if (cells.length === 0) continue;
// 첫 번째 셀을 제목으로
const rowBlock: ParsedBlock = {
content: cells[0],
children: [],
};
// 나머지 셀을 속성처럼 표시
for (let j = 1; j < cells.length && j < headers.length; j++) {
rowBlock.children!.push({
content: `${headers[j]}:: ${cells[j]}`,
});
}
result.push(rowBlock);
}
return result;
}
/**
* 복잡한 마크다운 문서를 Logseq 블록으로 변환
* 섹션 구분, 테이블, 코드 블록 등을 처리
*
* ⚠️ 중요: Logseq 아웃라이너 규칙을 엄격히 준수
* - 하나의 블록에는 하나의 논리적 단위만 포함
* - 여러 헤딩이나 리스트가 하나의 블록에 있으면 클릭해야 표시되는 문제 발생
* - 각 헤딩, 리스트 아이템은 반드시 개별 블록으로 분리
*/
parseComplexDocument(content: string): ParsedBlock[] {
const result: ParsedBlock[] = [];
const lines = content.split('\n');
let i = 0;
let currentSection: ParsedBlock | null = null;
while (i < lines.length) {
const line = lines[i];
const trimmed = line.trim();
// 빈 줄: 섹션 구분자로 처리
if (!trimmed) {
currentSection = null;
i++;
continue;
}
// 코드 블록 처리 (```로 시작)
if (trimmed.startsWith('```')) {
const codeBlockLines: string[] = [];
const language = trimmed.slice(3).trim();
i++;
while (i < lines.length && !lines[i].trim().startsWith('```')) {
codeBlockLines.push(lines[i]);
i++;
}
i++; // 닫는 ``` 스킵
// 코드 블록을 하나의 블록으로
const codeContent = codeBlockLines.join('\n');
const codeBlock: ParsedBlock = {
content: `\`\`\`${language}\n${codeContent}\n\`\`\``,
};
// 현재 섹션이 있으면 자식으로, 없으면 최상위로
if (currentSection) {
if (!currentSection.children) currentSection.children = [];
currentSection.children.push(codeBlock);
} else {
result.push(codeBlock);
}
continue;
}
// 테이블 감지 (|로 시작하는 줄)
if (
trimmed.startsWith('|') ||
(trimmed.includes('|') && lines[i + 1]?.includes('---'))
) {
const tableLines: string[] = [line];
i++;
while (i < lines.length && lines[i].includes('|')) {
tableLines.push(lines[i]);
i++;
}
const tableBlocks = this.convertMarkdownTable(tableLines.join('\n'));
// 현재 섹션이 있으면 자식으로
if (currentSection) {
if (!currentSection.children) currentSection.children = [];
currentSection.children.push(...tableBlocks);
} else {
result.push(...tableBlocks);
}
continue;
}
// 줄 타입 분석
const lineType = this.analyzeLineType(trimmed);
const indentLevel = this.calculateIndentLevel(line);
const blockContent = this.processLineContent(trimmed);
const newBlock: ParsedBlock = { content: blockContent };
// 헤딩: 항상 새로운 섹션 시작 (개별 최상위 블록)
if (lineType === 'heading') {
result.push(newBlock);
currentSection = newBlock;
i++;
continue;
}
// 리스트 아이템: 들여쓰기에 따라 처리
if (lineType === 'list-item' || lineType === 'checkbox') {
if (indentLevel > 0 && currentSection) {
// 들여쓰기가 있으면 현재 섹션의 자식으로
if (!currentSection.children) currentSection.children = [];
currentSection.children.push(newBlock);
} else if (currentSection && indentLevel === 0) {
// 들여쓰기 없는 리스트는 현재 섹션의 자식으로
if (!currentSection.children) currentSection.children = [];
currentSection.children.push(newBlock);
} else {
// 섹션이 없으면 최상위 블록으로
result.push(newBlock);
}
i++;
continue;
}
// 속성: 현재 섹션에 붙이거나 최상위로
if (lineType === 'property') {
if (currentSection) {
if (!currentSection.children) currentSection.children = [];
currentSection.children.push(newBlock);
} else {
result.push(newBlock);
}
i++;
continue;
}
// 일반 텍스트: 개별 블록으로 추가
if (indentLevel > 0 && currentSection) {
if (!currentSection.children) currentSection.children = [];
currentSection.children.push(newBlock);
} else {
result.push(newBlock);
// 일반 텍스트는 섹션으로 설정하지 않음 (후속 리스트와 분리)
}
i++;
}
return result;
}
/**
* 요약/정리 문서를 Logseq 아웃라이너에 최적화
* 내용은 변경하지 않고 구조만 개선
*/
optimizeForOutliner(content: string): ParsedBlock[] {
// 먼저 기본 파싱 수행
const blocks = this.parseToBlocks(content);
// 최적화: 단일 자식만 있는 부모는 병합 가능성 검토
return this.optimizeBlockStructure(blocks);
}
/**
* 인라인 포맷팅 처리
* Markdown 인라인 문법을 Logseq 호환 형식으로 변환
*/
private processInlineFormatting(text: string): string {
const result = text;
// 이미 Logseq Task 마커가 있으면 그대로 반환
if (/^(TODO|DOING|DONE|LATER|NOW)\s/.test(result)) {
return result;
}
// 이미 우선순위가 있으면 그대로 반환
if (/^\[#[ABC]\]/.test(result)) {
return result;
}
// 코드 스팬 (`code`) 유지
// **bold**, *italic*, ~~strikethrough~~ 유지
// [[page]], ((block)) 참조 유지
// 인라인 링크 [text](url) → Logseq 형식으로 유지
return result;
}
/**
* 문자열의 선행 탭 개수 계산
*/
private countLeadingTabs(line: string): number {
let count = 0;
for (const char of line) {
if (char === '\t') count++;
else break;
}
return count;
}
/**
* 블록 구조 최적화
* - 너무 깊은 중첩 방지 (최대 5레벨)
* - 빈 블록 제거
*/
private optimizeBlockStructure(
blocks: ParsedBlock[],
depth = 0,
): ParsedBlock[] {
const maxDepth = 5;
return blocks
.filter((block) => block.content.trim().length > 0)
.map((block) => {
const optimized: ParsedBlock = { content: block.content };
if (block.children && block.children.length > 0) {
if (depth < maxDepth) {
optimized.children = this.optimizeBlockStructure(
block.children,
depth + 1,
);
} else {
// 최대 깊이 초과 시 자식들을 같은 레벨로 평탄화
this.logger.warn(
`Max depth (${maxDepth}) exceeded, flattening children`,
);
optimized.content += ` (${block.children.map((c) => c.content).join(', ')})`;
}
}
return optimized;
});
}
}