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-05-03 17:00:21 +02:00
|
|
|
import {
|
|
|
|
|
closeSync,
|
|
|
|
|
fstatSync,
|
|
|
|
|
mkdirSync,
|
|
|
|
|
openSync,
|
|
|
|
|
opendirSync,
|
|
|
|
|
readFileSync,
|
|
|
|
|
renameSync,
|
|
|
|
|
unlinkSync,
|
|
|
|
|
writeFileSync,
|
|
|
|
|
type Dirent,
|
|
|
|
|
} 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[] {
|
2026-05-04 10:45:49 +02:00
|
|
|
return collectWikiLinksFromValue(value, 0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function collectWikiLinksFromValue(value: unknown, depth: number): string[] {
|
2026-02-20 10:39:45 +01:00
|
|
|
if (typeof value === 'string') return extractWikiLinks(value)
|
2026-05-04 10:45:49 +02:00
|
|
|
if (!Array.isArray(value)) return []
|
|
|
|
|
|
|
|
|
|
const nestedLink = nestedFlowWikilink(value, depth)
|
|
|
|
|
if (nestedLink) return [nestedLink]
|
|
|
|
|
return value.flatMap((item) => collectWikiLinksFromValue(item, depth + 1))
|
2026-02-20 10:39:45 +01:00
|
|
|
}
|
|
|
|
|
|
2026-05-04 10:45:49 +02:00
|
|
|
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)
|
2026-02-20 10:39:45 +01:00
|
|
|
const DEDICATED_KEYS = new Set([
|
2026-05-04 10:45:49 +02:00
|
|
|
'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 {
|
|
|
|
|
entry: VaultEntry
|
|
|
|
|
query: string
|
|
|
|
|
rawContent: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface SearchRequestInput {
|
|
|
|
|
query: string
|
|
|
|
|
vaultPath: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface SearchResponseInput {
|
|
|
|
|
mode: string
|
|
|
|
|
query: string
|
|
|
|
|
results: VaultSearchResult[]
|
|
|
|
|
}
|
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/**',
|
|
|
|
|
]
|
|
|
|
|
|
2026-05-03 17:00:21 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 13:45:24 +02:00
|
|
|
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-05-04 10:45:49 +02:00
|
|
|
function frontmatterProperties(frontmatter: Record<string, unknown>): Record<string, FrontmatterPropertyValue> {
|
|
|
|
|
const properties: Record<string, FrontmatterPropertyValue> = {}
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 13:45:24 +02:00
|
|
|
function parseMarkdownFile(filePath: string): VaultEntry | null {
|
|
|
|
|
try {
|
2026-05-03 17:00:21 +02:00
|
|
|
const raw = readUtf8File(filePath)
|
|
|
|
|
const stats = pathStats(filePath)
|
2026-04-23 13:45:24 +02:00
|
|
|
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: [],
|
2026-05-04 10:45:49 +02:00
|
|
|
properties: frontmatterProperties(fm),
|
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 {
|
2026-05-03 17:00:21 +02:00
|
|
|
const items = directoryEntries(dir)
|
2026-02-20 10:39:45 +01:00
|
|
|
for (const item of items) {
|
|
|
|
|
if (item.name.startsWith('.')) continue
|
2026-05-03 17:00:21 +02:00
|
|
|
const full = resolveInside(dir, item.name)
|
|
|
|
|
if (!full) continue
|
2026-02-20 10:39:45 +01:00
|
|
|
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)
|
2026-05-03 17:00:21 +02:00
|
|
|
if (!filePath || !pathExists(filePath)) {
|
2026-04-10 17:59:21 +02:00
|
|
|
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-05-03 17:00:21 +02:00
|
|
|
const targets = new Set(oldTargets)
|
2026-04-10 17:59:21 +02:00
|
|
|
let updatedFiles = 0
|
|
|
|
|
for (const filePath of allFiles) {
|
|
|
|
|
if (filePath === excludePath) continue
|
|
|
|
|
try {
|
2026-05-03 17:00:21 +02:00
|
|
|
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}]]`
|
|
|
|
|
})
|
2026-04-10 17:59:21 +02:00
|
|
|
if (replaced !== content) {
|
2026-05-03 17:00:21 +02:00
|
|
|
writeUtf8File(filePath, replaced)
|
2026-04-10 17:59:21 +02:00
|
|
|
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
|
2026-05-03 17:00:21 +02:00
|
|
|
sendJson(res, { content: readUtf8File(filePath) })
|
2026-04-10 17:59:21 +02:00
|
|
|
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 {
|
2026-05-03 17:00:21 +02:00
|
|
|
contentMap[filePath] = readUtf8File(filePath)
|
2026-04-10 17:59:21 +02:00
|
|
|
} 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) {
|
2026-05-04 10:45:49 +02:00
|
|
|
sendVaultSearchResponse(res, { results: [], query, mode })
|
2026-04-10 17:59:21 +02:00
|
|
|
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-05-04 10:45:49 +02:00
|
|
|
sendVaultSearchResponse(res, {
|
|
|
|
|
results: collectVaultSearchResults({ vaultPath, query }),
|
|
|
|
|
query,
|
|
|
|
|
mode,
|
|
|
|
|
})
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function collectVaultSearchResults({ vaultPath, query }: SearchRequestInput): VaultSearchResult[] {
|
|
|
|
|
const results: VaultSearchResult[] = []
|
2026-04-10 17:59:21 +02:00
|
|
|
for (const filePath of findMarkdownFiles(vaultPath)) {
|
|
|
|
|
const entry = parseMarkdownFile(filePath)
|
|
|
|
|
if (!entry || entry.trashed) continue
|
2026-05-04 10:45:49 +02:00
|
|
|
const rawContent = readUtf8File(filePath)
|
|
|
|
|
if (entryMatchesSearch({ entry, rawContent, query })) results.push(searchResultFromEntry(entry))
|
2026-04-10 17:59:21 +02:00
|
|
|
}
|
2026-05-04 10:45:49 +02:00
|
|
|
return results.slice(0, 20)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function entryMatchesSearch({ entry, rawContent, query }: SearchEntryInput): boolean {
|
|
|
|
|
return entry.title.toLowerCase().includes(query) || rawContent.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 }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sendVaultSearchResponse(res: ServerResponse, { results, query, mode }: SearchResponseInput): void {
|
|
|
|
|
sendJson(res, { results, elapsed_ms: results.length > 0 ? 1 : 0, query, mode })
|
2026-04-10 17:59:21 +02:00
|
|
|
}
|
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> {
|
2026-05-03 17:00:21 +02:00
|
|
|
if (!isPostRoute(url, req, '/api/vault/save')) return false
|
2026-04-10 17:59:21 +02:00
|
|
|
try {
|
2026-05-03 17:00:21 +02:00
|
|
|
await saveVaultContent(req, res)
|
2026-04-10 17:59:21 +02:00
|
|
|
} catch (err: unknown) {
|
2026-05-03 17:00:21 +02:00
|
|
|
sendCaughtError(res, err, 'Save failed')
|
2026-04-10 17:59:21 +02:00
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 17:00:21 +02:00
|
|
|
async function saveVaultContent(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 17:59:21 +02:00
|
|
|
async function handleVaultRename(url: URL, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
2026-05-03 17:00:21 +02:00
|
|
|
if (!isPostRoute(url, req, '/api/vault/rename')) return false
|
2026-04-10 17:59:21 +02:00
|
|
|
try {
|
2026-05-03 17:00:21 +02:00
|
|
|
await renameVaultNoteTitle(req, res)
|
2026-04-10 17:59:21 +02:00
|
|
|
} catch (err: unknown) {
|
2026-05-03 17:00:21 +02:00
|
|
|
sendCaughtError(res, err, 'Rename failed')
|
2026-04-10 17:59:21 +02:00
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 17:00:21 +02:00
|
|
|
async function renameVaultNoteTitle(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
|
|
|
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(vaultPath, oldTitle, newTitle, newPath) : 0
|
|
|
|
|
sendJson(res, { new_path: newPath, updated_files: updatedFiles })
|
|
|
|
|
}
|
|
|
|
|
|
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-05-03 17:00:21 +02:00
|
|
|
function markdownSiblingPath(filePath: string, stem: string): string | null {
|
|
|
|
|
if (isUnsafeMarkdownFilenameStem(stem)) return null
|
|
|
|
|
return resolveInside(path.dirname(filePath), `${stem}.md`)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 17:59:21 +02:00
|
|
|
async function handleVaultRenameFilename(url: URL, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
2026-05-03 17:00:21 +02:00
|
|
|
if (!isPostRoute(url, req, '/api/vault/rename-filename')) return false
|
2026-04-10 17:59:21 +02:00
|
|
|
try {
|
2026-05-03 17:00:21 +02:00
|
|
|
await renameVaultNoteFilename(req, res)
|
2026-04-10 17:59:21 +02:00
|
|
|
} catch (err: unknown) {
|
2026-05-03 17:00:21 +02:00
|
|
|
sendCaughtError(res, err, 'Rename failed')
|
2026-04-10 17:59:21 +02:00
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 17:00:21 +02:00
|
|
|
async function renameVaultNoteFilename(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
|
|
|
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(vaultPath, oldPath, newPath, oldTitle) : 0
|
|
|
|
|
sendJson(res, { new_path: newPath, updated_files: updatedFiles })
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 17:59:21 +02:00
|
|
|
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
|
|
|
|
|
}
|
2026-05-03 17:00:21 +02:00
|
|
|
unlinkSync(filePath)
|
2026-04-10 17:59:21 +02:00
|
|
|
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-05-03 17:00:21 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function readJsonBody<T>(req: IncomingMessage): Promise<T> {
|
|
|
|
|
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)
|
2026-02-20 22:52:56 +01:00
|
|
|
}
|
|
|
|
|
|
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
|
|
|
},
|
|
|
|
|
})
|