import { Injectable, Inject, Logger } from '@nestjs/common';
import type { ConfigType } from '@nestjs/config';
import axios, { AxiosInstance, AxiosError } from 'axios';
import { logseqConfig, getLogseqBaseUrl } from '../config/logseq.config';
import {
LogseqBlock,
LogseqPage,
LogseqGraph,
LogseqApiMethods,
LogseqSearchResult,
CreateBlockOptions,
CreatePageOptions,
} from './logseq.types';
/**
* Logseq HTTP API 클라이언트
*
* SOLID 원칙:
* - SRP: Logseq HTTP API 통신만 담당
* - OCP: 새로운 API 메서드 추가 시 확장 가능
* - DIP: ConfigType을 통한 의존성 주입
*/
@Injectable()
export class LogseqClient {
private readonly logger = new Logger(LogseqClient.name);
private readonly httpClient: AxiosInstance;
constructor(
@Inject(logseqConfig.KEY)
private readonly config: ConfigType<typeof logseqConfig>,
) {
const baseURL = getLogseqBaseUrl(config);
this.httpClient = axios.create({
baseURL,
timeout: config.timeout,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${config.token}`,
},
});
this.logger.log(`Logseq client initialized: ${baseURL}`);
}
/**
* 저수준 API 호출
*/
private async callApi<T>(method: string, args: unknown[] = []): Promise<T> {
try {
const response = await this.httpClient.post('/api', { method, args });
return response.data as T;
} catch (error) {
if (error instanceof AxiosError) {
const errorData = error.response?.data as
| { error?: string }
| undefined;
this.logger.error(
`Logseq API error: ${method} - ${error.message}`,
error.response?.data,
);
throw new Error(
`Logseq API error: ${errorData?.error || error.message}`,
);
}
throw error;
}
}
// ==================== Graph ====================
/**
* 현재 열린 Graph 정보 조회
*/
async getCurrentGraph(): Promise<LogseqGraph | null> {
return this.callApi<LogseqGraph | null>(LogseqApiMethods.GET_CURRENT_GRAPH);
}
// ==================== Page ====================
/**
* 모든 Page 목록 조회
*/
async getAllPages(): Promise<LogseqPage[]> {
const pages = await this.callApi<LogseqPage[] | null>(
LogseqApiMethods.GET_ALL_PAGES,
);
return pages || [];
}
/**
* Page 조회 (이름 또는 UUID)
*/
async getPage(nameOrUuid: string): Promise<LogseqPage | null> {
return this.callApi<LogseqPage | null>(LogseqApiMethods.GET_PAGE, [
nameOrUuid,
]);
}
/**
* Page 생성
*/
async createPage(
name: string,
options?: CreatePageOptions,
): Promise<LogseqPage | null> {
return this.callApi<LogseqPage | null>(LogseqApiMethods.CREATE_PAGE, [
name,
options?.properties,
{ journal: options?.journal, redirect: options?.redirect },
]);
}
/**
* Page 삭제
*/
async deletePage(name: string): Promise<void> {
await this.callApi<void>(LogseqApiMethods.DELETE_PAGE, [name]);
}
/**
* Page의 Block 트리 조회
*/
async getPageBlocksTree(nameOrUuid: string): Promise<LogseqBlock[]> {
const blocks = await this.callApi<LogseqBlock[] | null>(
LogseqApiMethods.GET_PAGE_BLOCKS_TREE,
[nameOrUuid],
);
return blocks || [];
}
/**
* Page의 역참조(Backlinks) 조회
*/
async getPageLinkedReferences(
name: string,
): Promise<Array<[LogseqPage, LogseqBlock[]]>> {
const refs = await this.callApi<Array<[LogseqPage, LogseqBlock[]]> | null>(
LogseqApiMethods.GET_PAGE_LINKED_REFERENCES,
[name],
);
return refs || [];
}
// ==================== Block ====================
/**
* Block 조회
*/
async getBlock(
uuid: string,
options?: { includeChildren?: boolean },
): Promise<LogseqBlock | null> {
return this.callApi<LogseqBlock | null>(LogseqApiMethods.GET_BLOCK, [
uuid,
options,
]);
}
/**
* Block 삽입 (기존 Block 아래/옆에)
*/
async insertBlock(
targetUuid: string,
content: string,
options?: CreateBlockOptions,
): Promise<LogseqBlock | null> {
return this.callApi<LogseqBlock | null>(LogseqApiMethods.INSERT_BLOCK, [
targetUuid,
content,
options,
]);
}
/**
* Block 내용 수정
*/
async updateBlock(
uuid: string,
content: string,
options?: { properties?: Record<string, unknown> },
): Promise<void> {
await this.callApi<void>(LogseqApiMethods.UPDATE_BLOCK, [
uuid,
content,
options,
]);
}
/**
* Block 삭제
*/
async removeBlock(uuid: string): Promise<void> {
await this.callApi<void>(LogseqApiMethods.REMOVE_BLOCK, [uuid]);
}
/**
* Page 끝에 Block 추가
*/
async appendBlockInPage(
pageName: string,
content: string,
options?: { properties?: Record<string, unknown> },
): Promise<LogseqBlock | null> {
return this.callApi<LogseqBlock | null>(
LogseqApiMethods.APPEND_BLOCK_IN_PAGE,
[pageName, content, options],
);
}
/**
* Page에 계층적 블록 구조 추가
* 각 블록을 개별적으로 추가하여 Logseq 호환성 보장
*/
async appendBlocksInPage(
pageName: string,
blocks: Array<{ content: string; children?: any[] }>,
): Promise<LogseqBlock | null> {
let lastBlock: LogseqBlock | null = null;
for (const block of blocks) {
// 부모 블록 추가
const parentBlock = await this.appendBlockInPage(pageName, block.content);
if (!parentBlock) continue;
lastBlock = parentBlock;
// 자식 블록이 있으면 재귀적으로 추가
if (block.children && block.children.length > 0) {
await this.insertChildBlocks(parentBlock.uuid, block.children);
}
}
return lastBlock;
}
/**
* 부모 블록 아래에 자식 블록들을 재귀적으로 추가
*/
private async insertChildBlocks(
parentUuid: string,
children: Array<{ content: string; children?: any[] }>,
): Promise<void> {
for (const child of children) {
const childBlock = await this.insertBlock(parentUuid, child.content, {
sibling: false,
});
if (childBlock && child.children && child.children.length > 0) {
await this.insertChildBlocks(childBlock.uuid, child.children);
}
}
}
/**
* Page 앞에 Block 추가
*/
async prependBlockInPage(
pageName: string,
content: string,
options?: { properties?: Record<string, unknown> },
): Promise<LogseqBlock | null> {
return this.callApi<LogseqBlock | null>(
LogseqApiMethods.PREPEND_BLOCK_IN_PAGE,
[pageName, content, options],
);
}
// ==================== Journal ====================
/**
* 오늘의 Journal Page 이름 생성
* Logseq 기본 형식: "Dec 1st, 2024" 또는 설정에 따라 다름
* 여기서는 ISO 날짜 형식 사용 (Page 생성 시 Logseq이 처리)
*/
getTodayJournalName(): string {
const today = new Date();
// Logseq 기본 Journal 형식: "Dec 1st, 2024"
const options: Intl.DateTimeFormatOptions = {
month: 'short',
day: 'numeric',
year: 'numeric',
};
const formatted = today.toLocaleDateString('en-US', options);
// "Dec 1, 2024" -> "Dec 1st, 2024"
const day = today.getDate();
const suffix = this.getDaySuffix(day);
return formatted.replace(String(day), `${day}${suffix}`);
}
private getDaySuffix(day: number): string {
if (day >= 11 && day <= 13) return 'th';
switch (day % 10) {
case 1:
return 'st';
case 2:
return 'nd';
case 3:
return 'rd';
default:
return 'th';
}
}
/**
* 특정 날짜의 Journal Page 이름 생성
*/
getJournalNameForDate(date: Date): string {
const options: Intl.DateTimeFormatOptions = {
month: 'short',
day: 'numeric',
year: 'numeric',
};
const formatted = date.toLocaleDateString('en-US', options);
const day = date.getDate();
const suffix = this.getDaySuffix(day);
return formatted.replace(String(day), `${day}${suffix}`);
}
// ==================== Search ====================
/**
* 전문 검색
*/
async search(query: string): Promise<LogseqSearchResult> {
const result = await this.callApi<LogseqSearchResult | null>(
LogseqApiMethods.SEARCH,
[query],
);
return result || { blocks: [], pages: [] };
}
}