Add conflict resolution diff UI and app branding
This commit is contained in:
@@ -12,8 +12,8 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/tim/md-hub-secure/apps/server/internal/markdown"
|
||||
"github.com/tim/md-hub-secure/apps/server/internal/store"
|
||||
"github.com/tim/cairnquire/apps/server/internal/markdown"
|
||||
"github.com/tim/cairnquire/apps/server/internal/store"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
@@ -41,6 +41,25 @@ type Page struct {
|
||||
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,
|
||||
@@ -61,6 +80,11 @@ 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 {
|
||||
@@ -104,6 +128,116 @@ func (s *Service) LoadPage(ctx context.Context, requestPath string) (*Page, erro
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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
|
||||
@@ -119,26 +253,10 @@ func (s *Service) LoadPage(ctx context.Context, requestPath string) (*Page, erro
|
||||
lastErr = err
|
||||
}
|
||||
if record == nil {
|
||||
return nil, lastErr
|
||||
return nil, "", lastErr
|
||||
}
|
||||
|
||||
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
|
||||
return record, normalized, nil
|
||||
}
|
||||
|
||||
func normalizeRequestPathCandidates(path string) []string {
|
||||
@@ -158,7 +276,13 @@ func (s *Service) syncFile(ctx context.Context, path string) (*DocumentChange, e
|
||||
return nil, fmt.Errorf("read content file %s: %w", path, err)
|
||||
}
|
||||
|
||||
rendered, err := s.renderer.Render(content)
|
||||
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)
|
||||
}
|
||||
@@ -168,12 +292,6 @@ func (s *Service) syncFile(ctx context.Context, path string) (*DocumentChange, e
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user