2026-02-14 18:22:42 +01:00
|
|
|
/// <reference types="vitest/config" />
|
2026-04-10 17:59:21 +02:00
|
|
|
import type { IncomingMessage, ServerResponse } from 'http'
|
2026-02-16 16:56:44 +01:00
|
|
|
import path from 'path'
|
2026-02-20 10:39:45 +01:00
|
|
|
import fs from 'fs'
|
2026-04-19 18:03:58 +02:00
|
|
|
import os from 'os'
|
2026-02-20 10:39:45 +01:00
|
|
|
import { defineConfig, type Plugin } from 'vite'
|
2026-02-14 18:20:07 +01:00
|
|
|
import react from '@vitejs/plugin-react'
|
2026-02-16 16:56:44 +01:00
|
|
|
import tailwindcss from '@tailwindcss/vite'
|
2026-02-20 10:39:45 +01:00
|
|
|
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
|
2026-03-06 21:52:40 +01:00
|
|
|
archived: boolean
|
|
|
|
|
trashed: boolean
|
|
|
|
|
trashedAt: number | null
|
2026-02-20 10:39:45 +01:00
|
|
|
modifiedAt: number | null
|
|
|
|
|
createdAt: number | null
|
|
|
|
|
fileSize: number
|
|
|
|
|
snippet: string
|
2026-03-06 21:52:40 +01:00
|
|
|
wordCount: number
|
2026-02-20 10:39:45 +01:00
|
|
|
relationships: Record<string, string[]>
|
2026-03-06 21:52:40 +01:00
|
|
|
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<string, string | number | boolean | null>
|
2026-02-20 10:39:45 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 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[] {
|
|
|
|
|
if (typeof value === 'string') return extractWikiLinks(value)
|
|
|
|
|
if (Array.isArray(value)) {
|
|
|
|
|
return value.flatMap((v) => (typeof v === 'string' ? extractWikiLinks(v) : []))
|
|
|
|
|
}
|
|
|
|
|
return []
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Frontmatter keys that map to dedicated VaultEntry fields (skip in generic relationships)
|
|
|
|
|
const DEDICATED_KEYS = new Set([
|
|
|
|
|
'aliases', 'is_a', 'is a', 'belongs_to', 'belongs to',
|
2026-03-18 01:56:33 +01:00
|
|
|
'related_to', 'related to', 'status', 'title',
|
2026-02-20 10:39:45 +01:00
|
|
|
])
|
|
|
|
|
|
2026-04-19 18:03:58 +02:00
|
|
|
function getFrontmatterValue(
|
|
|
|
|
frontmatter: Record<string, unknown>,
|
|
|
|
|
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))
|
|
|
|
|
|
2026-04-23 13:45:24 +02:00
|
|
|
const devServerWatchIgnored = [
|
|
|
|
|
'**/coverage/**',
|
|
|
|
|
'**/test-results/**',
|
|
|
|
|
'**/playwright-report/**',
|
|
|
|
|
'**/dist/**',
|
|
|
|
|
'**/src-tauri/target/**',
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
function frontmatterString(frontmatter: Record<string, unknown>, ...keys: string[]): string | null {
|
|
|
|
|
const value = getFrontmatterValue(frontmatter, keys)
|
|
|
|
|
return typeof value === 'string' ? value : null
|
|
|
|
|
}
|
2026-02-20 10:39:45 +01:00
|
|
|
|
2026-04-23 13:45:24 +02:00
|
|
|
function frontmatterStringArray(frontmatter: Record<string, unknown>, ...keys: string[]): string[] {
|
|
|
|
|
const value = getFrontmatterValue(frontmatter, keys)
|
|
|
|
|
if (Array.isArray(value)) return value.map(String)
|
|
|
|
|
if (typeof value === 'string') return [value]
|
|
|
|
|
return []
|
|
|
|
|
}
|
2026-02-20 10:39:45 +01:00
|
|
|
|
2026-04-23 13:45:24 +02:00
|
|
|
function frontmatterBool(frontmatter: Record<string, unknown>, ...keys: string[]): boolean | null {
|
|
|
|
|
return parseYamlBool(getFrontmatterValue(frontmatter, keys))
|
|
|
|
|
}
|
2026-02-20 10:39:45 +01:00
|
|
|
|
2026-04-23 13:45:24 +02:00
|
|
|
function markdownTitle(content: string, frontmatter: Record<string, unknown>, fallback: string): string {
|
|
|
|
|
const title = frontmatterString(frontmatter, 'title')
|
|
|
|
|
if (title) return title
|
2026-02-20 10:39:45 +01:00
|
|
|
|
2026-04-23 13:45:24 +02:00
|
|
|
const h1Match = content.match(/^#\s+(.+)$/m)
|
|
|
|
|
return h1Match ? h1Match[1].trim() : fallback
|
|
|
|
|
}
|
2026-02-20 10:39:45 +01:00
|
|
|
|
2026-04-23 13:45:24 +02:00
|
|
|
function markdownBodyText(content: string): string {
|
|
|
|
|
return content.replace(/^#+\s+.+$/gm, '').replace(/[\n\r]+/g, ' ').trim()
|
|
|
|
|
}
|
2026-02-20 10:39:45 +01:00
|
|
|
|
2026-04-23 13:45:24 +02:00
|
|
|
function frontmatterWikiLinks(frontmatter: Record<string, unknown>, ...keys: string[]): string[] {
|
|
|
|
|
return frontmatterStringArray(frontmatter, ...keys).flatMap((value) => extractWikiLinks(value))
|
|
|
|
|
}
|
2026-02-20 10:39:45 +01:00
|
|
|
|
2026-04-23 13:45:24 +02:00
|
|
|
function frontmatterRelationships(frontmatter: Record<string, unknown>): Record<string, string[]> {
|
|
|
|
|
const relationships: Record<string, string[]> = {}
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-02-20 10:39:45 +01:00
|
|
|
|
2026-04-23 13:45:24 +02:00
|
|
|
function parseMarkdownFile(filePath: string): VaultEntry | null {
|
|
|
|
|
try {
|
|
|
|
|
const raw = fs.readFileSync(filePath, 'utf-8')
|
|
|
|
|
const stats = fs.statSync(filePath)
|
|
|
|
|
const { data, content } = matter(raw)
|
|
|
|
|
const fm = data as Record<string, unknown>
|
2026-02-20 10:39:45 +01:00
|
|
|
|
2026-04-23 13:45:24 +02:00
|
|
|
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)
|
2026-03-06 21:52:40 +01:00
|
|
|
|
2026-02-20 10:39:45 +01:00
|
|
|
return {
|
|
|
|
|
path: filePath,
|
|
|
|
|
filename,
|
|
|
|
|
title,
|
2026-04-23 13:45:24 +02:00
|
|
|
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,
|
2026-03-06 21:52:40 +01:00
|
|
|
trashedAt: null,
|
2026-02-20 10:39:45 +01:00
|
|
|
modifiedAt: stats.mtimeMs,
|
2026-04-23 13:45:24 +02:00
|
|
|
createdAt: stats.birthtimeMs,
|
2026-02-20 10:39:45 +01:00
|
|
|
fileSize: stats.size,
|
|
|
|
|
snippet,
|
2026-03-06 21:52:40 +01:00
|
|
|
wordCount: bodyText.split(/\s+/).filter(Boolean).length,
|
2026-04-23 13:45:24 +02:00
|
|
|
relationships: frontmatterRelationships(fm),
|
|
|
|
|
icon: frontmatterString(fm, 'icon'),
|
|
|
|
|
color: frontmatterString(fm, 'color'),
|
2026-03-06 21:52:40 +01:00
|
|
|
order: fm.order != null ? Number(fm.order) : null,
|
2026-04-23 13:45:24 +02:00
|
|
|
sidebarLabel: frontmatterString(fm, 'sidebar label', 'sidebar_label'),
|
|
|
|
|
template: frontmatterString(fm, 'template'),
|
|
|
|
|
sort: frontmatterString(fm, 'sort'),
|
|
|
|
|
view: frontmatterString(fm, 'view'),
|
|
|
|
|
visible: frontmatterBool(fm, 'visible'),
|
2026-03-06 21:52:40 +01:00
|
|
|
outgoingLinks: [],
|
|
|
|
|
properties: {},
|
2026-02-20 10:39:45 +01:00
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Recursively find all .md files under a directory. */
|
|
|
|
|
function findMarkdownFiles(dir: string): string[] {
|
|
|
|
|
const results: string[] = []
|
|
|
|
|
try {
|
|
|
|
|
const items = fs.readdirSync(dir, { withFileTypes: true })
|
|
|
|
|
for (const item of items) {
|
|
|
|
|
if (item.name.startsWith('.')) continue
|
|
|
|
|
const full = path.join(dir, item.name)
|
|
|
|
|
if (item.isDirectory()) {
|
|
|
|
|
results.push(...findMarkdownFiles(full))
|
|
|
|
|
} else if (item.name.endsWith('.md')) {
|
|
|
|
|
results.push(full)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// skip unreadable dirs
|
|
|
|
|
}
|
|
|
|
|
return results
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 17:59:21 +02:00
|
|
|
function sendJson(res: ServerResponse, payload: unknown, statusCode = 200): void {
|
|
|
|
|
res.statusCode = statusCode
|
|
|
|
|
res.setHeader('Content-Type', 'application/json')
|
|
|
|
|
res.end(JSON.stringify(payload))
|
|
|
|
|
}
|
2026-02-20 10:39:45 +01:00
|
|
|
|
2026-04-10 17:59:21 +02:00
|
|
|
function readExistingQueryPath(url: URL, res: ServerResponse, key: string): string | null {
|
|
|
|
|
const filePath = url.searchParams.get(key)
|
|
|
|
|
if (!filePath || !fs.existsSync(filePath)) {
|
|
|
|
|
sendJson(res, { error: 'Invalid or missing path' }, 400)
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
return filePath
|
|
|
|
|
}
|
2026-02-20 10:39:45 +01:00
|
|
|
|
2026-04-10 20:20:08 +02:00
|
|
|
function updateTitleWikilinks(vaultPath: string, oldTitle: string, _newTitle: string, excludePath: string): number {
|
|
|
|
|
const newPathStem = path.relative(vaultPath, excludePath).replace(/\.md$/i, '')
|
|
|
|
|
const oldTargets = collectLegacyWikilinkTargets(oldTitle, excludePath, vaultPath)
|
|
|
|
|
return updateWikilinksForTargets(vaultPath, oldTargets, newPathStem, excludePath)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function collectLegacyWikilinkTargets(oldTitle: string, oldPath: string, vaultPath: string): 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(vaultPath: string, oldTargets: string[], newTarget: string, excludePath: string): number {
|
|
|
|
|
if (oldTargets.length === 0) return 0
|
2026-04-10 17:59:21 +02:00
|
|
|
const allFiles = findMarkdownFiles(vaultPath)
|
2026-04-10 20:20:08 +02:00
|
|
|
const escaped = oldTargets.map(target => target.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
|
|
|
const pattern = new RegExp(`\\[\\[(?:${escaped.join('|')})(\\|[^\\]]*?)?\\]\\]`, 'g')
|
2026-04-10 17:59:21 +02:00
|
|
|
let updatedFiles = 0
|
|
|
|
|
for (const filePath of allFiles) {
|
|
|
|
|
if (filePath === excludePath) continue
|
|
|
|
|
try {
|
|
|
|
|
const content = fs.readFileSync(filePath, 'utf-8')
|
|
|
|
|
const replaced = content.replace(pattern, (_m: string, pipe: string | undefined) =>
|
2026-04-10 20:20:08 +02:00
|
|
|
pipe ? `[[${newTarget}${pipe}]]` : `[[${newTarget}]]`
|
2026-04-10 17:59:21 +02:00
|
|
|
)
|
|
|
|
|
if (replaced !== content) {
|
|
|
|
|
fs.writeFileSync(filePath, replaced, 'utf-8')
|
|
|
|
|
updatedFiles++
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// Skip unreadable files in the dev vault API.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return updatedFiles
|
|
|
|
|
}
|
2026-02-20 10:39:45 +01:00
|
|
|
|
2026-04-10 20:20:08 +02:00
|
|
|
function updatePathWikilinks(vaultPath: string, oldPath: string, newPath: string, oldTitle: string): number {
|
2026-04-10 17:59:21 +02:00
|
|
|
const newRelativeStem = path.relative(vaultPath, newPath).replace(/\.md$/i, '')
|
2026-04-10 20:20:08 +02:00
|
|
|
const oldTargets = collectLegacyWikilinkTargets(oldTitle, oldPath, vaultPath)
|
|
|
|
|
return updateWikilinksForTargets(vaultPath, oldTargets, newRelativeStem, newPath)
|
2026-04-10 17:59:21 +02:00
|
|
|
}
|
2026-02-20 10:39:45 +01:00
|
|
|
|
2026-04-10 17:59:21 +02:00
|
|
|
function handleVaultPing(url: URL, res: ServerResponse): boolean {
|
|
|
|
|
if (url.pathname !== '/api/vault/ping') return false
|
|
|
|
|
sendJson(res, { ok: true })
|
|
|
|
|
return true
|
|
|
|
|
}
|
2026-02-20 10:39:45 +01:00
|
|
|
|
2026-04-10 17:59:21 +02:00
|
|
|
function handleVaultList(url: URL, res: ServerResponse): boolean {
|
|
|
|
|
if (url.pathname !== '/api/vault/list') return false
|
|
|
|
|
const dirPath = readExistingQueryPath(url, res, 'path')
|
|
|
|
|
if (!dirPath) return true
|
|
|
|
|
const entries = findMarkdownFiles(dirPath).map(parseMarkdownFile).filter(Boolean)
|
|
|
|
|
sendJson(res, entries)
|
|
|
|
|
return true
|
|
|
|
|
}
|
test: add real filesystem vault integration tests
Replace mock vault approach with real filesystem I/O for integration
tests. Each test copies tests/fixtures/test-vault/ to a temp directory,
overrides mock handlers to point at it, and verifies actual file
operations through the vite dev server middleware.
Tests cover: vault loading, archive/trash filtering, note creation,
rename with filesystem update, wikilink cascade on rename, relationship
display, and real file content loading.
Also extends vite.config.ts vault API middleware with write endpoints
(save, rename, delete, search, entry) and fixes getBool to handle
YAML 1.2 string values like "Yes"/"yes" (js-yaml 4.x compatibility).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:46:45 +01:00
|
|
|
|
2026-04-10 17:59:21 +02:00
|
|
|
function handleVaultContent(url: URL, res: ServerResponse): boolean {
|
|
|
|
|
if (url.pathname !== '/api/vault/content') return false
|
|
|
|
|
const filePath = readExistingQueryPath(url, res, 'path')
|
|
|
|
|
if (!filePath) return true
|
|
|
|
|
sendJson(res, { content: fs.readFileSync(filePath, 'utf-8') })
|
|
|
|
|
return true
|
|
|
|
|
}
|
test: add real filesystem vault integration tests
Replace mock vault approach with real filesystem I/O for integration
tests. Each test copies tests/fixtures/test-vault/ to a temp directory,
overrides mock handlers to point at it, and verifies actual file
operations through the vite dev server middleware.
Tests cover: vault loading, archive/trash filtering, note creation,
rename with filesystem update, wikilink cascade on rename, relationship
display, and real file content loading.
Also extends vite.config.ts vault API middleware with write endpoints
(save, rename, delete, search, entry) and fixes getBool to handle
YAML 1.2 string values like "Yes"/"yes" (js-yaml 4.x compatibility).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:46:45 +01:00
|
|
|
|
2026-04-10 17:59:21 +02:00
|
|
|
function handleVaultAllContent(url: URL, res: ServerResponse): boolean {
|
|
|
|
|
if (url.pathname !== '/api/vault/all-content') return false
|
|
|
|
|
const dirPath = readExistingQueryPath(url, res, 'path')
|
|
|
|
|
if (!dirPath) return true
|
|
|
|
|
const contentMap: Record<string, string> = {}
|
|
|
|
|
for (const filePath of findMarkdownFiles(dirPath)) {
|
|
|
|
|
try {
|
|
|
|
|
contentMap[filePath] = fs.readFileSync(filePath, 'utf-8')
|
|
|
|
|
} catch {
|
|
|
|
|
// Skip unreadable files.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
sendJson(res, contentMap)
|
|
|
|
|
return true
|
|
|
|
|
}
|
test: add real filesystem vault integration tests
Replace mock vault approach with real filesystem I/O for integration
tests. Each test copies tests/fixtures/test-vault/ to a temp directory,
overrides mock handlers to point at it, and verifies actual file
operations through the vite dev server middleware.
Tests cover: vault loading, archive/trash filtering, note creation,
rename with filesystem update, wikilink cascade on rename, relationship
display, and real file content loading.
Also extends vite.config.ts vault API middleware with write endpoints
(save, rename, delete, search, entry) and fixes getBool to handle
YAML 1.2 string values like "Yes"/"yes" (js-yaml 4.x compatibility).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:46:45 +01:00
|
|
|
|
2026-04-10 17:59:21 +02:00
|
|
|
function handleVaultEntry(url: URL, res: ServerResponse): boolean {
|
|
|
|
|
if (url.pathname !== '/api/vault/entry') return false
|
|
|
|
|
const filePath = readExistingQueryPath(url, res, 'path')
|
|
|
|
|
if (!filePath) return true
|
|
|
|
|
sendJson(res, parseMarkdownFile(filePath))
|
|
|
|
|
return true
|
|
|
|
|
}
|
test: add real filesystem vault integration tests
Replace mock vault approach with real filesystem I/O for integration
tests. Each test copies tests/fixtures/test-vault/ to a temp directory,
overrides mock handlers to point at it, and verifies actual file
operations through the vite dev server middleware.
Tests cover: vault loading, archive/trash filtering, note creation,
rename with filesystem update, wikilink cascade on rename, relationship
display, and real file content loading.
Also extends vite.config.ts vault API middleware with write endpoints
(save, rename, delete, search, entry) and fixes getBool to handle
YAML 1.2 string values like "Yes"/"yes" (js-yaml 4.x compatibility).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:46:45 +01:00
|
|
|
|
2026-04-10 17:59:21 +02:00
|
|
|
function handleVaultSearch(url: URL, res: ServerResponse): boolean {
|
|
|
|
|
if (url.pathname !== '/api/vault/search') return false
|
|
|
|
|
const vaultPath = url.searchParams.get('vault_path')
|
|
|
|
|
const query = (url.searchParams.get('query') ?? '').toLowerCase()
|
|
|
|
|
const mode = url.searchParams.get('mode') ?? 'all'
|
|
|
|
|
if (!vaultPath || !query) {
|
|
|
|
|
sendJson(res, { results: [], elapsed_ms: 0, query, mode })
|
|
|
|
|
return true
|
|
|
|
|
}
|
test: add real filesystem vault integration tests
Replace mock vault approach with real filesystem I/O for integration
tests. Each test copies tests/fixtures/test-vault/ to a temp directory,
overrides mock handlers to point at it, and verifies actual file
operations through the vite dev server middleware.
Tests cover: vault loading, archive/trash filtering, note creation,
rename with filesystem update, wikilink cascade on rename, relationship
display, and real file content loading.
Also extends vite.config.ts vault API middleware with write endpoints
(save, rename, delete, search, entry) and fixes getBool to handle
YAML 1.2 string values like "Yes"/"yes" (js-yaml 4.x compatibility).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:46:45 +01:00
|
|
|
|
2026-04-10 17:59:21 +02:00
|
|
|
const results: { title: string; path: string; snippet: string; score: number; note_type: string | null }[] = []
|
|
|
|
|
for (const filePath of findMarkdownFiles(vaultPath)) {
|
|
|
|
|
const entry = parseMarkdownFile(filePath)
|
|
|
|
|
if (!entry || entry.trashed) continue
|
|
|
|
|
const raw = fs.readFileSync(filePath, 'utf-8')
|
|
|
|
|
if (entry.title.toLowerCase().includes(query) || raw.toLowerCase().includes(query)) {
|
|
|
|
|
results.push({ title: entry.title, path: entry.path, snippet: entry.snippet, score: 1.0, note_type: entry.isA })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
sendJson(res, { results: results.slice(0, 20), elapsed_ms: 1, query, mode })
|
|
|
|
|
return true
|
|
|
|
|
}
|
test: add real filesystem vault integration tests
Replace mock vault approach with real filesystem I/O for integration
tests. Each test copies tests/fixtures/test-vault/ to a temp directory,
overrides mock handlers to point at it, and verifies actual file
operations through the vite dev server middleware.
Tests cover: vault loading, archive/trash filtering, note creation,
rename with filesystem update, wikilink cascade on rename, relationship
display, and real file content loading.
Also extends vite.config.ts vault API middleware with write endpoints
(save, rename, delete, search, entry) and fixes getBool to handle
YAML 1.2 string values like "Yes"/"yes" (js-yaml 4.x compatibility).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:46:45 +01:00
|
|
|
|
2026-04-10 17:59:21 +02:00
|
|
|
async function handleVaultSave(url: URL, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
|
|
|
if (url.pathname !== '/api/vault/save' || req.method !== 'POST') return false
|
|
|
|
|
try {
|
|
|
|
|
const body = await readRequestBody(req)
|
|
|
|
|
const { path: filePath, content } = JSON.parse(body)
|
|
|
|
|
if (!filePath || content === undefined) {
|
|
|
|
|
sendJson(res, { error: 'Missing path or content' }, 400)
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
|
|
|
|
fs.writeFileSync(filePath, content, 'utf-8')
|
|
|
|
|
sendJson(res, null)
|
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
sendJson(res, { error: err instanceof Error ? err.message : 'Save failed' }, 500)
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleVaultRename(url: URL, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
|
|
|
if (url.pathname !== '/api/vault/rename' || req.method !== 'POST') return false
|
|
|
|
|
try {
|
|
|
|
|
const body = await readRequestBody(req)
|
|
|
|
|
const { vault_path: vaultPath, old_path: oldPath, new_title: newTitle } = JSON.parse(body)
|
|
|
|
|
const oldContent = fs.readFileSync(oldPath, 'utf-8')
|
|
|
|
|
const oldTitle = oldContent.match(/^# (.+)$/m)?.[1]?.trim() ?? ''
|
|
|
|
|
const slug = newTitle.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
|
|
|
|
|
const newPath = path.join(path.dirname(oldPath), `${slug}.md`)
|
|
|
|
|
const newContent = oldContent.replace(/^# .+$/m, `# ${newTitle}`)
|
|
|
|
|
|
|
|
|
|
fs.writeFileSync(newPath, newContent, 'utf-8')
|
|
|
|
|
if (newPath !== oldPath) fs.unlinkSync(oldPath)
|
|
|
|
|
|
|
|
|
|
const updatedFiles = vaultPath ? updateTitleWikilinks(vaultPath, oldTitle, newTitle, newPath) : 0
|
|
|
|
|
sendJson(res, { new_path: newPath, updated_files: updatedFiles })
|
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
sendJson(res, { error: err instanceof Error ? err.message : 'Rename failed' }, 500)
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 13:45:24 +02:00
|
|
|
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('\\')
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 17:59:21 +02:00
|
|
|
async function handleVaultRenameFilename(url: URL, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
|
|
|
if (url.pathname !== '/api/vault/rename-filename' || req.method !== 'POST') return false
|
|
|
|
|
try {
|
|
|
|
|
const body = await readRequestBody(req)
|
|
|
|
|
const {
|
|
|
|
|
vault_path: vaultPath,
|
|
|
|
|
old_path: oldPath,
|
|
|
|
|
new_filename_stem: newFilenameStem,
|
|
|
|
|
} = JSON.parse(body)
|
2026-04-23 13:45:24 +02:00
|
|
|
const filename = validateMarkdownFilenameStem(newFilenameStem)
|
|
|
|
|
if (!filename.ok) {
|
|
|
|
|
sendJson(res, { error: filename.error }, 400)
|
2026-04-10 17:59:21 +02:00
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 13:45:24 +02:00
|
|
|
const newPath = path.join(path.dirname(oldPath), `${filename.stem}.md`)
|
2026-04-10 20:20:08 +02:00
|
|
|
const oldTitle = parseMarkdownFile(oldPath)?.title ?? path.basename(oldPath, '.md')
|
2026-04-10 17:59:21 +02:00
|
|
|
if (newPath !== oldPath && fs.existsSync(newPath)) {
|
|
|
|
|
sendJson(res, { error: 'A note with that name already exists' }, 409)
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fs.renameSync(oldPath, newPath)
|
2026-04-10 20:20:08 +02:00
|
|
|
const updatedFiles = vaultPath ? updatePathWikilinks(vaultPath, oldPath, newPath, oldTitle) : 0
|
2026-04-10 17:59:21 +02:00
|
|
|
sendJson(res, { new_path: newPath, updated_files: updatedFiles })
|
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
sendJson(res, { error: err instanceof Error ? err.message : 'Rename failed' }, 500)
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleVaultDelete(url: URL, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
fs.unlinkSync(filePath)
|
|
|
|
|
sendJson(res, filePath)
|
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
sendJson(res, { error: err instanceof Error ? err.message : 'Delete failed' }, 500)
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleVaultApiRequest(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
|
|
|
const url = new URL(req.url ?? '/', `http://${req.headers.host}`)
|
2026-04-19 18:03:58 +02:00
|
|
|
const handlers = [
|
|
|
|
|
() => Promise.resolve(handleVaultPing(url, res)),
|
|
|
|
|
() => Promise.resolve(handleVaultList(url, res)),
|
|
|
|
|
() => Promise.resolve(handleVaultContent(url, res)),
|
|
|
|
|
() => Promise.resolve(handleVaultAllContent(url, res)),
|
|
|
|
|
() => Promise.resolve(handleVaultEntry(url, res)),
|
|
|
|
|
() => Promise.resolve(handleVaultSearch(url, 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 17:59:21 +02:00
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function vaultApiPlugin(): Plugin {
|
|
|
|
|
return {
|
|
|
|
|
name: 'vault-api',
|
|
|
|
|
configureServer(server) {
|
|
|
|
|
server.middlewares.use(async (req, res, next) => {
|
|
|
|
|
if (await handleVaultApiRequest(req, res)) return
|
2026-02-20 10:39:45 +01:00
|
|
|
next()
|
|
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-14 18:20:07 +01:00
|
|
|
|
2026-03-29 15:05:56 +02:00
|
|
|
// --- Proxy helpers ---
|
2026-02-20 22:52:56 +01:00
|
|
|
|
2026-04-10 17:59:21 +02:00
|
|
|
function readRequestBody(req: IncomingMessage): Promise<string> {
|
2026-02-20 22:52:56 +01:00
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
let body = ''
|
|
|
|
|
req.on('data', (chunk: Buffer) => { body += chunk.toString() })
|
|
|
|
|
req.on('end', () => resolve(body))
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 22:59:00 +01:00
|
|
|
/** 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,
|
|
|
|
|
}))
|
|
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 18:20:07 +01:00
|
|
|
// https://vite.dev/config/
|
|
|
|
|
export default defineConfig({
|
2026-03-29 15:05:56 +02:00
|
|
|
plugins: [react(), tailwindcss(), vaultApiPlugin(), mcpBridgeInfoPlugin()],
|
2026-02-16 16:56:44 +01:00
|
|
|
|
|
|
|
|
resolve: {
|
|
|
|
|
alias: {
|
|
|
|
|
'@': path.resolve(__dirname, './src'),
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-02-14 18:20:07 +01:00
|
|
|
|
2026-04-06 14:31:04 +02:00
|
|
|
// 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.
|
2026-03-17 13:14:56 +01:00
|
|
|
define: {
|
2026-04-06 14:31:04 +02:00
|
|
|
...(process.env.CI || (process.env.TAURI_PLATFORM && !process.env.TAURI_DEBUG)
|
2026-04-04 10:53:29 +02:00
|
|
|
? {}
|
|
|
|
|
: { __DEMO_VAULT_PATH__: JSON.stringify(path.resolve(__dirname, 'demo-vault-v2')) }),
|
2026-03-17 13:14:56 +01:00
|
|
|
},
|
|
|
|
|
|
2026-02-14 18:20:07 +01:00
|
|
|
// Prevent vite from obscuring Rust errors
|
|
|
|
|
clearScreen: false,
|
|
|
|
|
|
|
|
|
|
// Tauri expects a fixed port
|
|
|
|
|
server: {
|
2026-03-02 23:20:49 +01:00
|
|
|
port: 5202,
|
2026-02-14 18:20:07 +01:00
|
|
|
strictPort: true,
|
2026-02-16 14:04:15 +01:00
|
|
|
allowedHosts: true,
|
2026-04-23 13:45:24 +02:00
|
|
|
watch: {
|
|
|
|
|
ignored: devServerWatchIgnored,
|
|
|
|
|
},
|
2026-02-14 18:20:07 +01:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// 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}'],
|
2026-02-17 13:10:43 +01:00
|
|
|
coverage: {
|
|
|
|
|
provider: 'v8',
|
2026-04-21 10:01:51 +02:00
|
|
|
reporter: ['text', 'json', 'html', 'lcov'],
|
2026-04-19 18:03:58 +02:00
|
|
|
// 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,
|
2026-02-17 13:10:43 +01:00
|
|
|
include: ['src/**/*.{ts,tsx}'],
|
|
|
|
|
exclude: [
|
|
|
|
|
'src/**/*.{test,spec}.{ts,tsx}',
|
|
|
|
|
'src/test/**',
|
|
|
|
|
'src/mock-tauri.ts',
|
|
|
|
|
'src/main.tsx',
|
refactor: export pure functions from useNoteActions + update coverage config
Export buildNewEntry, slugify, entryMatchesTarget, buildNoteContent,
resolveNewNote, resolveNewType as public functions for direct unit testing.
Add coverage exclusions for untestable files: shadcn/ui primitives,
AI modules (useAIChat, useMcpBridge, ai-chat, AIChatPanel), and types.ts.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 12:02:07 +01:00
|
|
|
'src/types.ts',
|
|
|
|
|
'src/hooks/useMcpBridge.ts',
|
2026-02-28 22:21:42 +01:00
|
|
|
'src/hooks/useAiAgent.ts',
|
refactor: export pure functions from useNoteActions + update coverage config
Export buildNewEntry, slugify, entryMatchesTarget, buildNoteContent,
resolveNewNote, resolveNewType as public functions for direct unit testing.
Add coverage exclusions for untestable files: shadcn/ui primitives,
AI modules (useAIChat, useMcpBridge, ai-chat, AIChatPanel), and types.ts.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 12:02:07 +01:00
|
|
|
'src/utils/ai-chat.ts',
|
2026-02-28 22:21:42 +01:00
|
|
|
'src/utils/ai-agent.ts',
|
refactor: export pure functions from useNoteActions + update coverage config
Export buildNewEntry, slugify, entryMatchesTarget, buildNoteContent,
resolveNewNote, resolveNewType as public functions for direct unit testing.
Add coverage exclusions for untestable files: shadcn/ui primitives,
AI modules (useAIChat, useMcpBridge, ai-chat, AIChatPanel), and types.ts.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 12:02:07 +01:00
|
|
|
'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',
|
2026-02-17 13:10:43 +01:00
|
|
|
],
|
|
|
|
|
thresholds: {
|
|
|
|
|
lines: 70,
|
|
|
|
|
functions: 70,
|
|
|
|
|
branches: 70,
|
|
|
|
|
statements: 70,
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-02-14 18:20:07 +01:00
|
|
|
},
|
|
|
|
|
})
|