/**
* Obsidian Vault Operations
*/
import { promises as fs } from 'node:fs';
import path from 'node:path';
import type { Note, NoteMetadata, SearchResult, SearchMatch, Graph, GraphNode, GraphEdge } from './types.js';
export class VaultService {
constructor(private vaultPath: string) {}
private resolvePath(notePath: string): string {
// 상대 경로인 경우 vault 경로 기준으로 해석
if (path.isAbsolute(notePath)) {
return notePath;
}
return path.join(this.vaultPath, notePath);
}
private ensureMarkdownExtension(notePath: string): string {
if (!notePath.endsWith('.md')) {
return `${notePath}.md`;
}
return notePath;
}
private extractFrontmatter(content: string): { frontmatter: Record<string, unknown> | undefined; body: string } {
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n/;
const match = content.match(frontmatterRegex);
if (!match) {
return { frontmatter: undefined, body: content };
}
try {
const frontmatterText = match[1];
const frontmatter: Record<string, unknown> = {};
// 간단한 YAML 파싱 (key: value 형태)
for (const line of frontmatterText.split('\n')) {
const colonIndex = line.indexOf(':');
if (colonIndex > 0) {
const key = line.slice(0, colonIndex).trim();
let value: unknown = line.slice(colonIndex + 1).trim();
// 배열 형태 처리 [item1, item2]
if (typeof value === 'string' && value.startsWith('[') && value.endsWith(']')) {
value = value.slice(1, -1).split(',').map(v => v.trim());
}
frontmatter[key] = value;
}
}
return { frontmatter, body: content.slice(match[0].length) };
} catch {
return { frontmatter: undefined, body: content };
}
}
private extractTags(content: string): string[] {
const tagRegex = /#([a-zA-Z0-9가-힣_/-]+)/g;
const tags: string[] = [];
let match;
while ((match = tagRegex.exec(content)) !== null) {
tags.push(match[1]);
}
return [...new Set(tags)];
}
private extractLinks(content: string): string[] {
// [[link]] 또는 [[link|alias]] 형태
const linkRegex = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
const links: string[] = [];
let match;
while ((match = linkRegex.exec(content)) !== null) {
links.push(match[1]);
}
return [...new Set(links)];
}
async listNotes(folder?: string): Promise<NoteMetadata[]> {
const searchPath = folder ? this.resolvePath(folder) : this.vaultPath;
const notes: NoteMetadata[] = [];
async function walkDir(dir: string, vaultPath: string, service: VaultService): Promise<void> {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
// .obsidian 폴더 제외
if (entry.name.startsWith('.')) continue;
if (entry.isDirectory()) {
await walkDir(fullPath, vaultPath, service);
} else if (entry.name.endsWith('.md')) {
const stat = await fs.stat(fullPath);
const content = await fs.readFile(fullPath, 'utf-8');
const relativePath = path.relative(vaultPath, fullPath);
notes.push({
path: relativePath,
name: entry.name.replace('.md', ''),
tags: service.extractTags(content),
links: service.extractLinks(content),
backlinks: [], // 나중에 계산
createdAt: stat.birthtime,
modifiedAt: stat.mtime,
});
}
}
}
await walkDir(searchPath, this.vaultPath, this);
// 백링크 계산
for (const note of notes) {
note.backlinks = notes
.filter(n => n.links.includes(note.name))
.map(n => n.name);
}
return notes;
}
async readNote(notePath: string): Promise<Note> {
const fullPath = this.resolvePath(this.ensureMarkdownExtension(notePath));
const content = await fs.readFile(fullPath, 'utf-8');
const stat = await fs.stat(fullPath);
const { frontmatter, body } = this.extractFrontmatter(content);
const relativePath = path.relative(this.vaultPath, fullPath);
const name = path.basename(fullPath, '.md');
// 백링크 계산을 위해 전체 노트 스캔
const allNotes = await this.listNotes();
const backlinks = allNotes
.filter(n => n.links.includes(name))
.map(n => n.name);
return {
path: relativePath,
name,
content: body,
frontmatter,
tags: this.extractTags(content),
links: this.extractLinks(content),
backlinks,
createdAt: stat.birthtime,
modifiedAt: stat.mtime,
};
}
async createNote(notePath: string, content: string, frontmatter?: Record<string, unknown>): Promise<Note> {
const fullPath = this.resolvePath(this.ensureMarkdownExtension(notePath));
// 디렉토리가 없으면 생성
await fs.mkdir(path.dirname(fullPath), { recursive: true });
let finalContent = content;
if (frontmatter && Object.keys(frontmatter).length > 0) {
const frontmatterStr = Object.entries(frontmatter)
.map(([key, value]) => {
if (Array.isArray(value)) {
return `${key}: [${value.join(', ')}]`;
}
return `${key}: ${value}`;
})
.join('\n');
finalContent = `---\n${frontmatterStr}\n---\n${content}`;
}
await fs.writeFile(fullPath, finalContent, 'utf-8');
return this.readNote(notePath);
}
async updateNote(notePath: string, content: string, frontmatter?: Record<string, unknown>): Promise<Note> {
const fullPath = this.resolvePath(this.ensureMarkdownExtension(notePath));
// 파일 존재 확인
await fs.access(fullPath);
let finalContent = content;
if (frontmatter && Object.keys(frontmatter).length > 0) {
const frontmatterStr = Object.entries(frontmatter)
.map(([key, value]) => {
if (Array.isArray(value)) {
return `${key}: [${value.join(', ')}]`;
}
return `${key}: ${value}`;
})
.join('\n');
finalContent = `---\n${frontmatterStr}\n---\n${content}`;
}
await fs.writeFile(fullPath, finalContent, 'utf-8');
return this.readNote(notePath);
}
async deleteNote(notePath: string): Promise<void> {
const fullPath = this.resolvePath(this.ensureMarkdownExtension(notePath));
await fs.unlink(fullPath);
}
async appendToNote(notePath: string, content: string): Promise<Note> {
const fullPath = this.resolvePath(this.ensureMarkdownExtension(notePath));
const existing = await fs.readFile(fullPath, 'utf-8');
await fs.writeFile(fullPath, existing + '\n' + content, 'utf-8');
return this.readNote(notePath);
}
async searchNotes(query: string, options?: { tags?: string[]; folder?: string }): Promise<SearchResult[]> {
const notes = await this.listNotes(options?.folder);
const results: SearchResult[] = [];
const queryLower = query.toLowerCase();
for (const noteMeta of notes) {
// 태그 필터
if (options?.tags && options.tags.length > 0) {
const hasTag = options.tags.some(tag => noteMeta.tags.includes(tag));
if (!hasTag) continue;
}
const note = await this.readNote(noteMeta.path);
const lines = note.content.split('\n');
const matches: SearchMatch[] = [];
let score = 0;
// 제목 매칭 (높은 점수)
if (note.name.toLowerCase().includes(queryLower)) {
score += 10;
}
// 내용 매칭
for (let i = 0; i < lines.length; i++) {
if (lines[i].toLowerCase().includes(queryLower)) {
matches.push({
line: i + 1,
content: lines[i],
context: lines.slice(Math.max(0, i - 1), Math.min(lines.length, i + 2)).join('\n'),
});
score += 1;
}
}
if (matches.length > 0 || score > 0) {
results.push({
path: note.path,
name: note.name,
matches,
score,
});
}
}
return results.sort((a, b) => b.score - a.score);
}
async getBacklinks(notePath: string): Promise<NoteMetadata[]> {
const note = await this.readNote(notePath);
const allNotes = await this.listNotes();
return allNotes.filter(n => n.links.includes(note.name));
}
async getGraph(options?: { depth?: number; center?: string }): Promise<Graph> {
const notes = await this.listNotes();
const nodes: GraphNode[] = [];
const edges: GraphEdge[] = [];
const nodeMap = new Map<string, GraphNode>();
// 모든 노트를 노드로 추가
for (const note of notes) {
const node: GraphNode = {
id: note.name,
name: note.name,
path: note.path,
type: 'note',
};
nodes.push(node);
nodeMap.set(note.name, node);
}
// 링크를 엣지로 추가
for (const note of notes) {
for (const link of note.links) {
// 링크된 노트가 존재하지 않으면 unresolved 노드로 추가
if (!nodeMap.has(link)) {
const unresolvedNode: GraphNode = {
id: link,
name: link,
path: '',
type: 'unresolved',
};
nodes.push(unresolvedNode);
nodeMap.set(link, unresolvedNode);
}
edges.push({
source: note.name,
target: link,
type: 'link',
});
}
// 태그를 노드와 엣지로 추가
for (const tag of note.tags) {
const tagId = `#${tag}`;
if (!nodeMap.has(tagId)) {
const tagNode: GraphNode = {
id: tagId,
name: tag,
path: '',
type: 'tag',
};
nodes.push(tagNode);
nodeMap.set(tagId, tagNode);
}
edges.push({
source: note.name,
target: tagId,
type: 'tag',
});
}
}
// center 옵션이 있으면 해당 노드 중심으로 필터링
if (options?.center) {
const depth = options.depth ?? 1;
const centerNode = nodeMap.get(options.center);
if (centerNode) {
const connectedNodes = new Set<string>([options.center]);
// BFS로 depth만큼 연결된 노드 탐색
let currentLevel = new Set<string>([options.center]);
for (let i = 0; i < depth; i++) {
const nextLevel = new Set<string>();
for (const nodeId of currentLevel) {
for (const edge of edges) {
if (edge.source === nodeId && !connectedNodes.has(edge.target)) {
nextLevel.add(edge.target);
connectedNodes.add(edge.target);
}
if (edge.target === nodeId && !connectedNodes.has(edge.source)) {
nextLevel.add(edge.source);
connectedNodes.add(edge.source);
}
}
}
currentLevel = nextLevel;
}
return {
nodes: nodes.filter(n => connectedNodes.has(n.id)),
edges: edges.filter(e => connectedNodes.has(e.source) && connectedNodes.has(e.target)),
};
}
}
return { nodes, edges };
}
async getDailyNote(date?: string): Promise<Note | null> {
const targetDate = date ?? new Date().toISOString().split('T')[0];
const possiblePaths = [
`${targetDate}.md`,
`Daily/${targetDate}.md`,
`daily/${targetDate}.md`,
`Journal/${targetDate}.md`,
`journal/${targetDate}.md`,
];
for (const notePath of possiblePaths) {
try {
return await this.readNote(notePath);
} catch {
continue;
}
}
return null;
}
async createDailyNote(date?: string, template?: string): Promise<Note> {
const targetDate = date ?? new Date().toISOString().split('T')[0];
const content = template ?? `# ${targetDate}\n\n## Tasks\n\n## Notes\n\n`;
return this.createNote(`${targetDate}.md`, content, {
date: targetDate,
tags: ['daily'],
});
}
}