package docs import ( "context" "database/sql" "errors" "fmt" "io/fs" "log/slog" "os" "path/filepath" "strings" "sync" "github.com/tim/cairnquire/apps/server/internal/markdown" "github.com/tim/cairnquire/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 } 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, 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() 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 { 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 } 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 { if !errors.Is(err, sql.ErrNoRows) { return nil, err } if baseHash != "" { return nil, err } path, pathErr := normalizeWritableDocumentPath(requestPath) if pathErr != nil { return nil, pathErr } record = &DocumentRecord{Path: path} } 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 lastErr error ) for _, candidate := range normalizeRequestPathCandidates(requestPath) { found, err := s.repo.GetDocumentByPath(ctx, candidate) if err == nil { record = found normalized = candidate break } lastErr = err } if record == nil { return nil, "", lastErr } return record, normalized, nil } func normalizeRequestPathCandidates(path string) []string { path = strings.Trim(path, "/") if path == "" { return []string{"index.md", "getting-started.md"} } if !strings.HasSuffix(path, ".md") { return []string{path + ".md", path + "/index.md"} } return []string{path} } func normalizeWritableDocumentPath(path string) (string, error) { path = strings.Trim(path, "/") if path == "" { return "", fmt.Errorf("document path is required") } path = filepath.ToSlash(filepath.Clean(filepath.FromSlash(path))) if path == "." || strings.HasPrefix(path, "../") || path == ".." || filepath.IsAbs(path) { return "", fmt.Errorf("invalid document path") } if !strings.HasSuffix(strings.ToLower(path), ".md") { path += ".md" } return path, nil } 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) } 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) } record, err := s.store.PutBytes(content) if err != nil { return nil, fmt.Errorf("store content file %s: %w", path, err) } 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 } if err := s.repo.UpdateSearchContent(ctx, relative, string(content)); err != nil { s.logger.Warn("index search content", "path", relative, "error", err) } s.logger.Debug("synced document", "path", relative, "hash", record.Hash) return &DocumentChange{ Path: relative, Title: rendered.Title, Hash: record.Hash, PreviousHash: previousHash, }, nil }