import fs from "fs/promises";
import path from "path";
import { glob } from "glob";
import matter from "gray-matter";
export interface NoteInfo {
relativePath: string;
absolutePath: string;
title: string;
modified: Date;
created: Date;
frontmatter: Record<string, unknown>;
}
export interface SearchResult {
relativePath: string;
title: string;
matches: string[];
frontmatter: Record<string, unknown>;
}
/**
* Get the vault path from environment or default
*/
export function getVaultPath(): string {
const vaultPath = process.env.OBSIDIAN_VAULT_PATH;
if (!vaultPath) {
throw new Error(
"OBSIDIAN_VAULT_PATH environment variable is not set. " +
"Please set it to your Obsidian vault directory."
);
}
return vaultPath;
}
/**
* Slugify a string for use in filenames
*/
export function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, "")
.replace(/[\s_-]+/g, "-")
.replace(/^-+|-+$/g, "");
}
/**
* Generate a timestamp string for filenames
*/
export function timestamp(): string {
const now = new Date();
return now.toISOString().slice(0, 16).replace("T", "-").replace(":", "");
}
/**
* Ensure a directory exists
*/
export async function ensureDir(dirPath: string): Promise<void> {
await fs.mkdir(dirPath, { recursive: true });
}
/**
* List recent notes in the vault
*/
export async function listRecentNotes(
vaultPath: string,
limit: number = 20
): Promise<NoteInfo[]> {
const pattern = path.join(vaultPath, "**/*.md");
const files = await glob(pattern, {
ignore: ["**/node_modules/**", "**/.obsidian/**", "**/.trash/**"],
});
const notes: NoteInfo[] = [];
for (const file of files) {
try {
const stat = await fs.stat(file);
const content = await fs.readFile(file, "utf-8");
const { data: frontmatter } = matter(content);
const relativePath = path.relative(vaultPath, file);
const title =
(frontmatter.title as string) ||
path.basename(file, ".md");
notes.push({
relativePath,
absolutePath: file,
title,
modified: stat.mtime,
created: stat.birthtime,
frontmatter,
});
} catch {
// Skip files we can't read
}
}
// Sort by modified date, newest first
notes.sort((a, b) => b.modified.getTime() - a.modified.getTime());
return notes.slice(0, limit);
}
/**
* Search notes by content or frontmatter
*/
export async function searchNotes(
vaultPath: string,
query: string,
options: {
tags?: string[];
folder?: string;
limit?: number;
} = {}
): Promise<SearchResult[]> {
const { tags, folder, limit = 50 } = options;
let pattern = path.join(vaultPath, folder || "", "**/*.md");
const files = await glob(pattern, {
ignore: ["**/node_modules/**", "**/.obsidian/**", "**/.trash/**"],
});
const results: SearchResult[] = [];
const queryLower = query.toLowerCase();
const queryTerms = queryLower.split(/\s+/).filter(Boolean);
for (const file of files) {
try {
const content = await fs.readFile(file, "utf-8");
const { data: frontmatter, content: body } = matter(content);
// Check tags filter
if (tags && tags.length > 0) {
const noteTags = (frontmatter.tags as string[]) || [];
const hasTag = tags.some((t) =>
noteTags.some((nt) => nt.toLowerCase().includes(t.toLowerCase()))
);
if (!hasTag) continue;
}
// Search in content
const contentLower = body.toLowerCase();
const titleLower = (
(frontmatter.title as string) || path.basename(file, ".md")
).toLowerCase();
const matches: string[] = [];
for (const term of queryTerms) {
if (contentLower.includes(term) || titleLower.includes(term)) {
// Extract context around match
const idx = contentLower.indexOf(term);
if (idx !== -1) {
const start = Math.max(0, idx - 50);
const end = Math.min(body.length, idx + term.length + 50);
matches.push("..." + body.slice(start, end).trim() + "...");
}
}
}
if (matches.length > 0 || queryTerms.length === 0) {
results.push({
relativePath: path.relative(vaultPath, file),
title:
(frontmatter.title as string) || path.basename(file, ".md"),
matches: matches.slice(0, 3),
frontmatter,
});
}
} catch {
// Skip files we can't read
}
}
return results.slice(0, limit);
}
/**
* Read a note's content
*/
export async function readNote(
vaultPath: string,
relativePath: string
): Promise<{ content: string; frontmatter: Record<string, unknown> }> {
const fullPath = path.join(vaultPath, relativePath);
const content = await fs.readFile(fullPath, "utf-8");
const { data: frontmatter, content: body } = matter(content);
return { content: body, frontmatter };
}
/**
* Write a note with frontmatter
*/
export async function writeNote(
vaultPath: string,
relativePath: string,
content: string,
frontmatter: Record<string, unknown> = {}
): Promise<string> {
const fullPath = path.join(vaultPath, relativePath);
// Ensure directory exists
await ensureDir(path.dirname(fullPath));
// Build the full content with frontmatter
const fullContent = matter.stringify(content, frontmatter);
await fs.writeFile(fullPath, fullContent, "utf-8");
return fullPath;
}
/**
* Append to an existing note
*/
export async function appendToNote(
vaultPath: string,
relativePath: string,
content: string
): Promise<void> {
const fullPath = path.join(vaultPath, relativePath);
try {
const existing = await fs.readFile(fullPath, "utf-8");
await fs.writeFile(fullPath, existing + "\n" + content, "utf-8");
} catch {
// File doesn't exist, create it
await writeNote(vaultPath, relativePath, content);
}
}
/**
* Get or create daily note
*/
export async function getDailyNotePath(
vaultPath: string,
dailyFolder: string = "Daily Notes"
): Promise<string> {
const today = new Date();
const dateStr = today.toISOString().slice(0, 10); // YYYY-MM-DD
return path.join(dailyFolder, `${dateStr}.md`);
}
/**
* Find backlinks to a note
*/
export async function findBacklinks(
vaultPath: string,
noteName: string
): Promise<string[]> {
const pattern = path.join(vaultPath, "**/*.md");
const files = await glob(pattern, {
ignore: ["**/node_modules/**", "**/.obsidian/**", "**/.trash/**"],
});
const backlinks: string[] = [];
const linkPattern = new RegExp(`\\[\\[${noteName}(\\|[^\\]]*)?\\]\\]`, "gi");
for (const file of files) {
try {
const content = await fs.readFile(file, "utf-8");
if (linkPattern.test(content)) {
backlinks.push(path.relative(vaultPath, file));
}
} catch {
// Skip files we can't read
}
}
return backlinks;
}