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

View File

@@ -1,8 +1,18 @@
package docs
import (
"context"
"database/sql"
"errors"
"log/slog"
"os"
"path/filepath"
"reflect"
"testing"
"github.com/tim/cairnquire/apps/server/internal/database"
"github.com/tim/cairnquire/apps/server/internal/markdown"
"github.com/tim/cairnquire/apps/server/internal/store"
)
func TestNormalizeRequestPathCandidatesSupportsIndexFiles(t *testing.T) {
@@ -37,3 +47,95 @@ func TestNormalizeRequestPathCandidatesSupportsIndexFiles(t *testing.T) {
})
}
}
func TestSaveSourcePageWithBaseHashRejectsStaleEditor(t *testing.T) {
service, sourceDir := setupDocsTestService(t)
ctx := context.Background()
page, err := service.LoadSourcePage(ctx, "hello")
if err != nil {
t.Fatalf("LoadSourcePage() error = %v", err)
}
serverContent := "# Hello\n\nServer edit"
if err := os.WriteFile(filepath.Join(sourceDir, "hello.md"), []byte(serverContent), 0o644); err != nil {
t.Fatalf("write server edit: %v", err)
}
_, err = service.SaveSourcePageWithBaseHash(ctx, "hello", "# Hello\n\nClient edit", page.Hash)
var conflict *DocumentConflictError
if !errors.As(err, &conflict) {
t.Fatalf("SaveSourcePageWithBaseHash() error = %v, want DocumentConflictError", err)
}
if conflict.BaseHash != page.Hash {
t.Fatalf("conflict base hash = %q, want %q", conflict.BaseHash, page.Hash)
}
if conflict.CurrentContent != serverContent {
t.Fatalf("conflict current content = %q, want %q", conflict.CurrentContent, serverContent)
}
content, err := os.ReadFile(filepath.Join(sourceDir, "hello.md"))
if err != nil {
t.Fatalf("read hello.md: %v", err)
}
if string(content) != serverContent {
t.Fatalf("stale save overwrote content: %q", string(content))
}
}
func TestSaveSourcePageWithBaseHashAcceptsMatchingHash(t *testing.T) {
service, sourceDir := setupDocsTestService(t)
ctx := context.Background()
page, err := service.LoadSourcePage(ctx, "hello")
if err != nil {
t.Fatalf("LoadSourcePage() error = %v", err)
}
nextContent := "# Hello\n\nClient edit"
updated, err := service.SaveSourcePageWithBaseHash(ctx, "hello", nextContent, page.Hash)
if err != nil {
t.Fatalf("SaveSourcePageWithBaseHash() error = %v", err)
}
if updated.Hash == page.Hash {
t.Fatal("expected hash to change after save")
}
content, err := os.ReadFile(filepath.Join(sourceDir, "hello.md"))
if err != nil {
t.Fatalf("read hello.md: %v", err)
}
if string(content) != nextContent {
t.Fatalf("saved content = %q, want %q", string(content), nextContent)
}
}
func setupDocsTestService(t *testing.T) (*Service, string) {
t.Helper()
sourceDir := t.TempDir()
storeDir := t.TempDir()
if err := os.WriteFile(filepath.Join(sourceDir, "hello.md"), []byte("# Hello\n\nOriginal"), 0o644); err != nil {
t.Fatalf("create hello.md: %v", err)
}
dbPath := filepath.Join(t.TempDir(), "test.db")
db, err := sql.Open("libsql", "file:"+dbPath)
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { db.Close() })
ctx := context.Background()
if err := database.ApplyMigrations(ctx, db); err != nil {
t.Fatalf("apply migrations: %v", err)
}
contentStore, err := store.New(storeDir)
if err != nil {
t.Fatalf("create content store: %v", err)
}
repo := NewRepository(db)
return NewService(sourceDir, contentStore, markdown.NewRenderer(), repo, slog.Default()), sourceDir
}