155 lines
3.4 KiB
Go
155 lines
3.4 KiB
Go
package docs
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"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
|
|
}
|
|
|
|
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) SyncSourceDir(ctx context.Context) error {
|
|
return 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
|
|
}
|
|
return s.syncFile(ctx, path)
|
|
})
|
|
}
|
|
|
|
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) error {
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return fmt.Errorf("read content file %s: %w", path, err)
|
|
}
|
|
|
|
rendered, err := s.renderer.Render(content)
|
|
if err != nil {
|
|
return fmt.Errorf("render content file %s: %w", path, err)
|
|
}
|
|
|
|
record, err := s.store.PutBytes(content)
|
|
if err != nil {
|
|
return fmt.Errorf("store content file %s: %w", path, err)
|
|
}
|
|
|
|
relative, err := filepath.Rel(s.sourceDir, path)
|
|
if err != nil {
|
|
return 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 err
|
|
}
|
|
|
|
previousHash := ""
|
|
documentID := "doc:" + strings.TrimSuffix(relative, ".md")
|
|
if existing != nil {
|
|
previousHash = existing.CurrentHash
|
|
documentID = existing.ID
|
|
if existing.CurrentHash == record.Hash {
|
|
return 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 err
|
|
}
|
|
|
|
s.logger.Debug("synced document", "path", relative, "hash", record.Hash)
|
|
return nil
|
|
}
|