Files
cairnquire/apps/server/internal/docs/service.go
2026-06-01 10:53:28 -04:00

358 lines
8.4 KiB
Go

package docs
import (
"context"
"database/sql"
"errors"
"fmt"
"io/fs"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"github.com/tim/cairnquire/apps/server/internal/markdown"
"github.com/tim/cairnquire/apps/server/internal/store"
)
type Service struct {
sourceDir string
store *store.ContentStore
renderer *markdown.Renderer
repo *Repository
logger *slog.Logger
mu sync.Mutex
onChange func(DocumentChange)
}
type DocumentChange struct {
Path string `json:"path"`
Title string `json:"title"`
Hash string `json:"hash"`
PreviousHash string `json:"previousHash,omitempty"`
}
type Page struct {
Path string
Title string
Tags []string
HTML string
Hash string
}
type SourcePage struct {
Path string
Title string
Tags []string
Content string
Hash string
}
type DocumentConflictError struct {
Path string
BaseHash string
CurrentHash string
CurrentContent string
}
func (e *DocumentConflictError) Error() string {
return fmt.Sprintf("document %s changed since editor loaded", e.Path)
}
func NewService(sourceDir string, store *store.ContentStore, renderer *markdown.Renderer, repo *Repository, logger *slog.Logger) *Service {
return &Service{
sourceDir: sourceDir,
store: store,
renderer: renderer,
repo: repo,
logger: logger,
}
}
func (s *Service) OnChange(callback func(DocumentChange)) {
s.mu.Lock()
defer s.mu.Unlock()
s.onChange = callback
}
func (s *Service) SyncSourceDir(ctx context.Context) ([]DocumentChange, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.syncSourceDirLocked(ctx)
}
func (s *Service) syncSourceDirLocked(ctx context.Context) ([]DocumentChange, error) {
var changes []DocumentChange
err := filepath.WalkDir(s.sourceDir, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() || filepath.Ext(path) != ".md" {
return nil
}
change, err := s.syncFile(ctx, path)
if err != nil {
return err
}
if change != nil {
changes = append(changes, *change)
}
return nil
})
if err != nil {
return nil, err
}
if s.onChange != nil {
for _, change := range changes {
s.onChange(change)
}
}
return changes, nil
}
func (s *Service) ListDocuments(ctx context.Context) ([]DocumentRecord, error) {
if _, err := s.SyncSourceDir(ctx); err != nil {
return nil, err
}
return s.repo.ListDocuments(ctx)
}
func (s *Service) LoadPage(ctx context.Context, requestPath string) (*Page, error) {
if _, err := s.SyncSourceDir(ctx); err != nil {
return nil, err
}
record, normalized, err := s.resolveRecord(ctx, requestPath)
if err != nil {
return nil, err
}
content, err := s.store.Read(record.CurrentHash)
if err != nil {
return nil, fmt.Errorf("read content %s: %w", record.CurrentHash, err)
}
rendered, err := s.renderer.RenderPath(content, normalized)
if err != nil {
return nil, fmt.Errorf("render page %s: %w", normalized, err)
}
return &Page{
Path: record.Path,
Title: rendered.Title,
Tags: record.Tags,
HTML: string(rendered.HTML),
Hash: record.CurrentHash,
}, nil
}
func (s *Service) LoadSourcePage(ctx context.Context, requestPath string) (*SourcePage, error) {
if _, err := s.SyncSourceDir(ctx); err != nil {
return nil, err
}
record, _, err := s.resolveRecord(ctx, requestPath)
if err != nil {
return nil, err
}
content, err := s.store.Read(record.CurrentHash)
if err != nil {
return nil, fmt.Errorf("read source content %s: %w", record.CurrentHash, err)
}
return &SourcePage{
Path: record.Path,
Title: record.Title,
Tags: record.Tags,
Content: string(content),
Hash: record.CurrentHash,
}, nil
}
func (s *Service) SaveSourcePage(ctx context.Context, requestPath string, content string) (*SourcePage, error) {
return s.SaveSourcePageWithBaseHash(ctx, requestPath, content, "")
}
func (s *Service) SaveSourcePageWithBaseHash(ctx context.Context, requestPath string, content string, baseHash string) (*SourcePage, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, err := s.syncSourceDirLocked(ctx); err != nil {
return nil, err
}
record, _, err := s.resolveRecord(ctx, requestPath)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
if baseHash != "" {
return nil, err
}
path, pathErr := normalizeWritableDocumentPath(requestPath)
if pathErr != nil {
return nil, pathErr
}
record = &DocumentRecord{Path: path}
}
if baseHash != "" && record.CurrentHash != baseHash {
currentContent, readErr := s.store.Read(record.CurrentHash)
if readErr != nil {
return nil, fmt.Errorf("read current content %s: %w", record.CurrentHash, readErr)
}
return nil, &DocumentConflictError{
Path: record.Path,
BaseHash: baseHash,
CurrentHash: record.CurrentHash,
CurrentContent: string(currentContent),
}
}
fullPath := filepath.Join(s.sourceDir, filepath.FromSlash(record.Path))
if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
return nil, fmt.Errorf("create document directory %s: %w", record.Path, err)
}
if err := os.WriteFile(fullPath, []byte(content), 0o644); err != nil {
return nil, fmt.Errorf("write document %s: %w", record.Path, err)
}
change, err := s.syncFile(ctx, fullPath)
if err != nil {
return nil, err
}
if change != nil && s.onChange != nil {
s.onChange(*change)
}
updated, _, err := s.resolveRecord(ctx, record.Path)
if err != nil {
return nil, err
}
return &SourcePage{
Path: updated.Path,
Title: updated.Title,
Tags: updated.Tags,
Content: content,
Hash: updated.CurrentHash,
}, nil
}
func (s *Service) resolveRecord(ctx context.Context, requestPath string) (*DocumentRecord, string, error) {
var (
record *DocumentRecord
normalized string
lastErr error
)
for _, candidate := range normalizeRequestPathCandidates(requestPath) {
found, err := s.repo.GetDocumentByPath(ctx, candidate)
if err == nil {
record = found
normalized = candidate
break
}
lastErr = err
}
if record == nil {
return nil, "", lastErr
}
return record, normalized, nil
}
func normalizeRequestPathCandidates(path string) []string {
path = strings.Trim(path, "/")
if path == "" {
return []string{"index.md", "getting-started.md"}
}
if !strings.HasSuffix(path, ".md") {
return []string{path + ".md", path + "/index.md"}
}
return []string{path}
}
func normalizeWritableDocumentPath(path string) (string, error) {
path = strings.Trim(path, "/")
if path == "" {
return "", fmt.Errorf("document path is required")
}
path = filepath.ToSlash(filepath.Clean(filepath.FromSlash(path)))
if path == "." || strings.HasPrefix(path, "../") || path == ".." || filepath.IsAbs(path) {
return "", fmt.Errorf("invalid document path")
}
if !strings.HasSuffix(strings.ToLower(path), ".md") {
path += ".md"
}
return path, nil
}
func (s *Service) syncFile(ctx context.Context, path string) (*DocumentChange, error) {
content, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read content file %s: %w", path, err)
}
relative, err := filepath.Rel(s.sourceDir, path)
if err != nil {
return nil, fmt.Errorf("compute relative path %s: %w", path, err)
}
relative = filepath.ToSlash(relative)
rendered, err := s.renderer.RenderPath(content, relative)
if err != nil {
return nil, fmt.Errorf("render content file %s: %w", path, err)
}
record, err := s.store.PutBytes(content)
if err != nil {
return nil, fmt.Errorf("store content file %s: %w", path, err)
}
existing, err := s.repo.GetDocumentByPath(ctx, relative)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
previousHash := ""
documentID := "doc:" + strings.TrimSuffix(relative, ".md")
if existing != nil {
previousHash = existing.CurrentHash
documentID = existing.ID
if existing.CurrentHash == record.Hash {
return nil, nil
}
}
if err := s.repo.PersistDocument(ctx, PersistDocumentInput{
ID: documentID,
Path: relative,
Title: rendered.Title,
Hash: record.Hash,
PreviousHash: previousHash,
Tags: rendered.Tags,
}); err != nil {
return nil, err
}
if err := s.repo.UpdateSearchContent(ctx, relative, string(content)); err != nil {
s.logger.Warn("index search content", "path", relative, "error", err)
}
s.logger.Debug("synced document", "path", relative, "hash", record.Hash)
return &DocumentChange{
Path: relative,
Title: rendered.Title,
Hash: record.Hash,
PreviousHash: previousHash,
}, nil
}