Files
cairnquire/apps/server/internal/docs/service.go

199 lines
4.3 KiB
Go

package docs
import (
"context"
"database/sql"
"errors"
"fmt"
"io/fs"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"github.com/tim/md-hub-secure/apps/server/internal/markdown"
"github.com/tim/md-hub-secure/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
}
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()
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
}
normalized := normalizeRequestPath(requestPath)
record, err := s.repo.GetDocumentByPath(ctx, normalized)
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.Render(content)
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 normalizeRequestPath(path string) string {
path = strings.Trim(path, "/")
if path == "" {
return "getting-started.md"
}
if !strings.HasSuffix(path, ".md") {
path += ".md"
}
return path
}
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)
}
rendered, err := s.renderer.Render(content)
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)
}
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)
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
}
s.logger.Debug("synced document", "path", relative, "hash", record.Hash)
return &DocumentChange{
Path: relative,
Title: rendered.Title,
Hash: record.Hash,
PreviousHash: previousHash,
}, nil
}