Add conflict resolution diff UI and app branding

This commit is contained in:
2026-05-13 16:25:28 -04:00
parent 780ff3a02c
commit d359baa010
83 changed files with 4523 additions and 3735 deletions

View File

@@ -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