feat: implement WYSIWYG markdown editor with Go backend and React frontend
- Backend: Go HTTP server with Cobra CLI (--data-dir, --port, --host flags) - CRUD REST API for markdown files with JSON error responses - File storage in flat directory structure (flat structure, .md files only) - Comprehensive logrus logging for all operations - Static file serving for frontend build (./frontend/dist) - Frontend: React + TypeScript + Tailwind CSS - Markdown editor with live GFM preview - File management: list, create, open, save, delete - Theme system (Dark, Light, System) with persistence - Responsive design for desktop and mobile - Backend tests (storage, API handlers) and frontend tests
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
frontend/node_modules
|
||||||
|
eval
|
||||||
26
Makefile
Normal file
26
Makefile
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
.PHONY: all build test frontend backend frontend-build backend-test
|
||||||
|
|
||||||
|
all: build
|
||||||
|
|
||||||
|
build: backend frontend-build
|
||||||
|
@echo "Build complete"
|
||||||
|
|
||||||
|
test: backend-test frontend-test
|
||||||
|
|
||||||
|
backend:
|
||||||
|
go build -o eval ./cmd
|
||||||
|
|
||||||
|
backend-test:
|
||||||
|
go test ./internal/storage ./internal/api -v
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
npm --prefix frontend install
|
||||||
|
|
||||||
|
frontend-build:
|
||||||
|
npm --prefix frontend run build
|
||||||
|
|
||||||
|
frontend-test:
|
||||||
|
npm --prefix frontend run test
|
||||||
|
|
||||||
|
frontend-dev:
|
||||||
|
npm --prefix frontend run dev
|
||||||
100
cmd/root.go
Normal file
100
cmd/root.go
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"eval/internal/api"
|
||||||
|
"eval/internal/config"
|
||||||
|
"eval/internal/storage"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
cfg = config.NewConfig()
|
||||||
|
)
|
||||||
|
|
||||||
|
var rootCmd = &cobra.Command{
|
||||||
|
Use: "eval",
|
||||||
|
Short: "WYSIWYG Markdown Editor Server",
|
||||||
|
Long: `Eval is a markdown editor with preview featuring Go backend and React frontend.`,
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
runServer(cmd)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
cfg.AddFlags(rootCmd)
|
||||||
|
rootCmd.PersistentFlags().String("log-level", "info", "Log level (debug, info, warn, error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func runServer(cmd *cobra.Command) {
|
||||||
|
// Initialize logger
|
||||||
|
logger := logrus.New()
|
||||||
|
logger.SetFormatter(&logrus.TextFormatter{
|
||||||
|
FullTimestamp: true,
|
||||||
|
})
|
||||||
|
logLevel, _ := cmd.Flags().GetString("log-level")
|
||||||
|
if logLevel != "" {
|
||||||
|
level, err := logrus.ParseLevel(logLevel)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warnf("invalid log level: %v, using info", err)
|
||||||
|
level = logrus.InfoLevel
|
||||||
|
}
|
||||||
|
logger.SetLevel(level)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate config
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
logger.Fatalf("invalid configuration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure data directory exists
|
||||||
|
if err := cfg.EnsureDataDir(); err != nil {
|
||||||
|
logger.Fatalf("failed to create data directory: %v", err)
|
||||||
|
}
|
||||||
|
logger.Infof("using data directory: %s", cfg.DataDirPath())
|
||||||
|
|
||||||
|
// Initialize storage
|
||||||
|
store := storage.NewStorage(cfg.DataDirPath())
|
||||||
|
|
||||||
|
// Initialize API handlers
|
||||||
|
handlers := api.NewHandlers(store, cfg, logger)
|
||||||
|
|
||||||
|
// Determine build directory (check both build and dist)
|
||||||
|
buildDir := "./frontend/build"
|
||||||
|
distDir := "./frontend/dist"
|
||||||
|
|
||||||
|
if _, err := os.Stat(buildDir); os.IsNotExist(err) {
|
||||||
|
if _, err := os.Stat(distDir); err == nil {
|
||||||
|
buildDir = distDir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if rel, err := filepath.Rel(cfg.DataDirPath(), buildDir); err == nil {
|
||||||
|
buildDir = rel
|
||||||
|
}
|
||||||
|
logger.Infof("serving static files from: %s", buildDir)
|
||||||
|
|
||||||
|
// Setup routes
|
||||||
|
router, err := handlers.SetupRoutes(buildDir)
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatalf("failed to setup routes: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
if err := handlers.StartServer(router); err != nil {
|
||||||
|
logger.Fatalf("server error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute starts the root command
|
||||||
|
// This is called from main.go in the main package
|
||||||
|
func Execute() {
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
1
frontend/dist/assets/index-IEmHo4ub.css
vendored
Normal file
1
frontend/dist/assets/index-IEmHo4ub.css
vendored
Normal file
File diff suppressed because one or more lines are too long
79
frontend/dist/assets/index-PGya2Dnr.js
vendored
Normal file
79
frontend/dist/assets/index-PGya2Dnr.js
vendored
Normal file
File diff suppressed because one or more lines are too long
13
frontend/dist/index.html
vendored
Normal file
13
frontend/dist/index.html
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Eval Markdown Editor</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-PGya2Dnr.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-IEmHo4ub.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Eval Markdown Editor</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4504
frontend/package-lock.json
generated
Normal file
4504
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
frontend/package.json
Normal file
29
frontend/package.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "eval-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-markdown": "^9.0.0",
|
||||||
|
"remark-gfm": "^4.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.47",
|
||||||
|
"tailwindcss": "^3.4.11",
|
||||||
|
"typescript": "^5.6.0",
|
||||||
|
"vite": "^5.4.0",
|
||||||
|
"vitest": "^2.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
7
frontend/src/App.test.tsx
Normal file
7
frontend/src/App.test.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
|
||||||
|
describe('App', () => {
|
||||||
|
it('should render', () => {
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
274
frontend/src/App.tsx
Normal file
274
frontend/src/App.tsx
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
|
|
||||||
|
interface FileMetadata {
|
||||||
|
filename: string
|
||||||
|
title: string
|
||||||
|
modified: string
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileContent {
|
||||||
|
filename: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
modified: string
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [files, setFiles] = useState<FileMetadata[]>([])
|
||||||
|
const [currentFile, setCurrentFile] = useState<FileContent | null>(null)
|
||||||
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
const [theme, setTheme] = useState<'light' | 'dark' | 'system'>('system')
|
||||||
|
const [content, setContent] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadFiles()
|
||||||
|
applyTheme(theme)
|
||||||
|
}, [theme])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentFile) {
|
||||||
|
setContent(currentFile.content)
|
||||||
|
}
|
||||||
|
}, [currentFile])
|
||||||
|
|
||||||
|
const loadFiles = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/files')
|
||||||
|
const data = await response.json()
|
||||||
|
setFiles(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load files:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyTheme = (theme: 'light' | 'dark' | 'system') => {
|
||||||
|
const root = document.documentElement
|
||||||
|
if (theme === 'system') {
|
||||||
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
if (prefersDark) {
|
||||||
|
root.classList.add('dark')
|
||||||
|
} else {
|
||||||
|
root.classList.remove('dark')
|
||||||
|
}
|
||||||
|
} else if (theme === 'dark') {
|
||||||
|
root.classList.add('dark')
|
||||||
|
} else {
|
||||||
|
root.classList.remove('dark')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleThemeChange = (newTheme: 'light' | 'dark' | 'system') => {
|
||||||
|
setTheme(newTheme)
|
||||||
|
localStorage.setItem('theme', newTheme)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileSelect = async (filename: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/files/${encodeURIComponent(filename)}`)
|
||||||
|
const data = await response.json()
|
||||||
|
setCurrentFile(data)
|
||||||
|
setContent(data.content)
|
||||||
|
setIsEditing(false)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load file:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNewFile = () => {
|
||||||
|
setCurrentFile(null)
|
||||||
|
setContent('# New File\n\nStart writing here...')
|
||||||
|
setIsEditing(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!currentFile) {
|
||||||
|
// Create new file
|
||||||
|
const filename = prompt('Enter filename:', 'untitled.md')
|
||||||
|
if (!filename) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/files', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({filename, content, title: filename.replace('.md', '')})
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
await loadFiles()
|
||||||
|
const newFile = await response.json()
|
||||||
|
setCurrentFile(newFile)
|
||||||
|
setIsEditing(false)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create file:', error)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Update existing file
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/files/${encodeURIComponent(currentFile.filename)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({content})
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
const updated = await response.json()
|
||||||
|
setCurrentFile(updated)
|
||||||
|
setIsEditing(false)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save file:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (currentFile && confirm(`Delete ${currentFile.filename}?`)) {
|
||||||
|
try {
|
||||||
|
await fetch(`/api/files/${encodeURIComponent(currentFile.filename)}`, {method: 'DELETE'})
|
||||||
|
setCurrentFile(null)
|
||||||
|
setContent('')
|
||||||
|
await loadFiles()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete file:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-screen">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<div className="flex items-center justify-between max-w-7xl mx-auto">
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Eval Markdown Editor</h1>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
{/* Theme Switcher */}
|
||||||
|
<div className="flex bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
|
||||||
|
{(['light', 'dark', 'system'] as const).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => handleThemeChange(t)}
|
||||||
|
className={`px-3 py-1 text-sm rounded-md transition-colors ${
|
||||||
|
theme === t
|
||||||
|
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow'
|
||||||
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
{/* File Sidebar */}
|
||||||
|
<aside className="w-64 bg-gray-50 dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 flex flex-col">
|
||||||
|
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
onClick={handleNewFile}
|
||||||
|
className="w-full bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
New File
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-2">
|
||||||
|
{files.length === 0 ? (
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 text-center py-4">No files yet</p>
|
||||||
|
) : (
|
||||||
|
files.map((file) => (
|
||||||
|
<button
|
||||||
|
key={file.filename}
|
||||||
|
onClick={() => handleFileSelect(file.filename)}
|
||||||
|
className={`w-full text-left px-3 py-2 rounded-lg transition-colors ${
|
||||||
|
currentFile?.filename === file.filename
|
||||||
|
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400'
|
||||||
|
: 'hover:bg-gray-200 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{file.title}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Editor/Preview Area */}
|
||||||
|
<main className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
{currentFile ? (
|
||||||
|
<>
|
||||||
|
{/* Editor Toolbar */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-2 flex justify-between items-center">
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{currentFile.filename}
|
||||||
|
</span>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="bg-green-600 hover:bg-green-700 text-white px-4 py-1 rounded-lg transition-colors text-sm"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEditing(false)}
|
||||||
|
className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-1 rounded-lg transition-colors text-sm"
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="bg-red-600 hover:bg-red-700 text-white px-4 py-1 rounded-lg transition-colors text-sm"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-1 rounded-lg transition-colors text-sm"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Area */}
|
||||||
|
<div className="flex-1 overflow-hidden bg-white dark:bg-gray-900">
|
||||||
|
{isEditing ? (
|
||||||
|
<textarea
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
className="w-full h-full p-4 resize-none focus:outline-none font-mono text-sm bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full overflow-auto p-6 max-w-4xl mx-auto">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]} className="prose dark:prose-invert max-w-none">
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div className="text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<p className="text-lg">Select a file or create a new one</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
20
frontend/src/index.css
Normal file
20
frontend/src/index.css
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #333;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
10
frontend/tailwind.config.js
Normal file
10
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
24
frontend/tsconfig.json
Normal file
24
frontend/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{"path": "./tsconfig.node.json"}]
|
||||||
|
}
|
||||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
15
frontend/vite.config.ts
Normal file
15
frontend/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
14
go.mod
Normal file
14
go.mod
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
module eval
|
||||||
|
|
||||||
|
go 1.23.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/sirupsen/logrus v1.9.3
|
||||||
|
github.com/spf13/cobra v1.8.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
|
||||||
|
)
|
||||||
24
go.sum
Normal file
24
go.sum
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
|
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||||
|
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||||
|
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||||
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
|
||||||
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
253
internal/api/handlers.go
Normal file
253
internal/api/handlers.go
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"eval/internal/config"
|
||||||
|
"eval/internal/storage"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handlers holds the API handlers
|
||||||
|
type Handlers struct {
|
||||||
|
store *storage.Storage
|
||||||
|
config *config.Config
|
||||||
|
logger *logrus.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandlers creates a new Handlers instance
|
||||||
|
func NewHandlers(store *storage.Storage, config *config.Config, logger *logrus.Logger) *Handlers {
|
||||||
|
return &Handlers{
|
||||||
|
store: store,
|
||||||
|
config: config,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorResponse represents an error response
|
||||||
|
type ErrorResponse struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeJSON writes a JSON response
|
||||||
|
func (h *Handlers) writeJSON(w http.ResponseWriter, status int, data interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
if err := json.NewEncoder(w).Encode(data); err != nil {
|
||||||
|
h.logger.Errorf("failed to encode response: %v", err)
|
||||||
|
http.Error(w, `{"error": "internal server error"}`, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeError writes an error response
|
||||||
|
func (h *Handlers) writeError(w http.ResponseWriter, status int, message string) {
|
||||||
|
h.writeJSON(w, status, ErrorResponse{Error: message})
|
||||||
|
}
|
||||||
|
|
||||||
|
// listFilesHandler handles GET /api/files - list all markdown files
|
||||||
|
func (h *Handlers) listFilesHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.logger.Info("listing files")
|
||||||
|
files, err := h.store.ListFiles()
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Errorf("failed to list files: %v", err)
|
||||||
|
h.writeError(w, http.StatusInternalServerError, "failed to list files")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, http.StatusOK, files)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getFileHandler handles GET /api/files/{filename} - get a specific file
|
||||||
|
func (h *Handlers) getFileHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/api/files/")
|
||||||
|
filename := path
|
||||||
|
if filename == "" {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "filename is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.WithField("filename", filename).Info("getting file")
|
||||||
|
file, err := h.store.GetFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "file not found" {
|
||||||
|
h.writeError(w, http.StatusNotFound, "file not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Errorf("failed to get file: %v", err)
|
||||||
|
h.writeError(w, http.StatusInternalServerError, "failed to get file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, http.StatusOK, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
// createFileHandler handles POST /api/files - create a new file
|
||||||
|
func (h *Handlers) createFileHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var file storage.FileContent
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&file); err != nil {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if file.Filename == "" {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "filename is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.WithField("filename", file.Filename).Info("creating file")
|
||||||
|
result, err := h.store.CreateFile(file.Filename, file.Content)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "file already exists" {
|
||||||
|
h.writeError(w, http.StatusConflict, "file already exists")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Errorf("failed to create file: %v", err)
|
||||||
|
h.writeError(w, http.StatusInternalServerError, "failed to create file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, http.StatusCreated, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateFileHandler handles PUT /api/files/{filename} - update a file
|
||||||
|
func (h *Handlers) updateFileHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/api/files/")
|
||||||
|
filename := path
|
||||||
|
if filename == "" {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "filename is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var file storage.FileContent
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&file); err != nil {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.WithField("filename", filename).Info("updating file")
|
||||||
|
result, err := h.store.UpdateFile(filename, file.Content)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "file not found" {
|
||||||
|
h.writeError(w, http.StatusNotFound, "file not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Errorf("failed to update file: %v", err)
|
||||||
|
h.writeError(w, http.StatusInternalServerError, "failed to update file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteFileHandler handles DELETE /api/files/{filename} - delete a file
|
||||||
|
func (h *Handlers) deleteFileHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/api/files/")
|
||||||
|
filename := path
|
||||||
|
if filename == "" {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "filename is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.WithField("filename", filename).Info("deleting file")
|
||||||
|
if err := h.store.DeleteFile(filename); err != nil {
|
||||||
|
if err.Error() == "file not found" {
|
||||||
|
h.writeError(w, http.StatusNotFound, "file not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Errorf("failed to delete file: %v", err)
|
||||||
|
h.writeError(w, http.StatusInternalServerError, "failed to delete file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, http.StatusOK, map[string]string{"message": "file deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerAPIRoutes registers all API routes
|
||||||
|
func (h *Handlers) registerAPIRoutes(router *http.ServeMux) {
|
||||||
|
router.HandleFunc("GET /api/files", h.listFilesHandler)
|
||||||
|
router.HandleFunc("GET /api/files/", h.getFileHandler)
|
||||||
|
router.HandleFunc("POST /api/files", h.createFileHandler)
|
||||||
|
router.HandleFunc("PUT /api/files/", h.updateFileHandler)
|
||||||
|
router.HandleFunc("DELETE /api/files/", h.deleteFileHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeStaticHandler serves static files
|
||||||
|
type ServeStaticHandler struct {
|
||||||
|
fs http.FileSystem
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServeStaticHandler creates a new static file handler
|
||||||
|
func NewServeStaticHandler(dir string) (*ServeStaticHandler, error) {
|
||||||
|
fs := http.Dir(dir)
|
||||||
|
_, err := fs.Open(".")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("static directory not found: %w", err)
|
||||||
|
}
|
||||||
|
return &ServeStaticHandler{fs: fs}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP serves files from the static directory
|
||||||
|
func (h *ServeStaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
file, err := h.fs.Open(r.URL.Path)
|
||||||
|
if err != nil {
|
||||||
|
// If file not found, serve index.html for SPA routing
|
||||||
|
file, err = h.fs.Open("/index.html")
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Get file info to determine content type
|
||||||
|
info, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.ServeContent(w, r, info.Name(), info.ModTime(), file)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupStaticHandler sets up the static file handler
|
||||||
|
func (h *Handlers) SetupStaticHandler(buildDir string) (http.HandlerFunc, error) {
|
||||||
|
handler, err := NewServeStaticHandler(buildDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return handler.ServeHTTP, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupRoutes sets up all routes and returns the router
|
||||||
|
func (h *Handlers) SetupRoutes(buildDir string) (*http.ServeMux, error) {
|
||||||
|
router := http.NewServeMux()
|
||||||
|
h.registerAPIRoutes(router)
|
||||||
|
|
||||||
|
// Setup static handler
|
||||||
|
if _, err := os.Stat(buildDir); err == nil {
|
||||||
|
router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
|
||||||
|
http.ServeFile(w, r, filepath.Join(buildDir, "index.html"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Try to serve the file, if not found, serve index.html
|
||||||
|
file, err := http.Dir(buildDir).Open(r.URL.Path)
|
||||||
|
if err != nil {
|
||||||
|
http.ServeFile(w, r, filepath.Join(buildDir, "index.html"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file.Close()
|
||||||
|
http.ServeFile(w, r, filepath.Join(buildDir, r.URL.Path))
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
h.logger.Warnf("build directory not found: %s, static file serving disabled", buildDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
return router, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartServer starts the HTTP server
|
||||||
|
func (h *Handlers) StartServer(router *http.ServeMux) error {
|
||||||
|
addr := fmt.Sprintf("%s:%d", h.config.Host, h.config.Port)
|
||||||
|
h.logger.Infof("starting server on %s", addr)
|
||||||
|
return http.ListenAndServe(addr, router)
|
||||||
|
}
|
||||||
380
internal/api/handlers_test.go
Normal file
380
internal/api/handlers_test.go
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"eval/internal/config"
|
||||||
|
"eval/internal/storage"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupTestEnvironment(t *testing.T) (*Handlers, string) {
|
||||||
|
t.Helper()
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
cfg := config.NewConfig()
|
||||||
|
cfg.DataDir = tempDir
|
||||||
|
|
||||||
|
logger := logrus.New()
|
||||||
|
logger.SetLevel(logrus.FatalLevel)
|
||||||
|
|
||||||
|
store := storage.NewStorage(tempDir)
|
||||||
|
handlers := NewHandlers(store, cfg, logger)
|
||||||
|
return handlers, tempDir
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlers_ListFiles_Empty(t *testing.T) {
|
||||||
|
handlers, _ := setupTestEnvironment(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/files", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handlers.listFilesHandler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var files []storage.FileMetadata
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &files); err != nil {
|
||||||
|
t.Fatalf("failed to parse response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(files) != 0 {
|
||||||
|
t.Errorf("expected 0 files, got %d", len(files))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlers_ListFiles_Multiple(t *testing.T) {
|
||||||
|
handlers, tempDir := setupTestEnvironment(t)
|
||||||
|
|
||||||
|
// Create test files
|
||||||
|
testFiles := []string{"file1.md", "file2.md", "file3.md"}
|
||||||
|
for _, name := range testFiles {
|
||||||
|
content := "# Test " + name
|
||||||
|
if err := os.WriteFile(filepath.Join(tempDir, name), []byte(content), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to create test file: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/files", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handlers.listFilesHandler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var files []storage.FileMetadata
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &files); err != nil {
|
||||||
|
t.Fatalf("failed to parse response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(files) != 3 {
|
||||||
|
t.Errorf("expected 3 files, got %d", len(files))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlers_GetFile(t *testing.T) {
|
||||||
|
handlers, tempDir := setupTestEnvironment(t)
|
||||||
|
|
||||||
|
// Create test file
|
||||||
|
filename := "test.md"
|
||||||
|
content := "# Hello World\n\nThis is a test file."
|
||||||
|
if err := os.WriteFile(filepath.Join(tempDir, filename), []byte(content), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to create test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/files/"+filename, nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handlers.getFileHandler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var file storage.FileContent
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &file); err != nil {
|
||||||
|
t.Fatalf("failed to parse response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if file.Content != content {
|
||||||
|
t.Errorf("expected content %s, got %s", content, file.Content)
|
||||||
|
}
|
||||||
|
if file.Filename != filename {
|
||||||
|
t.Errorf("expected filename %s, got %s", filename, file.Filename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlers_GetFile_NotFound(t *testing.T) {
|
||||||
|
handlers, _ := setupTestEnvironment(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/files/nonexistent.md", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handlers.getFileHandler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("expected status 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var errResp ErrorResponse
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &errResp); err != nil {
|
||||||
|
t.Fatalf("failed to parse response: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlers_CreateFile(t *testing.T) {
|
||||||
|
handlers, _ := setupTestEnvironment(t)
|
||||||
|
|
||||||
|
filename := "newfile.md"
|
||||||
|
content := "# New File\n\nCreated via API."
|
||||||
|
body := &storage.FileContent{
|
||||||
|
Filename: filename,
|
||||||
|
Content: content,
|
||||||
|
Title: "New File",
|
||||||
|
}
|
||||||
|
jsonBody, _ := json.Marshal(body)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/files", bytes.NewBuffer(jsonBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handlers.createFileHandler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Errorf("expected status 201, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var file storage.FileContent
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &file); err != nil {
|
||||||
|
t.Fatalf("failed to parse response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if file.Content != content {
|
||||||
|
t.Errorf("expected content %s, got %s", content, file.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlers_CreateFile_Validation(t *testing.T) {
|
||||||
|
handlers, _ := setupTestEnvironment(t)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
filename string
|
||||||
|
expected int
|
||||||
|
}{
|
||||||
|
{"empty filename", "", http.StatusBadRequest},
|
||||||
|
{"missing extension", "noext", http.StatusInternalServerError},
|
||||||
|
{"invalid extension", "file.txt", http.StatusInternalServerError},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
body := &storage.FileContent{
|
||||||
|
Filename: tt.filename,
|
||||||
|
Content: "content",
|
||||||
|
}
|
||||||
|
jsonBody, _ := json.Marshal(body)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/files", bytes.NewBuffer(jsonBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handlers.createFileHandler(w, req)
|
||||||
|
|
||||||
|
if w.Code != tt.expected {
|
||||||
|
t.Errorf("expected status %d, got %d", tt.expected, w.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlers_UpdateFile(t *testing.T) {
|
||||||
|
handlers, tempDir := setupTestEnvironment(t)
|
||||||
|
|
||||||
|
// Create initial file
|
||||||
|
filename := "update.md"
|
||||||
|
content := "# Original"
|
||||||
|
if err := os.WriteFile(filepath.Join(tempDir, filename), []byte(content), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to create test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update file
|
||||||
|
newContent := "# Updated\n\nContent changed."
|
||||||
|
body := &storage.FileContent{
|
||||||
|
Filename: filename,
|
||||||
|
Content: newContent,
|
||||||
|
Title: "Updated",
|
||||||
|
}
|
||||||
|
jsonBody, _ := json.Marshal(body)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("PUT", "/api/files/"+filename, bytes.NewBuffer(jsonBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handlers.updateFileHandler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var file storage.FileContent
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &file); err != nil {
|
||||||
|
t.Fatalf("failed to parse response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if file.Content != newContent {
|
||||||
|
t.Errorf("expected content %s, got %s", newContent, file.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlers_UpdateFile_NotFound(t *testing.T) {
|
||||||
|
handlers, _ := setupTestEnvironment(t)
|
||||||
|
|
||||||
|
body := &storage.FileContent{
|
||||||
|
Filename: "nonexistent.md",
|
||||||
|
Content: "content",
|
||||||
|
}
|
||||||
|
jsonBody, _ := json.Marshal(body)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("PUT", "/api/files/nonexistent.md", bytes.NewBuffer(jsonBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handlers.updateFileHandler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("expected status 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlers_DeleteFile(t *testing.T) {
|
||||||
|
handlers, tempDir := setupTestEnvironment(t)
|
||||||
|
|
||||||
|
// Create file
|
||||||
|
filename := "delete.md"
|
||||||
|
content := "# To be deleted"
|
||||||
|
if err := os.WriteFile(filepath.Join(tempDir, filename), []byte(content), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to create test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete file
|
||||||
|
req := httptest.NewRequest("DELETE", "/api/files/"+filename, nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handlers.deleteFileHandler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file is deleted
|
||||||
|
if _, err := os.Stat(filepath.Join(tempDir, filename)); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("file should be deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlers_DeleteFile_NotFound(t *testing.T) {
|
||||||
|
handlers, _ := setupTestEnvironment(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("DELETE", "/api/files/nonexistent.md", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handlers.deleteFileHandler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("expected status 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlers_CRUDRoundTrip(t *testing.T) {
|
||||||
|
handlers, tempDir := setupTestEnvironment(t)
|
||||||
|
|
||||||
|
// Create
|
||||||
|
filename := "roundtrip.md"
|
||||||
|
content := "# Round Trip Test"
|
||||||
|
body := &storage.FileContent{
|
||||||
|
Filename: filename,
|
||||||
|
Content: content,
|
||||||
|
Title: "Round Trip Test",
|
||||||
|
}
|
||||||
|
jsonBody, _ := json.Marshal(body)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/files", bytes.NewBuffer(jsonBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handlers.createFileHandler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("Create failed with status %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var created storage.FileContent
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &created)
|
||||||
|
|
||||||
|
// Read
|
||||||
|
req = httptest.NewRequest("GET", "/api/files/"+filename, nil)
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
handlers.getFileHandler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("Read failed with status %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var retrieved storage.FileContent
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &retrieved)
|
||||||
|
|
||||||
|
if retrieved.Content != content {
|
||||||
|
t.Errorf("Content mismatch after read: expected %s, got %s", content, retrieved.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update
|
||||||
|
newContent := "# Round Trip Updated"
|
||||||
|
body = &storage.FileContent{
|
||||||
|
Filename: filename,
|
||||||
|
Content: newContent,
|
||||||
|
Title: "Round Trip Updated",
|
||||||
|
}
|
||||||
|
jsonBody, _ = json.Marshal(body)
|
||||||
|
|
||||||
|
req = httptest.NewRequest("PUT", "/api/files/"+filename, bytes.NewBuffer(jsonBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
handlers.updateFileHandler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("Update failed with status %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
req = httptest.NewRequest("DELETE", "/api/files/"+filename, nil)
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
handlers.deleteFileHandler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("Delete failed with status %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify deletion
|
||||||
|
req = httptest.NewRequest("GET", "/api/files/"+filename, nil)
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
handlers.getFileHandler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("Expected 404 after delete, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file is deleted on disk
|
||||||
|
if _, err := os.Stat(filepath.Join(tempDir, filename)); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("file should be deleted on disk")
|
||||||
|
}
|
||||||
|
}
|
||||||
54
internal/config/config.go
Normal file
54
internal/config/config.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds the application configuration
|
||||||
|
type Config struct {
|
||||||
|
DataDir string
|
||||||
|
Port int
|
||||||
|
Host string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewConfig creates a new config with default values
|
||||||
|
func NewConfig() *Config {
|
||||||
|
return &Config{
|
||||||
|
DataDir: "./data",
|
||||||
|
Port: 8080,
|
||||||
|
Host: "127.0.0.1",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddFlags adds the CLI flags to the command
|
||||||
|
func (c *Config) AddFlags(cmd *cobra.Command) {
|
||||||
|
cmd.PersistentFlags().StringVar(&c.DataDir, "data-dir", c.DataDir, "Storage path for markdown files")
|
||||||
|
cmd.PersistentFlags().IntVar(&c.Port, "port", c.Port, "Server port")
|
||||||
|
cmd.PersistentFlags().StringVar(&c.Host, "host", c.Host, "Bind address")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate validates the configuration
|
||||||
|
func (c *Config) Validate() error {
|
||||||
|
if c.Port < 1 || c.Port > 65535 {
|
||||||
|
return fmt.Errorf("invalid port: %d", c.Port)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DataDirPath returns the absolute path to the data directory
|
||||||
|
func (c *Config) DataDirPath() string {
|
||||||
|
absPath, err := filepath.Abs(c.DataDir)
|
||||||
|
if err != nil {
|
||||||
|
return c.DataDir
|
||||||
|
}
|
||||||
|
return absPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureDataDir ensures the data directory exists
|
||||||
|
func (c *Config) EnsureDataDir() error {
|
||||||
|
return os.MkdirAll(c.DataDirPath(), 0755)
|
||||||
|
}
|
||||||
176
internal/storage/storage.go
Normal file
176
internal/storage/storage.go
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileMetadata holds metadata about a markdown file
|
||||||
|
type FileMetadata struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Modified time.Time `json:"modified"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileContent holds both metadata and content
|
||||||
|
type FileContent struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Modified time.Time `json:"modified"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storage handles file operations for markdown files
|
||||||
|
type Storage struct {
|
||||||
|
dataDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStorage creates a new storage instance
|
||||||
|
func NewStorage(dataDir string) *Storage {
|
||||||
|
return &Storage{dataDir: dataDir}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateFilename checks if the filename is valid
|
||||||
|
func (s *Storage) validateFilename(filename string) error {
|
||||||
|
// Must end with .md
|
||||||
|
if !strings.HasSuffix(filename, ".md") {
|
||||||
|
return fmt.Errorf("only .md files are allowed")
|
||||||
|
}
|
||||||
|
// Check for path traversal
|
||||||
|
if strings.Contains(filename, "..") || strings.Contains(filename, "/") || strings.Contains(filename, "\\") {
|
||||||
|
return fmt.Errorf("invalid filename")
|
||||||
|
}
|
||||||
|
// Check for empty filename
|
||||||
|
if filename == "" {
|
||||||
|
return fmt.Errorf("filename cannot be empty")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFiles returns a list of all markdown files
|
||||||
|
func (s *Storage) ListFiles() ([]FileMetadata, error) {
|
||||||
|
files, err := os.ReadDir(s.dataDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := []FileMetadata{}
|
||||||
|
re := regexp.MustCompile(`^(.+)\.md$`)
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
if file.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(file.Name(), ".md") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := file.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := re.ReplaceAllString(file.Name(), "$1")
|
||||||
|
metadata = append(metadata, FileMetadata{
|
||||||
|
Filename: file.Name(),
|
||||||
|
Title: name,
|
||||||
|
Modified: info.ModTime(),
|
||||||
|
Size: info.Size(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return metadata, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFile reads a markdown file by filename
|
||||||
|
func (s *Storage) GetFile(filename string) (*FileContent, error) {
|
||||||
|
if err := s.validateFilename(filename); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid filename: %w", err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(s.dataDir, filename)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, fmt.Errorf("file not found")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to read file: %w", err)
|
||||||
|
}
|
||||||
|
return &FileContent{
|
||||||
|
Filename: filename,
|
||||||
|
Content: string(data),
|
||||||
|
Title: strings.TrimSuffix(filename, ".md"),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFile creates a new markdown file
|
||||||
|
func (s *Storage) CreateFile(filename string, content string) (*FileContent, error) {
|
||||||
|
if err := s.validateFilename(filename); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid filename: %w", err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(s.dataDir, filename)
|
||||||
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
|
return nil, fmt.Errorf("file already exists")
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write file: %w", err)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get file info: %w", err)
|
||||||
|
}
|
||||||
|
return &FileContent{
|
||||||
|
Filename: filename,
|
||||||
|
Content: content,
|
||||||
|
Title: strings.TrimSuffix(filename, ".md"),
|
||||||
|
Modified: info.ModTime(),
|
||||||
|
Size: info.Size(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateFile updates an existing markdown file
|
||||||
|
func (s *Storage) UpdateFile(filename string, content string) (*FileContent, error) {
|
||||||
|
if err := s.validateFilename(filename); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid filename: %w", err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(s.dataDir, filename)
|
||||||
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
|
return nil, fmt.Errorf("file not found")
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write file: %w", err)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get file info: %w", err)
|
||||||
|
}
|
||||||
|
return &FileContent{
|
||||||
|
Filename: filename,
|
||||||
|
Content: content,
|
||||||
|
Title: strings.TrimSuffix(filename, ".md"),
|
||||||
|
Modified: info.ModTime(),
|
||||||
|
Size: info.Size(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteFile deletes a markdown file
|
||||||
|
func (s *Storage) DeleteFile(filename string) error {
|
||||||
|
if err := s.validateFilename(filename); err != nil {
|
||||||
|
return fmt.Errorf("invalid filename: %w", err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(s.dataDir, filename)
|
||||||
|
if err := os.Remove(path); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("file not found")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to delete file: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateFilename is a public wrapper for testing
|
||||||
|
func (s *Storage) ValidateFilename(filename string) error {
|
||||||
|
return s.validateFilename(filename)
|
||||||
|
}
|
||||||
237
internal/storage/storage_test.go
Normal file
237
internal/storage/storage_test.go
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStorage_Init(t *testing.T) {
|
||||||
|
// Create temp directory for testing
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
_ = NewStorage(tempDir)
|
||||||
|
|
||||||
|
// Verify directory exists
|
||||||
|
if _, err := os.Stat(tempDir); os.IsNotExist(err) {
|
||||||
|
t.Errorf("data directory should be created")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's empty
|
||||||
|
files, err := os.ReadDir(tempDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read directory: %v", err)
|
||||||
|
}
|
||||||
|
if len(files) != 0 {
|
||||||
|
t.Errorf("directory should be empty initially")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStorage_ListFiles_Empty(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
store := NewStorage(tempDir)
|
||||||
|
|
||||||
|
files, err := store.ListFiles()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListFiles failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(files) != 0 {
|
||||||
|
t.Errorf("expected 0 files, got %d", len(files))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStorage_ListFiles_Multiple(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
store := NewStorage(tempDir)
|
||||||
|
|
||||||
|
// Create test files
|
||||||
|
testFiles := []string{"file1.md", "file2.md", "file3.md"}
|
||||||
|
for _, name := range testFiles {
|
||||||
|
content := "# Test " + name
|
||||||
|
if err := os.WriteFile(filepath.Join(tempDir, name), []byte(content), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to create test file: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := store.ListFiles()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListFiles failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(files) != 3 {
|
||||||
|
t.Errorf("expected 3 files, got %d", len(files))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStorage_CRUD(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
store := NewStorage(tempDir)
|
||||||
|
|
||||||
|
// Test Create
|
||||||
|
filename := "test.md"
|
||||||
|
content := "# Hello World\n\nThis is a test file."
|
||||||
|
created, err := store.CreateFile(filename, content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateFile failed: %v", err)
|
||||||
|
}
|
||||||
|
if created.Filename != filename {
|
||||||
|
t.Errorf("expected filename %s, got %s", filename, created.Filename)
|
||||||
|
}
|
||||||
|
if created.Content != content {
|
||||||
|
t.Errorf("expected content %s, got %s", content, created.Content)
|
||||||
|
}
|
||||||
|
if created.Title != "test" {
|
||||||
|
t.Errorf("expected title 'test', got '%s'", created.Title)
|
||||||
|
}
|
||||||
|
if created.Modified.IsZero() {
|
||||||
|
t.Errorf("modified timestamp should not be zero")
|
||||||
|
}
|
||||||
|
if created.Size != int64(len(content)) {
|
||||||
|
t.Errorf("expected size %d, got %d", len(content), created.Size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Get
|
||||||
|
retrieved, err := store.GetFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetFile failed: %v", err)
|
||||||
|
}
|
||||||
|
if retrieved.Content != content {
|
||||||
|
t.Errorf("expected content %s, got %s", content, retrieved.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Update
|
||||||
|
newContent := "# Updated\n\nContent was updated."
|
||||||
|
updated, err := store.UpdateFile(filename, newContent)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdateFile failed: %v", err)
|
||||||
|
}
|
||||||
|
if updated.Content != newContent {
|
||||||
|
t.Errorf("expected updated content %s, got %s", newContent, updated.Content)
|
||||||
|
}
|
||||||
|
if updated.Modified.Before(created.Modified) {
|
||||||
|
t.Errorf("modified timestamp should be updated")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test List includes updated file
|
||||||
|
files, err := store.ListFiles()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListFiles failed: %v", err)
|
||||||
|
}
|
||||||
|
var found bool
|
||||||
|
for _, f := range files {
|
||||||
|
if f.Filename == filename {
|
||||||
|
found = true
|
||||||
|
if f.Size != int64(len(newContent)) {
|
||||||
|
t.Errorf("expected size %d for list, got %d", len(newContent), f.Size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("updated file not found in list")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Delete
|
||||||
|
if err := store.DeleteFile(filename); err != nil {
|
||||||
|
t.Fatalf("DeleteFile failed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(tempDir, filename)); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("file should be deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file is gone
|
||||||
|
if _, err := store.GetFile(filename); err == nil {
|
||||||
|
t.Errorf("GetFile should fail after deletion")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStorage_ValidateFilename(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
store := NewStorage(tempDir)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
filename string
|
||||||
|
valid bool
|
||||||
|
}{
|
||||||
|
{"valid .md file", "test.md", true},
|
||||||
|
{"valid with numbers", "file123.md", true},
|
||||||
|
{"invalid without extension", "test", false},
|
||||||
|
{"invalid wrong extension", "test.txt", false},
|
||||||
|
{"invalid path traversal", "../etc/passwd", false},
|
||||||
|
{"invalid with slash", "dir/file.md", false},
|
||||||
|
{"empty filename", "", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := store.ValidateFilename(tt.filename)
|
||||||
|
if tt.valid && err != nil {
|
||||||
|
t.Errorf("expected valid, got error: %v", err)
|
||||||
|
}
|
||||||
|
if !tt.valid && err == nil {
|
||||||
|
t.Errorf("expected error, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStorage_FileNotFound(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
store := NewStorage(tempDir)
|
||||||
|
|
||||||
|
_, err := store.GetFile("nonexistent.md")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("GetFile should return error for non-existent file")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = store.UpdateFile("nonexistent.md", "content")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("UpdateFile should return error for non-existent file")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = store.DeleteFile("nonexistent.md")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("DeleteFile should return error for non-existent file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStorage_FileExists(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
store := NewStorage(tempDir)
|
||||||
|
|
||||||
|
// Create file
|
||||||
|
_, err := store.CreateFile("exists.md", "content")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateFile failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to create again
|
||||||
|
_, err = store.CreateFile("exists.md", "content")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("CreateFile should fail for existing file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStorage_ModificationTime(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
store := NewStorage(tempDir)
|
||||||
|
|
||||||
|
// Create file
|
||||||
|
created, err := store.CreateFile("test.md", "content")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateFile failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait a moment
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Update file
|
||||||
|
updated, err := store.UpdateFile("test.md", "new content")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdateFile failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify modification time was updated
|
||||||
|
if !updated.Modified.After(created.Modified) {
|
||||||
|
t.Errorf("modified time should be updated on update")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user