feat: implement WYSIWYG markdown editor
Add full-stack markdown editor with Go backend and React frontend. Backend: - Cobra CLI with --data-dir, --port, --host flags - REST API for markdown file CRUD operations - File storage with flat directory structure - logrus logging for all operations - Static file serving for frontend - Comprehensive tests for CRUD and static assets Frontend: - React + TypeScript + Vite + Tailwind CSS - Live markdown preview with marked (GFM) - File management: list, create, open, save, delete - Theme system: Dark/Light/System with persistence - Responsive design (320px to 1920px) - Component tests for Editor, Preview, Sidebar Build: - Makefile for build, test, and run automation - Single command testing (make test) Closes SPEC.md requirements
This commit is contained in:
150
backend/storage/storage.go
Normal file
150
backend/storage/storage.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Storage handles file operations for markdown files
|
||||
type Storage struct {
|
||||
dataDir string
|
||||
mu sync.RWMutex
|
||||
log *logrus.Logger
|
||||
}
|
||||
|
||||
// New creates a new Storage instance
|
||||
func New(dataDir string, log *logrus.Logger) *Storage {
|
||||
return &Storage{
|
||||
dataDir: dataDir,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// Init ensures the data directory exists
|
||||
func (s *Storage) Init() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.log.WithField("dataDir", s.dataDir).Info("Initializing storage")
|
||||
|
||||
if err := os.MkdirAll(s.dataDir, 0755); err != nil {
|
||||
s.log.WithError(err).Error("Failed to create data directory")
|
||||
return fmt.Errorf("failed to create data directory: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns all markdown files in the data directory
|
||||
func (s *Storage) List() ([]string, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
s.log.Debug("Listing all files")
|
||||
|
||||
entries, err := os.ReadDir(s.dataDir)
|
||||
if err != nil {
|
||||
s.log.WithError(err).Error("Failed to read data directory")
|
||||
return nil, fmt.Errorf("failed to read data directory: %w", err)
|
||||
}
|
||||
|
||||
var files []string
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".md") {
|
||||
files = append(files, strings.TrimSuffix(entry.Name(), ".md"))
|
||||
}
|
||||
}
|
||||
|
||||
s.log.WithField("count", len(files)).Debug("Listed files")
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// Get reads a markdown file by name
|
||||
func (s *Storage) Get(name string) (string, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
s.log.WithField("name", name).Debug("Getting file")
|
||||
|
||||
if !isValidFilename(name) {
|
||||
s.log.WithField("name", name).Warn("Invalid filename")
|
||||
return "", fmt.Errorf("invalid filename")
|
||||
}
|
||||
|
||||
path := filepath.Join(s.dataDir, name+".md")
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
s.log.WithField("name", name).Warn("File not found")
|
||||
return "", fmt.Errorf("file not found")
|
||||
}
|
||||
s.log.WithError(err).WithField("name", name).Error("Failed to read file")
|
||||
return "", fmt.Errorf("failed to read file: %w", err)
|
||||
}
|
||||
|
||||
return string(content), nil
|
||||
}
|
||||
|
||||
// Save writes content to a markdown file
|
||||
func (s *Storage) Save(name, content string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.log.WithField("name", name).Debug("Saving file")
|
||||
|
||||
if !isValidFilename(name) {
|
||||
s.log.WithField("name", name).Warn("Invalid filename")
|
||||
return fmt.Errorf("invalid filename")
|
||||
}
|
||||
|
||||
path := filepath.Join(s.dataDir, name+".md")
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
s.log.WithError(err).WithField("name", name).Error("Failed to write file")
|
||||
return fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
s.log.WithField("name", name).Info("File saved")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a markdown file
|
||||
func (s *Storage) Delete(name string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.log.WithField("name", name).Debug("Deleting file")
|
||||
|
||||
if !isValidFilename(name) {
|
||||
s.log.WithField("name", name).Warn("Invalid filename")
|
||||
return fmt.Errorf("invalid filename")
|
||||
}
|
||||
|
||||
path := filepath.Join(s.dataDir, name+".md")
|
||||
if err := os.Remove(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
s.log.WithField("name", name).Warn("File not found for deletion")
|
||||
return fmt.Errorf("file not found")
|
||||
}
|
||||
s.log.WithError(err).WithField("name", name).Error("Failed to delete file")
|
||||
return fmt.Errorf("failed to delete file: %w", err)
|
||||
}
|
||||
|
||||
s.log.WithField("name", name).Info("File deleted")
|
||||
return nil
|
||||
}
|
||||
|
||||
// isValidFilename checks if a filename is safe to use
|
||||
func isValidFilename(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
// Prevent path traversal
|
||||
if strings.Contains(name, "/") || strings.Contains(name, "\\") || strings.Contains(name, "..") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user