feature: move folders
This commit is contained in:
@@ -274,6 +274,207 @@ func (s *Service) MoveDocument(ctx context.Context, requestPath, destinationPath
|
||||
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
|
||||
@@ -450,6 +651,26 @@ 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 {
|
||||
|
||||
Reference in New Issue
Block a user