Initial commit: WYSIWYG Markdown Editor - Go backend + React/TypeScript frontend with Tailwind CSS
Backend: - Cobra CLI with --data-dir, --port, --host flags - Gin HTTP server with REST API for markdown CRUD operations - File storage on disk (.md files only) - Comprehensive logrus logging - Backend tests with CRUD round-trip verification Frontend: - React 18 + TypeScript + Tailwind CSS - Markdown editor with live GFM preview (react-markdown + remark-gfm) - File management UI (list, create, open, save, delete) - Theme switcher with Dark/Light/System modes - Responsive design - Frontend tests with vitest Testing: - All backend tests pass (go test ./...) - All frontend tests pass (npm test)
This commit is contained in:
134
internal/storage/storage.go
Normal file
134
internal/storage/storage.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"markdown-editor/internal/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
allowedExt = regexp.MustCompile(`(?i)\.md$`)
|
||||
)
|
||||
|
||||
type Storage struct {
|
||||
dataDir string
|
||||
log *logrus.Logger
|
||||
}
|
||||
|
||||
func NewStorage(dataDir string) (*Storage, error) {
|
||||
log := logger.GetLogger()
|
||||
|
||||
// Create data directory if it doesn't exist
|
||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create data directory: %w", err)
|
||||
}
|
||||
|
||||
return &Storage{
|
||||
dataDir: dataDir,
|
||||
log: log,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// File represents a markdown file
|
||||
type File struct {
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// ListFiles returns all .md files in the data directory
|
||||
func (s *Storage) ListFiles() ([]string, error) {
|
||||
s.log.WithField("operation", "list_files").Info("Listing markdown files")
|
||||
|
||||
files := []string{}
|
||||
err := filepath.WalkDir(s.dataDir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !d.IsDir() && allowedExt.MatchString(d.Name()) {
|
||||
files = append(files, d.Name())
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to walk directory: %w", err)
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// GetFile reads a markdown file by name
|
||||
func (s *Storage) GetFile(name string) (*File, error) {
|
||||
s.log.WithField("operation", "get_file").WithField("filename", name).Info("Getting file")
|
||||
|
||||
path := filepath.Join(s.dataDir, name)
|
||||
if !allowedExt.MatchString(path) {
|
||||
return nil, fmt.Errorf("invalid file extension: only .md files allowed")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("file not found: %s", name)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read file: %w", err)
|
||||
}
|
||||
|
||||
return &File{
|
||||
Name: name,
|
||||
Content: string(data),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateFile creates a new markdown file
|
||||
func (s *Storage) CreateFile(name string, content string) error {
|
||||
s.log.WithField("operation", "create_file").WithField("filename", name).Info("Creating file")
|
||||
|
||||
path := filepath.Join(s.dataDir, name)
|
||||
if !allowedExt.MatchString(path) {
|
||||
return fmt.Errorf("invalid file extension: only .md files allowed")
|
||||
}
|
||||
|
||||
// Check if file already exists
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return fmt.Errorf("file already exists: %s", name)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateFile updates an existing markdown file
|
||||
func (s *Storage) UpdateFile(name string, content string) error {
|
||||
s.log.WithField("operation", "update_file").WithField("filename", name).Info("Updating file")
|
||||
|
||||
path := filepath.Join(s.dataDir, name)
|
||||
if !allowedExt.MatchString(path) {
|
||||
return fmt.Errorf("invalid file extension: only .md files allowed")
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteFile deletes a markdown file
|
||||
func (s *Storage) DeleteFile(name string) error {
|
||||
s.log.WithField("operation", "delete_file").WithField("filename", name).Info("Deleting file")
|
||||
|
||||
path := filepath.Join(s.dataDir, name)
|
||||
if !allowedExt.MatchString(path) {
|
||||
return fmt.Errorf("invalid file extension: only .md files allowed")
|
||||
}
|
||||
|
||||
if err := os.Remove(path); err != nil {
|
||||
return fmt.Errorf("failed to delete file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
168
internal/storage/storage_test.go
Normal file
168
internal/storage/storage_test.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStorage_ListFiles(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "test-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
store, err := NewStorage(tempDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create test files
|
||||
files := []string{"file1.md", "file2.md", "file3.txt"}
|
||||
for _, name := range files {
|
||||
err = os.WriteFile(filepath.Join(tempDir, name), []byte("# Content"), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
result, err := store.ListFiles()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(result) != 2 {
|
||||
t.Errorf("Expected 2 .md files, got %d", len(result))
|
||||
}
|
||||
|
||||
// Verify only .md files are listed
|
||||
for _, name := range result {
|
||||
if !IsMarkdownFile(name) {
|
||||
t.Errorf("Non-markdown file found: %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorage_CreateFile(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "test-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
store, err := NewStorage(tempDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = store.CreateFile("test.md", "# Test Content")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(tempDir, "test.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if string(content) != "# Test Content" {
|
||||
t.Errorf("Expected '# Test Content', got '%s'", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorage_UpdateFile(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "test-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
store, err := NewStorage(tempDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create file
|
||||
err = store.CreateFile("test.md", "# Original Content")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Update file
|
||||
err = store.UpdateFile("test.md", "# Updated Content")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(tempDir, "test.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if string(content) != "# Updated Content" {
|
||||
t.Errorf("Expected '# Updated Content', got '%s'", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorage_DeleteFile(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "test-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
store, err := NewStorage(tempDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create file
|
||||
err = store.CreateFile("test.md", "# Test Content")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Delete file
|
||||
err = store.DeleteFile("test.md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = os.Stat(filepath.Join(tempDir, "test.md"))
|
||||
if !os.IsNotExist(err) {
|
||||
t.Error("File was not deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorage_GetFile(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "test-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
store, err := NewStorage(tempDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create file
|
||||
err = store.CreateFile("test.md", "# Test Content")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
file, err := store.GetFile("test.md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if file.Content != "# Test Content" {
|
||||
t.Errorf("Expected '# Test Content', got '%s'", file.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func IsMarkdownFile(filename string) bool {
|
||||
return filepath.Ext(filename) == ".md"
|
||||
}
|
||||
Reference in New Issue
Block a user