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>
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"playwright:smoke": "playwright test tests/smoke/",
|
||||
"playwright:integration": "playwright test --config playwright.integration.config.ts",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"prepare": "husky"
|
||||
},
|
||||
|
||||
18
playwright.integration.config.ts
Normal file
18
playwright.integration.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/integration',
|
||||
timeout: 30_000,
|
||||
retries: 1,
|
||||
workers: 1,
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL || 'http://localhost:5365',
|
||||
headless: true,
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
|
||||
webServer: {
|
||||
command: `pnpm dev --port ${process.env.BASE_URL?.match(/:(\d+)/)?.[1] || '5365'}`,
|
||||
url: process.env.BASE_URL || 'http://localhost:5365',
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Vault API detection and proxy for browser dev mode.
|
||||
* When a local vault API server is running, routes read commands through it
|
||||
* instead of returning hardcoded mock data.
|
||||
* When a local vault API server is running, routes read and write commands
|
||||
* through it instead of returning hardcoded mock data.
|
||||
*/
|
||||
|
||||
let vaultApiAvailable: boolean | null = null
|
||||
@@ -18,25 +18,60 @@ async function checkVaultApi(): Promise<boolean> {
|
||||
return vaultApiAvailable
|
||||
}
|
||||
|
||||
const VAULT_API_COMMANDS: Record<string, (args: Record<string, unknown>) => string | null> = {
|
||||
list_vault: (args) => args.path ? `/api/vault/list?path=${encodeURIComponent(args.path as string)}` : null,
|
||||
reload_vault: (args) => args.path ? `/api/vault/list?path=${encodeURIComponent(args.path as string)}&reload=1` : null,
|
||||
get_note_content: (args) => args.path ? `/api/vault/content?path=${encodeURIComponent(args.path as string)}` : null,
|
||||
get_all_content: (args) => args.path ? `/api/vault/all-content?path=${encodeURIComponent(args.path as string)}` : null,
|
||||
interface VaultApiRequest {
|
||||
url: string
|
||||
method?: string
|
||||
body?: unknown
|
||||
}
|
||||
|
||||
/** Tracks last vault path for commands that don't receive it as an argument. */
|
||||
let lastVaultPath: string | null = null
|
||||
|
||||
const VAULT_API_COMMANDS: Record<string, (args: Record<string, unknown>) => VaultApiRequest | null> = {
|
||||
list_vault: (args) => {
|
||||
if (args.path) lastVaultPath = args.path as string
|
||||
return args.path ? { url: `/api/vault/list?path=${encodeURIComponent(args.path as string)}` } : null
|
||||
},
|
||||
reload_vault: (args) => {
|
||||
if (args.path) lastVaultPath = args.path as string
|
||||
return args.path ? { url: `/api/vault/list?path=${encodeURIComponent(args.path as string)}&reload=1` } : null
|
||||
},
|
||||
reload_vault_entry: (args) =>
|
||||
args.path ? { url: `/api/vault/entry?path=${encodeURIComponent(args.path as string)}` } : null,
|
||||
get_note_content: (args) =>
|
||||
args.path ? { url: `/api/vault/content?path=${encodeURIComponent(args.path as string)}` } : null,
|
||||
get_all_content: (args) =>
|
||||
args.path ? { url: `/api/vault/all-content?path=${encodeURIComponent(args.path as string)}` } : null,
|
||||
save_note_content: (args) =>
|
||||
args.path ? { url: '/api/vault/save', method: 'POST', body: { path: args.path, content: args.content } } : null,
|
||||
rename_note: (args) =>
|
||||
args.old_path ? { url: '/api/vault/rename', method: 'POST', body: { vault_path: args.vault_path, old_path: args.old_path, new_title: args.new_title } } : null,
|
||||
delete_note: (args) =>
|
||||
args.path ? { url: '/api/vault/delete', method: 'POST', body: { path: args.path } } : null,
|
||||
search_vault: (args) => {
|
||||
const q = args.query as string
|
||||
if (!q || !lastVaultPath) return null
|
||||
return { url: `/api/vault/search?vault_path=${encodeURIComponent(lastVaultPath)}&query=${encodeURIComponent(q)}&mode=${encodeURIComponent((args.mode as string) || 'all')}` }
|
||||
},
|
||||
}
|
||||
|
||||
export async function tryVaultApi<T>(cmd: string, args?: Record<string, unknown>): Promise<T | undefined> {
|
||||
const available = await checkVaultApi()
|
||||
if (!available) return undefined
|
||||
|
||||
const urlBuilder = VAULT_API_COMMANDS[cmd]
|
||||
if (!urlBuilder || !args) return undefined
|
||||
const requestBuilder = VAULT_API_COMMANDS[cmd]
|
||||
if (!requestBuilder || !args) return undefined
|
||||
|
||||
const url = urlBuilder(args)
|
||||
if (!url) return undefined
|
||||
const request = requestBuilder(args)
|
||||
if (!request) return undefined
|
||||
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
const fetchOpts: RequestInit = { method: request.method || 'GET' }
|
||||
if (request.body) {
|
||||
fetchOpts.headers = { 'Content-Type': 'application/json' }
|
||||
fetchOpts.body = JSON.stringify(request.body)
|
||||
}
|
||||
const res = await fetch(request.url, fetchOpts)
|
||||
if (!res.ok) return undefined
|
||||
const data = await res.json()
|
||||
return (cmd === 'get_note_content' ? data.content : data) as T
|
||||
|
||||
12
tests/fixtures/test-vault/event/team-meeting.md
vendored
Normal file
12
tests/fixtures/test-vault/event/team-meeting.md
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
Is A: Event
|
||||
Status: Active
|
||||
Belongs to:
|
||||
- "[[Alpha Project]]"
|
||||
Attendees:
|
||||
- "[[Test User]]"
|
||||
---
|
||||
|
||||
# Team Meeting
|
||||
|
||||
Weekly team meeting for the Alpha Project.
|
||||
8
tests/fixtures/test-vault/note/archived-note.md
vendored
Normal file
8
tests/fixtures/test-vault/note/archived-note.md
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
Is A: Note
|
||||
Archived: Yes
|
||||
---
|
||||
|
||||
# Archived Note
|
||||
|
||||
This note is archived and should not appear in the main sidebar.
|
||||
10
tests/fixtures/test-vault/note/note-b.md
vendored
Normal file
10
tests/fixtures/test-vault/note/note-b.md
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
Is A: Note
|
||||
Status: Active
|
||||
---
|
||||
|
||||
# Note B
|
||||
|
||||
This is Note B, referenced by Alpha Project.
|
||||
|
||||
It also links to [[Note C]].
|
||||
12
tests/fixtures/test-vault/note/note-c.md
vendored
Normal file
12
tests/fixtures/test-vault/note/note-c.md
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
Is A: Note
|
||||
Status: Active
|
||||
Related to:
|
||||
- "[[Alpha Project]]"
|
||||
Topics:
|
||||
- "[[design]]"
|
||||
---
|
||||
|
||||
# Note C
|
||||
|
||||
This is Note C with relationships to Alpha Project.
|
||||
9
tests/fixtures/test-vault/note/trashed-note.md
vendored
Normal file
9
tests/fixtures/test-vault/note/trashed-note.md
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
Is A: Note
|
||||
Trashed: true
|
||||
Trashed at: 2026-03-01
|
||||
---
|
||||
|
||||
# Trashed Note
|
||||
|
||||
This note is trashed and should not appear in search results or the main sidebar.
|
||||
16
tests/fixtures/test-vault/project/alpha-project.md
vendored
Normal file
16
tests/fixtures/test-vault/project/alpha-project.md
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
Is A: Project
|
||||
Status: Active
|
||||
Owner: "Test User"
|
||||
Related to:
|
||||
- "[[Note B]]"
|
||||
- "[[Note C]]"
|
||||
---
|
||||
|
||||
# Alpha Project
|
||||
|
||||
This is a test project that references other notes.
|
||||
|
||||
## Notes
|
||||
|
||||
See [[Note B]] for details and [[Note C]] for additional context.
|
||||
9
tests/fixtures/test-vault/type/event.md
vendored
Normal file
9
tests/fixtures/test-vault/type/event.md
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
Is A: Type
|
||||
icon: calendar
|
||||
color: orange
|
||||
order: 2
|
||||
sidebarLabel: Events
|
||||
---
|
||||
|
||||
# Event
|
||||
9
tests/fixtures/test-vault/type/note.md
vendored
Normal file
9
tests/fixtures/test-vault/type/note.md
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
Is A: Type
|
||||
icon: file-text
|
||||
color: green
|
||||
order: 1
|
||||
sidebarLabel: Notes
|
||||
---
|
||||
|
||||
# Note
|
||||
9
tests/fixtures/test-vault/type/project.md
vendored
Normal file
9
tests/fixtures/test-vault/type/project.md
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
Is A: Type
|
||||
icon: folder
|
||||
color: blue
|
||||
order: 0
|
||||
sidebarLabel: Projects
|
||||
---
|
||||
|
||||
# Project
|
||||
230
tests/integration/vault-workflows.spec.ts
Normal file
230
tests/integration/vault-workflows.spec.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Integration tests against a real filesystem vault.
|
||||
*
|
||||
* Each test copies tests/fixtures/test-vault/ to a temp directory,
|
||||
* points the app at it, and verifies real file I/O: creation, rename,
|
||||
* wikilink updates, archive/trash filtering, and relationship display.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
|
||||
const FIXTURE_VAULT = path.resolve('tests/fixtures/test-vault')
|
||||
|
||||
function copyDirSync(src: string, dest: string): void {
|
||||
fs.mkdirSync(dest, { recursive: true })
|
||||
for (const item of fs.readdirSync(src, { withFileTypes: true })) {
|
||||
const s = path.join(src, item.name)
|
||||
const d = path.join(dest, item.name)
|
||||
if (item.isDirectory()) copyDirSync(s, d)
|
||||
else fs.copyFileSync(s, d)
|
||||
}
|
||||
}
|
||||
|
||||
let tempVaultDir: string
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Fresh vault copy for each test
|
||||
tempVaultDir = fs.mkdtempSync(path.join(os.tmpdir(), 'laputa-test-vault-'))
|
||||
copyDirSync(FIXTURE_VAULT, tempVaultDir)
|
||||
|
||||
// Intercept window.__mockHandlers assignment to override vault path handlers.
|
||||
// The Object.defineProperty setter fires when mock-tauri/index.ts sets
|
||||
// window.__mockHandlers = mockHandlers, letting us patch the same object
|
||||
// reference that mockInvoke reads from.
|
||||
await page.addInitScript((vaultPath: string) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let ref: any = null
|
||||
Object.defineProperty(window, '__mockHandlers', {
|
||||
set(val) {
|
||||
ref = val
|
||||
ref.load_vault_list = () => ({
|
||||
vaults: [{ label: 'Test Vault', path: vaultPath }],
|
||||
active_vault: vaultPath,
|
||||
})
|
||||
ref.check_vault_exists = () => true
|
||||
ref.get_last_vault_path = () => vaultPath
|
||||
ref.get_default_vault_path = () => vaultPath
|
||||
ref.save_vault_list = () => null
|
||||
},
|
||||
get() { return ref },
|
||||
configurable: true,
|
||||
})
|
||||
}, tempVaultDir)
|
||||
|
||||
await page.goto('/')
|
||||
// Wait until the real vault entries are loaded in the sidebar
|
||||
await page.getByText('Alpha Project', { exact: true }).first().waitFor({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
fs.rmSync(tempVaultDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** Helper: locate the note-list container. */
|
||||
function noteList(page: import('@playwright/test').Page) {
|
||||
return page.locator('[data-testid="note-list-container"]')
|
||||
}
|
||||
|
||||
/** Helper: click a note item by title text in the sidebar. */
|
||||
async function openNote(page: import('@playwright/test').Page, title: string) {
|
||||
await noteList(page).getByText(title, { exact: true }).click()
|
||||
await page.waitForTimeout(300)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Vault loads entries from real fixture files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('vault loads entries from fixture files', async ({ page }) => {
|
||||
const list = noteList(page)
|
||||
await expect(list.getByText('Alpha Project', { exact: true })).toBeVisible()
|
||||
await expect(list.getByText('Note B', { exact: true })).toBeVisible()
|
||||
await expect(list.getByText('Note C', { exact: true })).toBeVisible()
|
||||
await expect(list.getByText('Team Meeting', { exact: true })).toBeVisible()
|
||||
|
||||
// Open a note and verify editor shows its content from disk
|
||||
await openNote(page, 'Alpha Project')
|
||||
// The WYSIWYG editor renders the title as an H1 heading
|
||||
await expect(page.getByRole('heading', { name: 'Alpha Project', level: 1 })).toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Archived note hidden from main sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('archived note does not appear in All Notes', async ({ page }) => {
|
||||
const list = noteList(page)
|
||||
// "Archived Note" has Archived: Yes — should be hidden from default view
|
||||
await expect(list.getByText('Archived Note', { exact: true })).not.toBeVisible()
|
||||
|
||||
// But regular notes are visible
|
||||
await expect(list.getByText('Note B', { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Trashed note hidden from note list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('trashed note does not appear in All Notes', async ({ page }) => {
|
||||
const list = noteList(page)
|
||||
// "Trashed Note" has Trashed: true — should be hidden
|
||||
await expect(list.getByText('Trashed Note', { exact: true })).not.toBeVisible()
|
||||
|
||||
// Regular notes are visible
|
||||
await expect(list.getByText('Note C', { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Create note saves file to disk with correct slug
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('create note saves file to disk with correct slug', async ({ page }) => {
|
||||
// "Create new note" instantly creates "Untitled note" and opens in editor
|
||||
await page.locator('button[title="Create new note"]').click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Verify the new note opens — H1 heading should appear in the WYSIWYG editor
|
||||
await expect(page.getByRole('heading', { name: /Untitled note/i, level: 1 })).toBeVisible({ timeout: 5_000 })
|
||||
|
||||
// Save to disk via Cmd+S
|
||||
await page.keyboard.press('Meta+s')
|
||||
|
||||
// Poll for the file to appear on disk
|
||||
const expectedPath = path.join(tempVaultDir, 'note', 'untitled-note.md')
|
||||
await expect(async () => {
|
||||
expect(fs.existsSync(expectedPath)).toBe(true)
|
||||
}).toPass({ timeout: 5_000 })
|
||||
|
||||
const content = fs.readFileSync(expectedPath, 'utf-8')
|
||||
expect(content).toContain('# Untitled note')
|
||||
expect(content).toContain('type: Note')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Rename note updates filename on disk
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('rename note updates filename on disk', async ({ page }) => {
|
||||
// Open Note B
|
||||
await openNote(page, 'Note B')
|
||||
|
||||
// Double-click the tab title — scope to the draggable tab element to avoid
|
||||
// matching the breadcrumb span that also has class "truncate".
|
||||
await page.locator('[draggable="true"] span.truncate', { hasText: 'Note B' }).dblclick()
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
// In rename mode, draggable becomes false and an <input> appears in the tab.
|
||||
// It's the only <input> element on the page at this point.
|
||||
const input = page.locator('input').first()
|
||||
await expect(input).toBeVisible({ timeout: 2_000 })
|
||||
await input.fill('Note B Renamed')
|
||||
await input.press('Enter')
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Verify filesystem: old file gone, new file exists
|
||||
const oldPath = path.join(tempVaultDir, 'note', 'note-b.md')
|
||||
const newPath = path.join(tempVaultDir, 'note', 'note-b-renamed.md')
|
||||
|
||||
await expect(async () => {
|
||||
expect(fs.existsSync(oldPath)).toBe(false)
|
||||
expect(fs.existsSync(newPath)).toBe(true)
|
||||
}).toPass({ timeout: 5_000 })
|
||||
|
||||
const newContent = fs.readFileSync(newPath, 'utf-8')
|
||||
expect(newContent).toContain('# Note B Renamed')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Wikilink update on rename — other files' [[Note B]] updated
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('rename note updates wikilinks in other files', async ({ page }) => {
|
||||
// Open Note B and rename it
|
||||
await openNote(page, 'Note B')
|
||||
|
||||
await page.locator('[draggable="true"] span.truncate', { hasText: 'Note B' }).dblclick()
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
const input = page.locator('input').first()
|
||||
await expect(input).toBeVisible({ timeout: 2_000 })
|
||||
await input.fill('Note B Updated')
|
||||
await input.press('Enter')
|
||||
|
||||
// Wait for rename to complete (file to be moved)
|
||||
const newPath = path.join(tempVaultDir, 'note', 'note-b-updated.md')
|
||||
await expect(async () => {
|
||||
expect(fs.existsSync(newPath)).toBe(true)
|
||||
}).toPass({ timeout: 5_000 })
|
||||
|
||||
// Verify alpha-project.md now references [[Note B Updated]] instead of [[Note B]]
|
||||
const alphaContent = fs.readFileSync(path.join(tempVaultDir, 'project', 'alpha-project.md'), 'utf-8')
|
||||
expect(alphaContent).toContain('[[Note B Updated]]')
|
||||
expect(alphaContent).not.toContain('[[Note B]]')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Relationship display in inspector
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('inspector shows relationships for note with wikilink fields', async ({ page }) => {
|
||||
// Open Note C which has Related to: [[Alpha Project]] and Topics: [[design]]
|
||||
await openNote(page, 'Note C')
|
||||
|
||||
// The DynamicRelationshipsPanel renders field labels like "Related to", "Topics"
|
||||
// as <span> elements. Verify that the relationship labels are visible in the inspector.
|
||||
await expect(page.getByText('Related to').first()).toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 8. Opening a note loads real file content from disk
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('editor shows real file content from disk', async ({ page }) => {
|
||||
// Open Team Meeting
|
||||
await openNote(page, 'Team Meeting')
|
||||
|
||||
// Verify editor shows the actual file content — H1 heading from the file
|
||||
await expect(page.getByRole('heading', { name: 'Team Meeting', level: 1 })).toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
131
vite.config.ts
131
vite.config.ts
@@ -129,12 +129,19 @@ function parseMarkdownFile(filePath: string): VaultEntry | null {
|
||||
}
|
||||
}
|
||||
|
||||
// Boolean field helper
|
||||
// Boolean field helper — handles both real booleans and YAML 1.1 string
|
||||
// variants ("Yes"/"yes"/"True"/"true") that js-yaml 4.x (YAML 1.2) leaves as strings.
|
||||
const getBool = (...keys: string[]): boolean | null => {
|
||||
for (const k of keys) {
|
||||
for (const fk of Object.keys(fm)) {
|
||||
if (fk.toLowerCase() === k.toLowerCase() && typeof fm[fk] === 'boolean') {
|
||||
return fm[fk]
|
||||
if (fk.toLowerCase() === k.toLowerCase()) {
|
||||
const v = fm[fk]
|
||||
if (typeof v === 'boolean') return v
|
||||
if (typeof v === 'string') {
|
||||
const lc = v.toLowerCase()
|
||||
if (lc === 'true' || lc === 'yes') return true
|
||||
if (lc === 'false' || lc === 'no') return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,7 +208,7 @@ function vaultApiPlugin(): Plugin {
|
||||
return {
|
||||
name: 'vault-api',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
server.middlewares.use(async (req, res, next) => {
|
||||
const url = new URL(req.url ?? '/', `http://${req.headers.host}`)
|
||||
|
||||
if (url.pathname === '/api/vault/ping') {
|
||||
@@ -258,6 +265,122 @@ function vaultApiPlugin(): Plugin {
|
||||
return
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/vault/entry') {
|
||||
const filePath = url.searchParams.get('path')
|
||||
if (!filePath || !fs.existsSync(filePath)) {
|
||||
res.statusCode = 400
|
||||
res.end(JSON.stringify({ error: 'Invalid or missing path' }))
|
||||
return
|
||||
}
|
||||
const entry = parseMarkdownFile(filePath)
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.end(JSON.stringify(entry))
|
||||
return
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/vault/search') {
|
||||
const vaultPath = url.searchParams.get('vault_path')
|
||||
const query = (url.searchParams.get('query') ?? '').toLowerCase()
|
||||
const mode = url.searchParams.get('mode') ?? 'all'
|
||||
if (!vaultPath || !query) {
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.end(JSON.stringify({ results: [], elapsed_ms: 0, query, mode }))
|
||||
return
|
||||
}
|
||||
const files = findMarkdownFiles(vaultPath)
|
||||
const results: { title: string; path: string; snippet: string; score: number; note_type: string | null }[] = []
|
||||
for (const f of files) {
|
||||
const entry = parseMarkdownFile(f)
|
||||
if (!entry || entry.trashed) continue
|
||||
const raw = fs.readFileSync(f, 'utf-8')
|
||||
if (entry.title.toLowerCase().includes(query) || raw.toLowerCase().includes(query)) {
|
||||
results.push({ title: entry.title, path: entry.path, snippet: entry.snippet, score: 1.0, note_type: entry.isA })
|
||||
}
|
||||
}
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.end(JSON.stringify({ results: results.slice(0, 20), elapsed_ms: 1, query, mode }))
|
||||
return
|
||||
}
|
||||
|
||||
// --- POST endpoints for write operations ---
|
||||
|
||||
if (url.pathname === '/api/vault/save' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await readRequestBody(req)
|
||||
const { path: filePath, content } = JSON.parse(body)
|
||||
if (!filePath || content === undefined) {
|
||||
res.statusCode = 400
|
||||
res.end(JSON.stringify({ error: 'Missing path or content' }))
|
||||
return
|
||||
}
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
||||
fs.writeFileSync(filePath, content, 'utf-8')
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.end('null')
|
||||
} catch (err: unknown) {
|
||||
res.statusCode = 500
|
||||
res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'Save failed' }))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/vault/rename' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await readRequestBody(req)
|
||||
const { vault_path: vaultPath, old_path: oldPath, new_title: newTitle } = JSON.parse(body)
|
||||
const oldContent = fs.readFileSync(oldPath, 'utf-8')
|
||||
const h1Match = oldContent.match(/^# (.+)$/m)
|
||||
const oldTitle = h1Match ? h1Match[1].trim() : ''
|
||||
const slug = newTitle.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
|
||||
const parentDir = path.dirname(oldPath)
|
||||
const newPath = path.join(parentDir, `${slug}.md`)
|
||||
const newContent = oldContent.replace(/^# .+$/m, `# ${newTitle}`)
|
||||
fs.writeFileSync(newPath, newContent, 'utf-8')
|
||||
if (newPath !== oldPath) fs.unlinkSync(oldPath)
|
||||
let updatedFiles = 0
|
||||
if (oldTitle && vaultPath) {
|
||||
const allFiles = findMarkdownFiles(vaultPath)
|
||||
const escaped = oldTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const pattern = new RegExp(`\\[\\[${escaped}(\\|[^\\]]*?)?\\]\\]`, 'g')
|
||||
for (const f of allFiles) {
|
||||
if (f === newPath) continue
|
||||
try {
|
||||
const c = fs.readFileSync(f, 'utf-8')
|
||||
const replaced = c.replace(pattern, (_m: string, pipe: string | undefined) =>
|
||||
pipe ? `[[${newTitle}${pipe}]]` : `[[${newTitle}]]`
|
||||
)
|
||||
if (replaced !== c) { fs.writeFileSync(f, replaced, 'utf-8'); updatedFiles++ }
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.end(JSON.stringify({ new_path: newPath, updated_files: updatedFiles }))
|
||||
} catch (err: unknown) {
|
||||
res.statusCode = 500
|
||||
res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'Rename failed' }))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/vault/delete' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await readRequestBody(req)
|
||||
const { path: filePath } = JSON.parse(body)
|
||||
if (!filePath) {
|
||||
res.statusCode = 400
|
||||
res.end(JSON.stringify({ error: 'Missing path' }))
|
||||
return
|
||||
}
|
||||
fs.unlinkSync(filePath)
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.end(JSON.stringify(filePath))
|
||||
} catch (err: unknown) {
|
||||
res.statusCode = 500
|
||||
res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'Delete failed' }))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user