feature: move folders

This commit is contained in:
2026-07-27 12:37:27 -04:00
parent 03c06c0dd9
commit af7940a6d5
10 changed files with 839 additions and 38 deletions

View File

@@ -192,6 +192,34 @@ func (r *Repository) MoveDocument(ctx context.Context, input MoveDocumentInput)
return nil
}
func (r *Repository) MoveFolderRelated(ctx context.Context, oldPrefix, newPrefix string) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin folder metadata move: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, `
UPDATE permissions
SET resource_id = ? || substr(resource_id, length(?) + 1)
WHERE resource_type = 'folder' AND (resource_id = ? OR resource_id LIKE ? || '/%')
`, newPrefix, oldPrefix, oldPrefix, oldPrefix); err != nil {
return fmt.Errorf("move folder permissions: %w", err)
}
if _, err := tx.ExecContext(ctx, `
UPDATE OR IGNORE watchers
SET folder_path = ? || substr(folder_path, length(?) + 1)
WHERE folder_path IS NOT NULL AND folder_path != '' AND (folder_path = ? OR folder_path LIKE ? || '/%')
`, newPrefix, oldPrefix, oldPrefix, oldPrefix); err != nil {
return fmt.Errorf("move folder watchers: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit folder metadata move: %w", err)
}
return nil
}
func (r *Repository) GetDocumentByID(ctx context.Context, id string) (*DocumentRecord, error) {
var (
record DocumentRecord

View File

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

View File

@@ -361,6 +361,219 @@ func TestSyncSourceDirHandles100FilesUnderTarget(t *testing.T) {
}
}
func TestMoveFolderMovesAllDocumentsAndMetadata(t *testing.T) {
service, sourceDir := setupDocsTestService(t)
ctx := context.Background()
if err := os.MkdirAll(filepath.Join(sourceDir, "guides", "deep"), 0o755); err != nil {
t.Fatalf("create guides folder: %v", err)
}
if err := os.WriteFile(filepath.Join(sourceDir, "guides", "intro.md"), []byte("# Intro\n"), 0o644); err != nil {
t.Fatalf("create intro.md: %v", err)
}
if err := os.WriteFile(filepath.Join(sourceDir, "guides", "deep", "dive.md"), []byte("# Dive\n"), 0o644); err != nil {
t.Fatalf("create dive.md: %v", err)
}
if _, err := service.SyncSourceDir(ctx); err != nil {
t.Fatalf("SyncSourceDir() error = %v", err)
}
if _, err := service.repo.db.ExecContext(ctx, `
INSERT INTO users (id, email, display_name, created_at) VALUES ('user-folder', 'folder@example.com', 'Folder', ?)
`, time.Now().UTC().Format(time.RFC3339)); err != nil {
t.Fatalf("create user: %v", err)
}
if _, err := service.repo.db.ExecContext(ctx, `
INSERT INTO permissions (id, user_id, resource_type, resource_id, permission, created_at)
VALUES ('permission-folder', 'user-folder', 'folder', 'guides/deep', 'write', ?)
`, time.Now().UTC().Format(time.RFC3339)); err != nil {
t.Fatalf("create folder permission: %v", err)
}
if _, err := service.repo.db.ExecContext(ctx, `
INSERT INTO watchers (user_id, document_id, folder_path, created_at)
VALUES ('user-folder', NULL, 'guides', ?)
`, time.Now().UTC().Format(time.RFC3339)); err != nil {
t.Fatalf("create folder watcher: %v", err)
}
var changes []DocumentChange
service.OnChange(func(change DocumentChange) {
changes = append(changes, change)
})
moved, err := service.MoveFolder(ctx, "guides", "handbook")
if err != nil {
t.Fatalf("MoveFolder() error = %v", err)
}
if moved != 2 {
t.Fatalf("moved count = %d, want 2", moved)
}
for _, oldPath := range []string{"guides/intro.md", "guides/deep/dive.md"} {
if _, err := os.Stat(filepath.Join(sourceDir, filepath.FromSlash(oldPath))); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("old file %s still exists or stat failed: %v", oldPath, err)
}
}
for oldPath, newPath := range map[string]string{"guides/intro.md": "handbook/intro.md", "guides/deep/dive.md": "handbook/deep/dive.md"} {
if _, err := os.Stat(filepath.Join(sourceDir, filepath.FromSlash(newPath))); err != nil {
t.Fatalf("new file %s missing: %v", newPath, err)
}
record, err := service.repo.GetDocumentByPath(ctx, newPath)
if err != nil {
t.Fatalf("get moved record %s: %v", newPath, err)
}
alias, err := service.repo.GetDocumentByPathOrAlias(ctx, oldPath)
if err != nil {
t.Fatalf("resolve alias for %s: %v", oldPath, err)
}
if alias.ID != record.ID {
t.Fatalf("alias for %s resolved to %q, want %q", oldPath, alias.ID, record.ID)
}
}
if _, err := os.Stat(filepath.Join(sourceDir, "guides")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("empty source folder not removed: %v", err)
}
var permissionFolder string
if err := service.repo.db.QueryRowContext(ctx, `SELECT resource_id FROM permissions WHERE id = 'permission-folder'`).Scan(&permissionFolder); err != nil {
t.Fatalf("get folder permission: %v", err)
}
if permissionFolder != "handbook/deep" {
t.Fatalf("permission folder = %q, want handbook/deep", permissionFolder)
}
var watcherFolder string
if err := service.repo.db.QueryRowContext(ctx, `SELECT folder_path FROM watchers WHERE user_id = 'user-folder'`).Scan(&watcherFolder); err != nil {
t.Fatalf("get watcher: %v", err)
}
if watcherFolder != "handbook" {
t.Fatalf("watcher folder = %q, want handbook", watcherFolder)
}
if len(changes) != 2 {
t.Fatalf("move changes = %d, want 2", len(changes))
}
for _, change := range changes {
if change.Type != "move" {
t.Fatalf("change type = %q, want move", change.Type)
}
}
}
func TestMoveFolderRejectsInvalidDestinations(t *testing.T) {
service, sourceDir := setupDocsTestService(t)
ctx := context.Background()
if err := os.MkdirAll(filepath.Join(sourceDir, "guides"), 0o755); err != nil {
t.Fatalf("create guides folder: %v", err)
}
if err := os.WriteFile(filepath.Join(sourceDir, "guides", "intro.md"), []byte("# Intro\n"), 0o644); err != nil {
t.Fatalf("create intro.md: %v", err)
}
if _, err := service.SyncSourceDir(ctx); err != nil {
t.Fatalf("SyncSourceDir() error = %v", err)
}
var conflict *DocumentMoveConflictError
if _, err := service.MoveFolder(ctx, "guides", "guides"); !errors.As(err, &conflict) {
t.Fatalf("unchanged MoveFolder() error = %v, want DocumentMoveConflictError", err)
}
if _, err := service.MoveFolder(ctx, "guides", "guides/nested"); !errors.As(err, &conflict) {
t.Fatalf("self-nested MoveFolder() error = %v, want DocumentMoveConflictError", err)
}
if _, err := service.MoveFolder(ctx, "guides", "../outside"); err == nil {
t.Fatal("expected traversal destination to be rejected")
}
if _, err := service.MoveFolder(ctx, "missing", "handbook"); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("missing folder MoveFolder() error = %v, want sql.ErrNoRows", err)
}
if _, err := os.Stat(filepath.Join(sourceDir, "guides", "intro.md")); err != nil {
t.Fatalf("source file changed after rejected moves: %v", err)
}
}
func TestArchiveFolderArchivesAllDocuments(t *testing.T) {
service, sourceDir := setupDocsTestService(t)
ctx := context.Background()
if err := os.MkdirAll(filepath.Join(sourceDir, "guides"), 0o755); err != nil {
t.Fatalf("create guides folder: %v", err)
}
if err := os.WriteFile(filepath.Join(sourceDir, "guides", "intro.md"), []byte("# Intro\n"), 0o644); err != nil {
t.Fatalf("create intro.md: %v", err)
}
if err := os.WriteFile(filepath.Join(sourceDir, "guides", "dive.md"), []byte("# Dive\n"), 0o644); err != nil {
t.Fatalf("create dive.md: %v", err)
}
if _, err := service.SyncSourceDir(ctx); err != nil {
t.Fatalf("SyncSourceDir() error = %v", err)
}
archived, err := service.ArchiveFolder(ctx, "guides")
if err != nil {
t.Fatalf("ArchiveFolder() error = %v", err)
}
if archived != 2 {
t.Fatalf("archived count = %d, want 2", archived)
}
records, err := service.repo.ListDocuments(ctx)
if err != nil {
t.Fatalf("ListDocuments() error = %v", err)
}
for _, record := range records {
if record.Path == "guides/intro.md" || record.Path == "guides/dive.md" {
t.Fatalf("document %s still listed after archive", record.Path)
}
}
archivedRecords, err := service.repo.ListArchivedDocuments(ctx)
if err != nil {
t.Fatalf("ListArchivedDocuments() error = %v", err)
}
if len(archivedRecords) != 2 {
t.Fatalf("archived records = %d, want 2", len(archivedRecords))
}
if _, err := os.Stat(filepath.Join(sourceDir, "guides", "intro.md")); err != nil {
t.Fatalf("archived file removed from disk: %v", err)
}
if _, err := service.ArchiveFolder(ctx, "guides"); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("re-archive error = %v, want sql.ErrNoRows", err)
}
}
func TestNormalizeFolderPathValidation(t *testing.T) {
tests := []struct {
name string
path string
want string
wantErr bool
}{
{name: "simple", path: "guides", want: "guides"},
{name: "nested with slashes", path: "/guides/deep/", want: "guides/deep"},
{name: "empty", path: "", wantErr: true},
{name: "traversal", path: "../outside", wantErr: true},
{name: "leading slash", path: "/etc", want: "etc"},
{name: "markdown suffix", path: "guides.md", wantErr: true},
{name: "nested markdown segment", path: "guides/notes.md/deep", wantErr: true},
{name: "cleaned dot segment", path: "guides/./deep", want: "guides/deep"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := NormalizeFolderPath(tt.path)
if tt.wantErr {
if err == nil {
t.Fatalf("NormalizeFolderPath(%q) = %q, want error", tt.path, got)
}
return
}
if err != nil {
t.Fatalf("NormalizeFolderPath(%q) error = %v", tt.path, err)
}
if got != tt.want {
t.Fatalf("NormalizeFolderPath(%q) = %q, want %q", tt.path, got, tt.want)
}
})
}
}
func setupDocsTestService(t *testing.T) (*Service, string) {
t.Helper()