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 {
|
2026-05-29 03:19:59 +02:00
|
|
|
excludeFrontmatter: boolean
|
2026-05-04 10:45:49 +02:00
|
|
|
entry: VaultEntry
|
|
|
|
|
query: string
|
|
|
|
|
rawContent: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface SearchRequestInput {
|
2026-05-29 03:19:59 +02:00
|
|
|
excludeFrontmatter: boolean
|
2026-05-04 10:45:49 +02:00
|
|
|
query: string
|
|
|
|
|
vaultPath: string
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 21:49:35 +02:00
|
|
|
interface VaultCommandPayload {
|
|
|
|
|
args?: Record<string, unknown>
|
|
|
|
|
cmd?: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface VaultCommandContext {
|
|
|
|
|
args: Record<string, unknown>
|
|
|
|
|
cmd: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface VaultCommandResponse {
|
|
|
|
|
payload: unknown
|
|
|
|
|
statusCode?: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type VaultCommandHandler = (context: VaultCommandContext) => VaultCommandResponse
|
|
|
|
|
|
|
|
|
|
interface CommandStringInput {
|
|
|
|
|
args: Record<string, unknown>
|
|
|
|
|
key: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface CommandResponseInput {
|
|
|
|
|
payload: unknown
|
|
|
|
|
statusCode?: number
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 22:40:33 +02:00
|
|
|
interface VaultReadCommandInput {
|
|
|
|
|
cmd: string
|
|
|
|
|
pathname: string
|
|
|
|
|
req: IncomingMessage
|
2026-05-07 21:49:35 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
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-05-07 21:49:35 +02:00
|
|
|
function commandString({ args, key }: CommandStringInput): string | null {
|
|
|
|
|
const value = Reflect.get(args, key)
|
|
|
|
|
return typeof value === 'string' && value.length > 0 ? value : null
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 03:19:59 +02:00
|
|
|
function commandBool({ args, key }: CommandStringInput): boolean {
|
|
|
|
|
const value = Reflect.get(args, key)
|
|
|
|
|
return value === true || value === '1' || value === 'true'
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 21:49:35 +02:00
|
|
|
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<string, string> = {}
|
|
|
|
|
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'
|
2026-05-29 03:19:59 +02:00
|
|
|
const excludeFrontmatter = commandBool({ args, key: 'exclude_frontmatter' })
|
|
|
|
|
const results = vaultPath && query
|
|
|
|
|
? collectVaultSearchResults({ vaultPath, query, excludeFrontmatter })
|
|
|
|
|
: []
|
2026-05-07 21:49:35 +02:00
|
|
|
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<string, VaultCommandHandler>([
|
|
|
|
|
['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 }
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 03:19:59 +02:00
|
|
|
const VAULT_ENDPOINT_ARG_KEYS = ['path', 'vault_path', 'query', 'mode', 'reload', 'exclude_frontmatter'] as const
|
2026-05-07 22:40:33 +02:00
|
|
|
|
|
|
|
|
function readVaultQueryArgs(url: URL): Record<string, unknown> {
|
|
|
|
|
const args: Record<string, unknown> = {}
|
|
|
|
|
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<Record<string, unknown>> {
|
|
|
|
|
if (req.method === 'POST') return readJsonBody<Record<string, unknown>>(req)
|
|
|
|
|
return readVaultQueryArgs(url)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleVaultReadCommand(
|
|
|
|
|
{ cmd, pathname, req, res, url }: VaultReadCommandInput,
|
|
|
|
|
): Promise<boolean> {
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 21:49:35 +02:00
|
|
|
function updateTitleWikilinks({ excludePath, oldTitle, vaultPath }: TitleWikilinkUpdateInput): number {
|
2026-04-10 20:20:08 +02:00
|
|
|
const newPathStem = path.relative(vaultPath, excludePath).replace(/\.md$/i, '')
|
2026-05-07 21:49:35 +02:00
|
|
|
const oldTargets = collectLegacyWikilinkTargets({ oldPath: excludePath, oldTitle, vaultPath })
|
|
|
|
|
return updateWikilinksForTargets({ excludePath, newTarget: newPathStem, oldTargets, vaultPath })
|
2026-04-10 20:20:08 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-07 21:49:35 +02:00
|
|
|
function collectLegacyWikilinkTargets({ oldPath, oldTitle, vaultPath }: LegacyWikilinkTargetInput): string[] {
|
2026-04-10 20:20:08 +02:00
|
|
|
const oldRelativeStem = path.relative(vaultPath, oldPath).replace(/\.md$/i, '')
|
|
|
|
|
const oldFilenameStem = path.basename(oldPath, '.md')
|
|
|
|
|
return [...new Set([oldTitle, oldRelativeStem, oldFilenameStem].filter(Boolean))]
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 21:49:35 +02:00
|
|
|
function updateWikilinksForTargets({ excludePath, newTarget, oldTargets, vaultPath }: WikilinkTargetUpdateInput): number {
|
2026-04-10 20:20:08 +02:00
|
|
|
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-05-07 21:49:35 +02:00
|
|
|
function updatePathWikilinks({ newPath, oldPath, oldTitle, vaultPath }: PathWikilinkUpdateInput): number {
|
2026-04-10 17:59:21 +02:00
|
|
|
const newRelativeStem = path.relative(vaultPath, newPath).replace(/\.md$/i, '')
|
2026-05-07 21:49:35 +02:00
|
|
|
const oldTargets = collectLegacyWikilinkTargets({ oldPath, oldTitle, vaultPath })
|
|
|
|
|
return updateWikilinksForTargets({ excludePath: newPath, newTarget: newRelativeStem, oldTargets, vaultPath })
|
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-05-07 22:40:33 +02:00
|
|
|
async function handleVaultList(url: URL, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
|
|
|
return handleVaultReadCommand({ cmd: 'list_vault', pathname: '/api/vault/list', req, res, url })
|
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-05-07 22:40:33 +02:00
|
|
|
async function handleVaultContent(url: URL, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
|
|
|
return handleVaultReadCommand({ cmd: 'get_note_content', pathname: '/api/vault/content', req, res, url })
|
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-05-07 22:40:33 +02:00
|
|
|
async function handleVaultAllContent(url: URL, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
|
|
|
return handleVaultReadCommand({ cmd: 'get_all_content', pathname: '/api/vault/all-content', req, res, url })
|
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-05-07 22:40:33 +02:00
|
|
|
async function handleVaultEntry(url: URL, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
|
|
|
return handleVaultReadCommand({ cmd: 'reload_vault_entry', pathname: '/api/vault/entry', req, res, url })
|
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-05-07 22:40:33 +02:00
|
|
|
async function handleVaultSearch(url: URL, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
|
|
|
return handleVaultReadCommand({ cmd: 'search_vault', pathname: '/api/vault/search', req, res, url })
|
2026-05-04 10:45:49 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-29 03:19:59 +02:00
|
|
|
function collectVaultSearchResults({ excludeFrontmatter, vaultPath, query }: SearchRequestInput): VaultSearchResult[] {
|
2026-05-04 10:45:49 +02:00
|
|
|
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)
|
2026-05-29 03:19:59 +02:00
|
|
|
if (entryMatchesSearch({ entry, rawContent, query, excludeFrontmatter })) {
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 03:19:59 +02:00
|
|
|
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)
|
2026-05-04 10:45:49 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function searchResultFromEntry(entry: VaultEntry): VaultSearchResult {
|
|
|
|
|
return { title: entry.title, path: entry.path, snippet: entry.snippet, score: 1.0, note_type: entry.isA }
|
|
|
|
|
}
|
|
|
|
|
|
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)
|
|
|
|
|
|
2026-05-07 21:49:35 +02:00
|
|
|
const updatedFiles = vaultPath ? updateTitleWikilinks({ excludePath: newPath, oldTitle, vaultPath }) : 0
|
2026-05-03 17:00:21 +02:00
|
|
|
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)
|
2026-05-07 21:49:35 +02:00
|
|
|
const updatedFiles = vaultPath ? updatePathWikilinks({ newPath, oldPath, oldTitle, vaultPath }) : 0
|
2026-05-03 17:00:21 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 21:49:35 +02:00
|
|
|
async function handleVaultCommand(url: URL, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
|
|
|
if (!isPostRoute(url, req, '/api/vault/command')) return false
|
|
|
|
|
try {
|
|
|
|
|
const context = vaultCommandContext(await readJsonBody<VaultCommandPayload>(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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 17:59:21 +02:00
|
|
|
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)),
|
2026-05-07 21:49:35 +02:00
|
|
|
() => handleVaultCommand(url, req, res),
|
2026-05-07 22:40:33 +02:00
|
|
|
() => handleVaultList(url, req, res),
|
|
|
|
|
() => handleVaultContent(url, req, res),
|
|
|
|
|
() => handleVaultAllContent(url, req, res),
|
|
|
|
|
() => handleVaultEntry(url, req, res),
|
|
|
|
|
() => handleVaultSearch(url, req, res),
|
2026-04-19 18:03:58 +02:00
|
|
|
() => 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-05-11 16:37:06 +02:00
|
|
|
// 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,
|
2026-05-08 11:18:20 +02:00
|
|
|
testTimeout: 10_000,
|
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
|
|
|
},
|
|
|
|
|
})
|