/// import type { IncomingMessage, ServerResponse } from 'http' import path from 'path' import { closeSync, fstatSync, mkdirSync, openSync, opendirSync, readFileSync, renameSync, unlinkSync, writeFileSync, type Dirent, } from 'fs' import os from 'os' import { defineConfig, type Plugin } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' import matter from 'gray-matter' // --- Vault API middleware (dev only) --- interface VaultEntry { path: string filename: string title: string isA: string | null aliases: string[] belongsTo: string[] relatedTo: string[] status: string | null archived: boolean trashed: boolean trashedAt: number | null modifiedAt: number | null createdAt: number | null fileSize: number snippet: string wordCount: number relationships: Record icon: string | null color: string | null order: number | null sidebarLabel: string | null template: string | null sort: string | null view: string | null visible: boolean | null outgoingLinks: string[] properties: Record } /** Extract all [[wiki-links]] from a string. */ function extractWikiLinks(value: string): string[] { const matches = value.match(/\[\[[^\]]+\]\]/g) return matches ?? [] } /** Extract wiki-links from a frontmatter value (string or array of strings). */ function wikiLinksFromValue(value: unknown): string[] { return collectWikiLinksFromValue(value, 0) } function collectWikiLinksFromValue(value: unknown, depth: number): string[] { if (typeof value === 'string') return extractWikiLinks(value) if (!Array.isArray(value)) return [] const nestedLink = nestedFlowWikilink(value, depth) if (nestedLink) return [nestedLink] return value.flatMap((item) => collectWikiLinksFromValue(item, depth + 1)) } function nestedFlowWikilink(value: unknown[], depth: number): string | null { if (depth === 0 || value.length !== 1 || typeof value[0] !== 'string') return null return extractWikiLinks(value[0]).length === 0 ? `[[${value[0]}]]` : null } // Frontmatter keys that map to dedicated VaultEntry fields (skip in generic properties/relationships) const DEDICATED_KEYS = new Set([ 'aliases', 'is_a', 'is a', 'type', 'status', 'title', '_archived', 'archived', '_icon', 'icon', 'color', '_order', 'order', '_sidebar_label', 'sidebar_label', 'sidebar label', 'template', '_sort', 'sort', 'view', '_width', 'width', 'visible', '_organized', '_favorite', '_favorite_index', '_list_properties_display', ].map((key) => key.toLowerCase())) type FrontmatterPropertyValue = string | number | boolean | null type VaultSearchResult = { title: string; path: string; snippet: string; score: number; note_type: string | null } interface SearchEntryInput { excludeFrontmatter: boolean entry: VaultEntry query: string rawContent: string } interface SearchRequestInput { excludeFrontmatter: boolean query: string vaultPath: string } interface VaultCommandPayload { args?: Record cmd?: string } interface VaultCommandContext { args: Record cmd: string } interface VaultCommandResponse { payload: unknown statusCode?: number } type VaultCommandHandler = (context: VaultCommandContext) => VaultCommandResponse interface CommandStringInput { args: Record key: string } interface CommandResponseInput { payload: unknown statusCode?: number } interface VaultReadCommandInput { cmd: string pathname: string req: IncomingMessage res: ServerResponse url: URL } interface TitleWikilinkUpdateInput { excludePath: string oldTitle: string vaultPath: string } interface LegacyWikilinkTargetInput { oldPath: string oldTitle: string vaultPath: string } interface WikilinkTargetUpdateInput { excludePath: string newTarget: string oldTargets: string[] vaultPath: string } interface PathWikilinkUpdateInput { newPath: string oldPath: string oldTitle: string vaultPath: string } function getFrontmatterValue( frontmatter: Record, keys: string[], ): unknown { const normalizedKeys = new Set(keys.map((key) => key.toLowerCase())) return Object.entries(frontmatter).find(([key]) => normalizedKeys.has(key.toLowerCase()))?.[1] } function parseYamlBool(value: unknown): boolean | null { if (typeof value === 'boolean') return value if (typeof value !== 'string') return null switch (value.toLowerCase()) { case 'true': case 'yes': return true case 'false': case 'no': return false default: return null } } const vitestCoverageDirectory = process.env.VITEST_COVERAGE_DIR ?? path.join(os.tmpdir(), 'tolaria-vitest-coverage', String(process.pid)) const devServerWatchIgnored = [ '**/coverage/**', '**/test-results/**', '**/playwright-report/**', '**/dist/**', '**/src-tauri/target/**', ] function readUtf8File(filePath: string): string { const fd = openSync(filePath, 'r') try { return readFileSync(fd, 'utf-8') } finally { closeSync(fd) } } function writeUtf8File(filePath: string, content: string): void { const fd = openSync(filePath, 'w') try { writeFileSync(fd, content, 'utf-8') } finally { closeSync(fd) } } function pathStats(filePath: string) { const fd = openSync(filePath, 'r') try { return fstatSync(fd) } finally { closeSync(fd) } } function pathExists(filePath: string): boolean { try { pathStats(filePath) return true } catch { return false } } function directoryEntries(dir: string): Dirent[] { const directory = opendirSync(dir) try { const entries: Dirent[] = [] let entry = directory.readSync() while (entry) { entries.push(entry) entry = directory.readSync() } return entries } finally { directory.closeSync() } } function isInsideRelativePath(relative: string): boolean { return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)) } function resolveInside(root: string, target: string): string | null { const normalizedTarget = path.normalize(target) if (path.isAbsolute(normalizedTarget)) return null const candidate = path.normalize(`${root}${path.sep}${normalizedTarget}`) return isInsideRelativePath(path.relative(root, candidate)) ? candidate : null } function frontmatterString(frontmatter: Record, ...keys: string[]): string | null { const value = getFrontmatterValue(frontmatter, keys) return typeof value === 'string' ? value : null } function frontmatterStringArray(frontmatter: Record, ...keys: string[]): string[] { const value = getFrontmatterValue(frontmatter, keys) if (Array.isArray(value)) return value.map(String) if (typeof value === 'string') return [value] return [] } function frontmatterBool(frontmatter: Record, ...keys: string[]): boolean | null { return parseYamlBool(getFrontmatterValue(frontmatter, keys)) } function markdownTitle(content: string, frontmatter: Record, fallback: string): string { const title = frontmatterString(frontmatter, 'title') if (title) return title const h1Match = content.match(/^#\s+(.+)$/m) return h1Match ? h1Match[1].trim() : fallback } function markdownBodyText(content: string): string { return content.replace(/^#+\s+.+$/gm, '').replace(/[\n\r]+/g, ' ').trim() } function frontmatterWikiLinks(frontmatter: Record, ...keys: string[]): string[] { return frontmatterStringArray(frontmatter, ...keys).flatMap((value) => extractWikiLinks(value)) } function frontmatterRelationships(frontmatter: Record): Record { const relationships: Record = {} for (const [key, value] of Object.entries(frontmatter)) { if (DEDICATED_KEYS.has(key.toLowerCase())) continue const links = wikiLinksFromValue(value) if (links.length > 0) relationships[key] = links } return relationships } function frontmatterProperties(frontmatter: Record): Record { const properties: Record = {} for (const [key, value] of Object.entries(frontmatter)) { if (DEDICATED_KEYS.has(key.toLowerCase()) || key.trim().startsWith('_')) continue const propertyValue = frontmatterPropertyValue(value) if (propertyValue !== undefined) properties[key] = propertyValue } return properties } function isScalarFrontmatterProperty(value: unknown): value is number | boolean { return typeof value === 'number' || typeof value === 'boolean' } function singleStringArrayValue(value: unknown): string | undefined { if (!Array.isArray(value)) return undefined if (value.length !== 1) return undefined return typeof value[0] === 'string' ? value[0] : undefined } function wikiLinkFreeString(value: string): string | undefined { return extractWikiLinks(value).length === 0 ? value : undefined } function frontmatterPropertyValue(value: unknown): FrontmatterPropertyValue | undefined { if (value === null) return null if (isScalarFrontmatterProperty(value)) return value if (typeof value === 'string') return wikiLinkFreeString(value) const singleArrayValue = singleStringArrayValue(value) return singleArrayValue === undefined ? undefined : wikiLinkFreeString(singleArrayValue) } function parseMarkdownFile(filePath: string): VaultEntry | null { try { const raw = readUtf8File(filePath) const stats = pathStats(filePath) const { data, content } = matter(raw) const fm = data as Record const filename = path.basename(filePath) const basename = filename.replace(/\.md$/, '') const title = markdownTitle(content, fm, basename) const bodyText = markdownBodyText(content) const snippet = bodyText.slice(0, 200) return { path: filePath, filename, title, isA: frontmatterString(fm, 'is_a', 'is a', 'type'), aliases: frontmatterStringArray(fm, 'aliases'), belongsTo: frontmatterWikiLinks(fm, 'belongs_to', 'belongs to'), relatedTo: frontmatterWikiLinks(fm, 'related_to', 'related to'), status: frontmatterString(fm, 'status'), archived: frontmatterBool(fm, 'archived') ?? false, trashed: frontmatterBool(fm, 'trashed') ?? false, trashedAt: null, modifiedAt: stats.mtimeMs, createdAt: stats.birthtimeMs, fileSize: stats.size, snippet, wordCount: bodyText.split(/\s+/).filter(Boolean).length, relationships: frontmatterRelationships(fm), icon: frontmatterString(fm, 'icon'), color: frontmatterString(fm, 'color'), order: fm.order != null ? Number(fm.order) : null, sidebarLabel: frontmatterString(fm, 'sidebar label', 'sidebar_label'), template: frontmatterString(fm, 'template'), sort: frontmatterString(fm, 'sort'), view: frontmatterString(fm, 'view'), visible: frontmatterBool(fm, 'visible'), outgoingLinks: [], properties: frontmatterProperties(fm), } } catch { return null } } /** Recursively find all .md files under a directory. */ function findMarkdownFiles(dir: string): string[] { const results: string[] = [] try { const items = directoryEntries(dir) for (const item of items) { if (item.name.startsWith('.')) continue const full = resolveInside(dir, item.name) if (!full) continue if (item.isDirectory()) { results.push(...findMarkdownFiles(full)) } else if (item.name.endsWith('.md')) { results.push(full) } } } catch { // skip unreadable dirs } return results } function sendJson(res: ServerResponse, payload: unknown, statusCode = 200): void { res.statusCode = statusCode res.setHeader('Content-Type', 'application/json') res.end(JSON.stringify(payload)) } function commandString({ args, key }: CommandStringInput): string | null { const value = Reflect.get(args, key) return typeof value === 'string' && value.length > 0 ? value : null } function commandBool({ args, key }: CommandStringInput): boolean { const value = Reflect.get(args, key) return value === true || value === '1' || value === 'true' } function commandResponse({ payload, statusCode = 200 }: CommandResponseInput): VaultCommandResponse { return { payload, statusCode } } function invalidPathResponse(): VaultCommandResponse { return commandResponse({ payload: { error: 'Invalid or missing path' }, statusCode: 400 }) } function existingCommandPath(input: CommandStringInput): string | null { const filePath = commandString(input) return filePath && pathExists(filePath) ? filePath : null } function commandVaultList({ args }: VaultCommandContext): VaultCommandResponse { const dirPath = existingCommandPath({ args, key: 'path' }) if (!dirPath) return invalidPathResponse() const entries = findMarkdownFiles(dirPath).map(parseMarkdownFile).filter(Boolean) return commandResponse({ payload: entries }) } function commandVaultContent({ args }: VaultCommandContext): VaultCommandResponse { const filePath = existingCommandPath({ args, key: 'path' }) if (!filePath) return invalidPathResponse() return commandResponse({ payload: { content: readUtf8File(filePath) } }) } function commandVaultAllContent({ args }: VaultCommandContext): VaultCommandResponse { const dirPath = existingCommandPath({ args, key: 'path' }) if (!dirPath) return invalidPathResponse() const contentMap: Record = {} for (const filePath of findMarkdownFiles(dirPath)) { try { Reflect.set(contentMap, filePath, readUtf8File(filePath)) } catch { // Skip unreadable files. } } return commandResponse({ payload: contentMap }) } function commandVaultEntry({ args }: VaultCommandContext): VaultCommandResponse { const filePath = existingCommandPath({ args, key: 'path' }) if (!filePath) return invalidPathResponse() return commandResponse({ payload: parseMarkdownFile(filePath) }) } function commandVaultSearch({ args }: VaultCommandContext): VaultCommandResponse { const vaultPath = commandString({ args, key: 'vault_path' }) const query = (commandString({ args, key: 'query' }) ?? '').toLowerCase() const mode = commandString({ args, key: 'mode' }) ?? 'all' const excludeFrontmatter = commandBool({ args, key: 'exclude_frontmatter' }) const results = vaultPath && query ? collectVaultSearchResults({ vaultPath, query, excludeFrontmatter }) : [] return commandResponse({ payload: { results, elapsed_ms: results.length > 0 ? 1 : 0, query, mode } }) } function commandVaultSave({ args }: VaultCommandContext): VaultCommandResponse { const filePath = commandString({ args, key: 'path' }) const content = Reflect.get(args, 'content') if (!filePath || typeof content !== 'string') return commandResponse({ payload: { error: 'Missing path or content' }, statusCode: 400 }) mkdirSync(path.dirname(filePath), { recursive: true }) writeUtf8File(filePath, content) return commandResponse({ payload: null }) } function commandVaultRename({ args }: VaultCommandContext): VaultCommandResponse { const oldPath = commandString({ args, key: 'old_path' }) const newTitle = commandString({ args, key: 'new_title' }) if (!oldPath || !newTitle) return commandResponse({ payload: { error: 'Missing rename input' }, statusCode: 400 }) const vaultPath = commandString({ args, key: 'vault_path' }) const oldContent = readUtf8File(oldPath) const oldTitle = oldContent.match(/^# (.+)$/m)?.[1]?.trim() ?? '' const slug = newTitle.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') const newPath = markdownSiblingPath(oldPath, slug) if (!newPath) return commandResponse({ payload: { error: 'Invalid title' }, statusCode: 400 }) writeUtf8File(newPath, oldContent.replace(/^# .+$/m, `# ${newTitle}`)) if (newPath !== oldPath) unlinkSync(oldPath) const updatedFiles = vaultPath ? updateTitleWikilinks({ excludePath: newPath, oldTitle, vaultPath }) : 0 return commandResponse({ payload: { new_path: newPath, updated_files: updatedFiles } }) } function commandVaultRenameFilename({ args }: VaultCommandContext): VaultCommandResponse { const oldPath = commandString({ args, key: 'old_path' }) if (!oldPath) return commandResponse({ payload: { error: 'Missing old path' }, statusCode: 400 }) const filename = validateMarkdownFilenameStem(commandString({ args, key: 'new_filename_stem' })) if (!filename.ok) return commandResponse({ payload: { error: filename.error }, statusCode: 400 }) const newPath = markdownSiblingPath(oldPath, filename.stem) if (!newPath) return commandResponse({ payload: { error: 'Invalid filename' }, statusCode: 400 }) if (newPath !== oldPath && pathExists(newPath)) { return commandResponse({ payload: { error: 'A note with that name already exists' }, statusCode: 409 }) } const vaultPath = commandString({ args, key: 'vault_path' }) const oldTitle = parseMarkdownFile(oldPath)?.title ?? path.basename(oldPath, '.md') renameSync(oldPath, newPath) const updatedFiles = vaultPath ? updatePathWikilinks({ newPath, oldPath, oldTitle, vaultPath }) : 0 return commandResponse({ payload: { new_path: newPath, updated_files: updatedFiles } }) } function commandVaultDelete({ args }: VaultCommandContext): VaultCommandResponse { const filePath = commandString({ args, key: 'path' }) if (!filePath) return commandResponse({ payload: { error: 'Missing path' }, statusCode: 400 }) unlinkSync(filePath) return commandResponse({ payload: filePath }) } const VAULT_COMMAND_HANDLERS = new Map([ ['delete_note', commandVaultDelete], ['get_all_content', commandVaultAllContent], ['get_note_content', commandVaultContent], ['list_vault', commandVaultList], ['reload_vault', commandVaultList], ['reload_vault_entry', commandVaultEntry], ['rename_note', commandVaultRename], ['rename_note_filename', commandVaultRenameFilename], ['save_note_content', commandVaultSave], ['search_vault', commandVaultSearch], ['validate_note_content', commandVaultContent], ]) function runVaultCommand(context: VaultCommandContext): VaultCommandResponse { const handler = VAULT_COMMAND_HANDLERS.get(context.cmd) if (handler) return handler(context) return commandResponse({ payload: { error: 'Unsupported vault command' }, statusCode: 404 }) } function vaultCommandContext(payload: VaultCommandPayload): VaultCommandContext | null { if (!payload.cmd || !payload.args) return null return { cmd: payload.cmd, args: payload.args } } const VAULT_ENDPOINT_ARG_KEYS = ['path', 'vault_path', 'query', 'mode', 'reload', 'exclude_frontmatter'] as const function readVaultQueryArgs(url: URL): Record { const args: Record = {} for (const key of VAULT_ENDPOINT_ARG_KEYS) { const value = url.searchParams.get(key) if (value !== null) Reflect.set(args, key, value) } return args } async function readVaultEndpointArgs(url: URL, req: IncomingMessage): Promise> { if (req.method === 'POST') return readJsonBody>(req) return readVaultQueryArgs(url) } async function handleVaultReadCommand( { cmd, pathname, req, res, url }: VaultReadCommandInput, ): Promise { if (url.pathname !== pathname) return false if (req.method !== 'GET' && req.method !== 'POST') { sendJson(res, { error: 'Unsupported method' }, 405) return true } try { const response = runVaultCommand({ args: await readVaultEndpointArgs(url, req), cmd }) sendJson(res, response.payload, response.statusCode) } catch (err: unknown) { sendCaughtError(res, err, 'Vault read failed') } return true } function updateTitleWikilinks({ excludePath, oldTitle, vaultPath }: TitleWikilinkUpdateInput): number { const newPathStem = path.relative(vaultPath, excludePath).replace(/\.md$/i, '') const oldTargets = collectLegacyWikilinkTargets({ oldPath: excludePath, oldTitle, vaultPath }) return updateWikilinksForTargets({ excludePath, newTarget: newPathStem, oldTargets, vaultPath }) } function collectLegacyWikilinkTargets({ oldPath, oldTitle, vaultPath }: LegacyWikilinkTargetInput): string[] { const oldRelativeStem = path.relative(vaultPath, oldPath).replace(/\.md$/i, '') const oldFilenameStem = path.basename(oldPath, '.md') return [...new Set([oldTitle, oldRelativeStem, oldFilenameStem].filter(Boolean))] } function updateWikilinksForTargets({ excludePath, newTarget, oldTargets, vaultPath }: WikilinkTargetUpdateInput): number { if (oldTargets.length === 0) return 0 const allFiles = findMarkdownFiles(vaultPath) const targets = new Set(oldTargets) let updatedFiles = 0 for (const filePath of allFiles) { if (filePath === excludePath) continue try { const content = readUtf8File(filePath) const replaced = content.replace(/\[\[([^\]|]+)(\|[^\]]*)?\]\]/g, (match: string, target: string, pipe: string | undefined) => { if (!targets.has(target)) return match return pipe ? `[[${newTarget}${pipe}]]` : `[[${newTarget}]]` }) if (replaced !== content) { writeUtf8File(filePath, replaced) updatedFiles++ } } catch { // Skip unreadable files in the dev vault API. } } return updatedFiles } function updatePathWikilinks({ newPath, oldPath, oldTitle, vaultPath }: PathWikilinkUpdateInput): number { const newRelativeStem = path.relative(vaultPath, newPath).replace(/\.md$/i, '') const oldTargets = collectLegacyWikilinkTargets({ oldPath, oldTitle, vaultPath }) return updateWikilinksForTargets({ excludePath: newPath, newTarget: newRelativeStem, oldTargets, vaultPath }) } function handleVaultPing(url: URL, res: ServerResponse): boolean { if (url.pathname !== '/api/vault/ping') return false sendJson(res, { ok: true }) return true } async function handleVaultList(url: URL, req: IncomingMessage, res: ServerResponse): Promise { return handleVaultReadCommand({ cmd: 'list_vault', pathname: '/api/vault/list', req, res, url }) } async function handleVaultContent(url: URL, req: IncomingMessage, res: ServerResponse): Promise { return handleVaultReadCommand({ cmd: 'get_note_content', pathname: '/api/vault/content', req, res, url }) } async function handleVaultAllContent(url: URL, req: IncomingMessage, res: ServerResponse): Promise { return handleVaultReadCommand({ cmd: 'get_all_content', pathname: '/api/vault/all-content', req, res, url }) } async function handleVaultEntry(url: URL, req: IncomingMessage, res: ServerResponse): Promise { return handleVaultReadCommand({ cmd: 'reload_vault_entry', pathname: '/api/vault/entry', req, res, url }) } async function handleVaultSearch(url: URL, req: IncomingMessage, res: ServerResponse): Promise { return handleVaultReadCommand({ cmd: 'search_vault', pathname: '/api/vault/search', req, res, url }) } function collectVaultSearchResults({ excludeFrontmatter, vaultPath, query }: SearchRequestInput): VaultSearchResult[] { const results: VaultSearchResult[] = [] for (const filePath of findMarkdownFiles(vaultPath)) { const entry = parseMarkdownFile(filePath) if (!entry || entry.trashed) continue const rawContent = readUtf8File(filePath) if (entryMatchesSearch({ entry, rawContent, query, excludeFrontmatter })) { results.push(searchResultFromEntry(entry)) } } return results.slice(0, 20) } function searchableSearchContent(rawContent: string, excludeFrontmatter: boolean): string { return excludeFrontmatter ? matter(rawContent).content : rawContent } function entryMatchesSearch({ entry, excludeFrontmatter, rawContent, query }: SearchEntryInput): boolean { const content = searchableSearchContent(rawContent, excludeFrontmatter) return entry.title.toLowerCase().includes(query) || content.toLowerCase().includes(query) } function searchResultFromEntry(entry: VaultEntry): VaultSearchResult { return { title: entry.title, path: entry.path, snippet: entry.snippet, score: 1.0, note_type: entry.isA } } async function handleVaultSave(url: URL, req: IncomingMessage, res: ServerResponse): Promise { if (!isPostRoute(url, req, '/api/vault/save')) return false try { await saveVaultContent(req, res) } catch (err: unknown) { sendCaughtError(res, err, 'Save failed') } return true } async function saveVaultContent(req: IncomingMessage, res: ServerResponse): Promise { const { path: filePath, content } = await readJsonBody<{ path?: string; content?: string }>(req) if (!filePath || content === undefined) { sendJson(res, { error: 'Missing path or content' }, 400) return } mkdirSync(path.dirname(filePath), { recursive: true }) writeUtf8File(filePath, content) sendJson(res, null) } async function handleVaultRename(url: URL, req: IncomingMessage, res: ServerResponse): Promise { if (!isPostRoute(url, req, '/api/vault/rename')) return false try { await renameVaultNoteTitle(req, res) } catch (err: unknown) { sendCaughtError(res, err, 'Rename failed') } return true } async function renameVaultNoteTitle(req: IncomingMessage, res: ServerResponse): Promise { const { vault_path: vaultPath, old_path: oldPath, new_title: newTitle, } = await readJsonBody<{ vault_path?: string; old_path: string; new_title: string }>(req) const oldContent = readUtf8File(oldPath) const oldTitle = oldContent.match(/^# (.+)$/m)?.[1]?.trim() ?? '' const slug = newTitle.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') const newPath = markdownSiblingPath(oldPath, slug) if (!newPath) { sendJson(res, { error: 'Invalid title' }, 400) return } writeUtf8File(newPath, oldContent.replace(/^# .+$/m, `# ${newTitle}`)) if (newPath !== oldPath) unlinkSync(oldPath) const updatedFiles = vaultPath ? updateTitleWikilinks({ excludePath: newPath, oldTitle, vaultPath }) : 0 sendJson(res, { new_path: newPath, updated_files: updatedFiles }) } type FilenameStemValidation = | { ok: true; stem: string } | { ok: false; error: string } function validateMarkdownFilenameStem(value: unknown): FilenameStemValidation { const stem = String(value ?? '').trim().replace(/\.md$/i, '').trim() if (!stem) return { ok: false, error: 'New filename cannot be empty' } if (isUnsafeMarkdownFilenameStem(stem)) return { ok: false, error: 'Invalid filename' } return { ok: true, stem } } function isUnsafeMarkdownFilenameStem(stem: string): boolean { return stem === '.' || stem === '..' || stem.includes('/') || stem.includes('\\') } function markdownSiblingPath(filePath: string, stem: string): string | null { if (isUnsafeMarkdownFilenameStem(stem)) return null return resolveInside(path.dirname(filePath), `${stem}.md`) } async function handleVaultRenameFilename(url: URL, req: IncomingMessage, res: ServerResponse): Promise { if (!isPostRoute(url, req, '/api/vault/rename-filename')) return false try { await renameVaultNoteFilename(req, res) } catch (err: unknown) { sendCaughtError(res, err, 'Rename failed') } return true } async function renameVaultNoteFilename(req: IncomingMessage, res: ServerResponse): Promise { const { vault_path: vaultPath, old_path: oldPath, new_filename_stem: newFilenameStem, } = await readJsonBody<{ vault_path?: string; old_path: string; new_filename_stem: string }>(req) const filename = validateMarkdownFilenameStem(newFilenameStem) if (!filename.ok) { sendJson(res, { error: filename.error }, 400) return } const newPath = markdownSiblingPath(oldPath, filename.stem) if (!newPath) { sendJson(res, { error: 'Invalid filename' }, 400) return } if (newPath !== oldPath && pathExists(newPath)) { sendJson(res, { error: 'A note with that name already exists' }, 409) return } const oldTitle = parseMarkdownFile(oldPath)?.title ?? path.basename(oldPath, '.md') renameSync(oldPath, newPath) const updatedFiles = vaultPath ? updatePathWikilinks({ newPath, oldPath, oldTitle, vaultPath }) : 0 sendJson(res, { new_path: newPath, updated_files: updatedFiles }) } async function handleVaultDelete(url: URL, req: IncomingMessage, res: ServerResponse): Promise { if (url.pathname !== '/api/vault/delete' || req.method !== 'POST') return false try { const body = await readRequestBody(req) const { path: filePath } = JSON.parse(body) if (!filePath) { sendJson(res, { error: 'Missing path' }, 400) return true } unlinkSync(filePath) sendJson(res, filePath) } catch (err: unknown) { sendJson(res, { error: err instanceof Error ? err.message : 'Delete failed' }, 500) } return true } async function handleVaultCommand(url: URL, req: IncomingMessage, res: ServerResponse): Promise { if (!isPostRoute(url, req, '/api/vault/command')) return false try { const context = vaultCommandContext(await readJsonBody(req)) if (!context) { sendJson(res, { error: 'Invalid vault command' }, 400) return true } const response = runVaultCommand(context) sendJson(res, response.payload, response.statusCode) } catch (err: unknown) { sendCaughtError(res, err, 'Vault command failed') } return true } async function handleVaultApiRequest(req: IncomingMessage, res: ServerResponse): Promise { const url = new URL(req.url ?? '/', `http://${req.headers.host}`) const handlers = [ () => Promise.resolve(handleVaultPing(url, res)), () => handleVaultCommand(url, req, res), () => handleVaultList(url, req, res), () => handleVaultContent(url, req, res), () => handleVaultAllContent(url, req, res), () => handleVaultEntry(url, req, res), () => handleVaultSearch(url, req, res), () => handleVaultSave(url, req, res), () => handleVaultRename(url, req, res), () => handleVaultRenameFilename(url, req, res), () => handleVaultDelete(url, req, res), ] for (const handler of handlers) { if (await handler()) return true } return false } function vaultApiPlugin(): Plugin { return { name: 'vault-api', configureServer(server) { server.middlewares.use(async (req, res, next) => { if (await handleVaultApiRequest(req, res)) return next() }) }, } } // --- Proxy helpers --- function readRequestBody(req: IncomingMessage): Promise { return new Promise((resolve) => { let body = '' req.on('data', (chunk: Buffer) => { body += chunk.toString() }) req.on('end', () => resolve(body)) }) } async function readJsonBody(req: IncomingMessage): Promise { return JSON.parse(await readRequestBody(req)) as T } function isPostRoute(url: URL, req: IncomingMessage, pathname: string): boolean { return url.pathname === pathname && req.method === 'POST' } function sendCaughtError(res: ServerResponse, err: unknown, fallback: string): void { sendJson(res, { error: err instanceof Error ? err.message : fallback }, 500) } /** WebSocket proxy info endpoint — tells the frontend where the MCP bridge is */ function mcpBridgeInfoPlugin(): Plugin { return { name: 'mcp-bridge-info', configureServer(server) { server.middlewares.use((req, res, next) => { if (req.url !== '/api/mcp/info') return next() res.setHeader('Content-Type', 'application/json') res.end(JSON.stringify({ wsUrl: `ws://localhost:${process.env.MCP_WS_PORT || 9710}`, available: true, })) }) }, } } // https://vite.dev/config/ export default defineConfig({ plugins: [react(), tailwindcss(), vaultApiPlugin(), mcpBridgeInfoPlugin()], resolve: { alias: { '@': path.resolve(__dirname, './src'), }, }, // Inject the demo-vault-v2 path in local dev only — production Tauri builds and // CI must resolve the default vault path at runtime via the backend to avoid // baking the CI runner's absolute path into the distributed bundle. define: { ...(process.env.CI || (process.env.TAURI_PLATFORM && !process.env.TAURI_DEBUG) ? {} : { __DEMO_VAULT_PATH__: JSON.stringify(path.resolve(__dirname, 'demo-vault-v2')) }), }, // Prevent vite from obscuring Rust errors clearScreen: false, // Tauri expects a fixed port server: { port: 5202, strictPort: true, allowedHosts: true, watch: { ignored: devServerWatchIgnored, }, }, // Env variables starting with TAURI_ are exposed to the frontend envPrefix: ['VITE_', 'TAURI_'], build: { // Tauri uses Chromium on Windows and WebKit on macOS/Linux target: process.env.TAURI_PLATFORM === 'windows' ? 'chrome105' : 'safari13', // Don't minify for debug builds minify: !process.env.TAURI_DEBUG ? 'esbuild' : false, // Produce sourcemaps for debug builds sourcemap: !!process.env.TAURI_DEBUG, }, test: { globals: true, environment: 'jsdom', setupFiles: ['./src/test/setup.ts'], include: ['src/**/*.{test,spec}.{ts,tsx}'], // The full jsdom suite is heavy enough that unconstrained worker fan-out can // starve UI tests on local dev machines. Keep the default hook path stable, // while still allowing CI or one-off runs to opt into a different cap. maxWorkers: process.env.VITEST_MAX_WORKERS ?? 4, testTimeout: 10_000, coverage: { provider: 'v8', reporter: ['text', 'json', 'html', 'lcov'], // Keep coverage temp files off the mounted workspace to avoid flaky // read-after-write races when Vitest re-reads its own coverage shards. reportsDirectory: vitestCoverageDirectory, include: ['src/**/*.{ts,tsx}'], exclude: [ 'src/**/*.{test,spec}.{ts,tsx}', 'src/test/**', 'src/mock-tauri.ts', 'src/main.tsx', 'src/types.ts', 'src/hooks/useMcpBridge.ts', 'src/hooks/useAiAgent.ts', 'src/utils/ai-chat.ts', 'src/utils/ai-agent.ts', 'src/components/ui/dropdown-menu.tsx', 'src/components/ui/scroll-area.tsx', 'src/components/ui/select.tsx', 'src/components/ui/separator.tsx', 'src/components/ui/tabs.tsx', 'src/components/ui/tooltip.tsx', 'src/components/ui/card.tsx', ], thresholds: { lines: 70, functions: 70, branches: 70, statements: 70, }, }, }, })