739 lines
20 KiB
Go
739 lines
20 KiB
Go
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 {
|
|
Type string `json:"type,omitempty"`
|
|
DocumentID string `json:"documentId"`
|
|
Path string `json:"path"`
|
|
OldPath string `json:"oldPath,omitempty"`
|
|
Title string `json:"title"`
|
|
Hash string `json:"hash"`
|
|
PreviousHash string `json:"previousHash,omitempty"`
|
|
}
|
|
|
|
type DocumentMoveConflictError struct {
|
|
OldPath string
|
|
NewPath string
|
|
Reason string
|
|
}
|
|
|
|
func (e *DocumentMoveConflictError) Error() string {
|
|
return fmt.Sprintf("cannot move %s to %s: %s", e.OldPath, e.NewPath, e.Reason)
|
|
}
|
|
|
|
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) ListArchivedDocuments(ctx context.Context) ([]DocumentRecord, error) {
|
|
return s.repo.ListArchivedDocuments(ctx)
|
|
}
|
|
|
|
func (s *Service) ArchiveDocument(ctx context.Context, path string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if _, err := s.syncSourceDirLocked(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
record, _, err := s.resolveRecord(ctx, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := s.repo.ArchiveDocument(ctx, record.Path); err != nil {
|
|
return err
|
|
}
|
|
|
|
if s.onChange != nil {
|
|
s.onChange(DocumentChange{
|
|
DocumentID: record.ID,
|
|
Path: record.Path,
|
|
Title: record.Title,
|
|
Hash: record.CurrentHash,
|
|
})
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) RestoreDocument(ctx context.Context, path string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if err := s.repo.RestoreDocument(ctx, path); err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := s.syncSourceDirLocked(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) MoveDocument(ctx context.Context, requestPath, destinationPath, expectedHash string) (*DocumentRecord, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if _, err := s.syncSourceDirLocked(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
oldPath, err := NormalizeDocumentPath(requestPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
newPath, err := NormalizeDocumentPath(destinationPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if oldPath == newPath {
|
|
return nil, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "destination is unchanged"}
|
|
}
|
|
|
|
record, err := s.repo.GetDocumentByPath(ctx, oldPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if expectedHash != "" && record.CurrentHash != expectedHash {
|
|
return nil, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "document changed since the page loaded"}
|
|
}
|
|
if _, err := s.repo.GetDocumentByPathIncludingArchived(ctx, newPath); err == nil {
|
|
return nil, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "destination already exists"}
|
|
} else if !errors.Is(err, sql.ErrNoRows) {
|
|
return nil, err
|
|
}
|
|
if aliased, err := s.repo.GetDocumentByPathOrAlias(ctx, newPath); err == nil && aliased.ID != record.ID {
|
|
return nil, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "destination is reserved by another document"}
|
|
} else if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return nil, err
|
|
}
|
|
|
|
oldFullPath := filepath.Join(s.sourceDir, filepath.FromSlash(oldPath))
|
|
newFullPath := filepath.Join(s.sourceDir, filepath.FromSlash(newPath))
|
|
if _, err := os.Stat(newFullPath); err == nil {
|
|
return nil, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "destination file already exists"}
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return nil, fmt.Errorf("check destination file: %w", err)
|
|
}
|
|
content, err := os.ReadFile(oldFullPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read source document: %w", err)
|
|
}
|
|
rendered, err := s.renderer.RenderPath(content, newPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("render moved document: %w", err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(newFullPath), 0o755); err != nil {
|
|
return nil, fmt.Errorf("create destination folder: %w", err)
|
|
}
|
|
if err := os.Rename(oldFullPath, newFullPath); err != nil {
|
|
return nil, fmt.Errorf("move document file: %w", err)
|
|
}
|
|
|
|
moveErr := s.repo.MoveDocument(ctx, MoveDocumentInput{
|
|
ID: record.ID,
|
|
OldPath: oldPath,
|
|
NewPath: newPath,
|
|
ExpectedHash: record.CurrentHash,
|
|
Title: rendered.Title,
|
|
})
|
|
if moveErr != nil {
|
|
if rollbackErr := os.Rename(newFullPath, oldFullPath); rollbackErr != nil {
|
|
return nil, fmt.Errorf("move database update failed: %v; filesystem rollback failed: %w", moveErr, rollbackErr)
|
|
}
|
|
return nil, moveErr
|
|
}
|
|
|
|
updated, err := s.repo.GetDocumentByID(ctx, record.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if s.onChange != nil {
|
|
s.onChange(DocumentChange{
|
|
Type: "move",
|
|
DocumentID: updated.ID,
|
|
Path: updated.Path,
|
|
OldPath: oldPath,
|
|
Title: updated.Title,
|
|
Hash: updated.CurrentHash,
|
|
})
|
|
}
|
|
return updated, nil
|
|
}
|
|
|
|
func (s *Service) MoveFolder(ctx context.Context, oldPrefix, newPrefix string) (int, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if _, err := s.syncSourceDirLocked(ctx); err != nil {
|
|
return 0, err
|
|
}
|
|
oldPath, err := NormalizeFolderPath(oldPrefix)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
newPath, err := NormalizeFolderPath(newPrefix)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if oldPath == newPath {
|
|
return 0, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "destination is unchanged"}
|
|
}
|
|
if strings.HasPrefix(newPath, oldPath+"/") {
|
|
return 0, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "cannot move a folder into itself"}
|
|
}
|
|
|
|
records, err := s.repo.ListDocuments(ctx)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
moving := make([]DocumentRecord, 0)
|
|
for _, record := range records {
|
|
if strings.HasPrefix(record.Path, oldPath+"/") {
|
|
moving = append(moving, record)
|
|
}
|
|
}
|
|
if len(moving) == 0 {
|
|
return 0, sql.ErrNoRows
|
|
}
|
|
|
|
newFullDir := filepath.Join(s.sourceDir, filepath.FromSlash(newPath))
|
|
if info, err := os.Stat(newFullDir); err == nil && info.IsDir() {
|
|
return 0, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "destination folder already exists"}
|
|
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return 0, fmt.Errorf("check destination folder: %w", err)
|
|
}
|
|
|
|
for _, record := range moving {
|
|
destination := newPath + strings.TrimPrefix(record.Path, oldPath)
|
|
if _, err := s.repo.GetDocumentByPathIncludingArchived(ctx, destination); err == nil {
|
|
return 0, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "destination already exists: " + destination}
|
|
} else if !errors.Is(err, sql.ErrNoRows) {
|
|
return 0, err
|
|
}
|
|
if _, err := os.Stat(filepath.Join(s.sourceDir, filepath.FromSlash(destination))); err == nil {
|
|
return 0, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "destination file already exists: " + destination}
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return 0, fmt.Errorf("check destination file: %w", err)
|
|
}
|
|
}
|
|
|
|
type movedPair struct {
|
|
record DocumentRecord
|
|
newPath string
|
|
newTitle string
|
|
oldFull string
|
|
newFull string
|
|
dbUpdated bool
|
|
}
|
|
moved := make([]movedPair, 0, len(moving))
|
|
rollback := func(moveErr error) (int, error) {
|
|
for i := len(moved) - 1; i >= 0; i-- {
|
|
pair := moved[i]
|
|
if pair.dbUpdated {
|
|
if err := s.repo.MoveDocument(ctx, MoveDocumentInput{
|
|
ID: pair.record.ID,
|
|
OldPath: pair.newPath,
|
|
NewPath: pair.record.Path,
|
|
ExpectedHash: pair.record.CurrentHash,
|
|
Title: pair.record.Title,
|
|
}); err != nil {
|
|
return 0, fmt.Errorf("move folder failed: %v; database rollback failed for %s: %w", moveErr, pair.newPath, err)
|
|
}
|
|
}
|
|
if err := os.Rename(pair.newFull, pair.oldFull); err != nil {
|
|
return 0, fmt.Errorf("move folder failed: %v; filesystem rollback failed for %s: %w", moveErr, pair.newFull, err)
|
|
}
|
|
}
|
|
return 0, moveErr
|
|
}
|
|
|
|
for _, record := range moving {
|
|
destination := newPath + strings.TrimPrefix(record.Path, oldPath)
|
|
oldFull := filepath.Join(s.sourceDir, filepath.FromSlash(record.Path))
|
|
newFull := filepath.Join(s.sourceDir, filepath.FromSlash(destination))
|
|
content, err := os.ReadFile(oldFull)
|
|
if err != nil {
|
|
return rollback(fmt.Errorf("read source document: %w", err))
|
|
}
|
|
rendered, err := s.renderer.RenderPath(content, destination)
|
|
if err != nil {
|
|
return rollback(fmt.Errorf("render moved document: %w", err))
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(newFull), 0o755); err != nil {
|
|
return rollback(fmt.Errorf("create destination folder: %w", err))
|
|
}
|
|
if err := os.Rename(oldFull, newFull); err != nil {
|
|
return rollback(fmt.Errorf("move document file: %w", err))
|
|
}
|
|
pair := movedPair{record: record, newPath: destination, newTitle: rendered.Title, oldFull: oldFull, newFull: newFull}
|
|
if err := s.repo.MoveDocument(ctx, MoveDocumentInput{
|
|
ID: record.ID,
|
|
OldPath: record.Path,
|
|
NewPath: destination,
|
|
ExpectedHash: record.CurrentHash,
|
|
Title: rendered.Title,
|
|
}); err != nil {
|
|
moved = append(moved, pair)
|
|
return rollback(err)
|
|
}
|
|
pair.dbUpdated = true
|
|
moved = append(moved, pair)
|
|
}
|
|
|
|
if err := s.repo.MoveFolderRelated(ctx, oldPath, newPath); err != nil {
|
|
return rollback(fmt.Errorf("move folder metadata: %w", err))
|
|
}
|
|
|
|
deepestOldDir := ""
|
|
for _, pair := range moved {
|
|
if dir := filepath.Dir(pair.oldFull); len(dir) > len(deepestOldDir) {
|
|
deepestOldDir = dir
|
|
}
|
|
}
|
|
if deepestOldDir != "" {
|
|
removeEmptyDirsUpTo(s.sourceDir, deepestOldDir)
|
|
}
|
|
|
|
if s.onChange != nil {
|
|
for _, pair := range moved {
|
|
s.onChange(DocumentChange{
|
|
Type: "move",
|
|
DocumentID: pair.record.ID,
|
|
Path: pair.newPath,
|
|
OldPath: pair.record.Path,
|
|
Title: pair.newTitle,
|
|
Hash: pair.record.CurrentHash,
|
|
})
|
|
}
|
|
}
|
|
return len(moved), nil
|
|
}
|
|
|
|
func (s *Service) ArchiveFolder(ctx context.Context, prefix string) (int, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if _, err := s.syncSourceDirLocked(ctx); err != nil {
|
|
return 0, err
|
|
}
|
|
folder, err := NormalizeFolderPath(prefix)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
records, err := s.repo.ListDocuments(ctx)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
archived := make([]DocumentRecord, 0)
|
|
for _, record := range records {
|
|
if !strings.HasPrefix(record.Path, folder+"/") {
|
|
continue
|
|
}
|
|
if err := s.repo.ArchiveDocument(ctx, record.Path); err != nil {
|
|
return len(archived), fmt.Errorf("archive document %s: %w", record.Path, err)
|
|
}
|
|
archived = append(archived, record)
|
|
}
|
|
if len(archived) == 0 {
|
|
return 0, sql.ErrNoRows
|
|
}
|
|
|
|
if s.onChange != nil {
|
|
for _, record := range archived {
|
|
s.onChange(DocumentChange{
|
|
DocumentID: record.ID,
|
|
Path: record.Path,
|
|
Title: record.Title,
|
|
Hash: record.CurrentHash,
|
|
})
|
|
}
|
|
}
|
|
return len(archived), nil
|
|
}
|
|
|
|
func removeEmptyDirsUpTo(root, dir string) {
|
|
for dir != root && strings.HasPrefix(dir, root) {
|
|
if err := os.Remove(dir); err != nil {
|
|
return
|
|
}
|
|
dir = filepath.Dir(dir)
|
|
}
|
|
}
|
|
|
|
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.GetDocumentByPathOrAlias(ctx, candidate)
|
|
if err == nil {
|
|
record = found
|
|
normalized = found.Path
|
|
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 NormalizeDocumentPath(path string) (string, error) {
|
|
return normalizeWritableDocumentPath(path)
|
|
}
|
|
|
|
func NormalizeFolderPath(path string) (string, error) {
|
|
path = strings.Trim(path, "/")
|
|
if path == "" {
|
|
return "", fmt.Errorf("folder path is required")
|
|
}
|
|
path = filepath.ToSlash(filepath.Clean(filepath.FromSlash(path)))
|
|
if path == "." || path == ".." || strings.HasPrefix(path, "../") || filepath.IsAbs(path) {
|
|
return "", fmt.Errorf("invalid folder path")
|
|
}
|
|
for _, segment := range strings.Split(path, "/") {
|
|
if segment == "" || segment == "." || segment == ".." {
|
|
return "", fmt.Errorf("invalid folder path")
|
|
}
|
|
if strings.HasSuffix(strings.ToLower(segment), ".md") {
|
|
return "", fmt.Errorf("folder names must not end with .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.GetDocumentByPathIncludingArchived(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)
|
|
}
|
|
|
|
if existing != nil && existing.ArchivedAt != nil {
|
|
return nil, nil
|
|
}
|
|
|
|
s.logger.Debug("synced document", "path", relative, "hash", record.Hash)
|
|
return &DocumentChange{
|
|
DocumentID: documentID,
|
|
Path: relative,
|
|
Title: rendered.Title,
|
|
Hash: record.Hash,
|
|
PreviousHash: previousHash,
|
|
}, nil
|
|
}
|