Fix: persist folder drag and drop moves
This commit is contained in:
@@ -45,6 +45,7 @@ var (
|
||||
ErrAdminNotConfigured = errors.New("bootstrap admin step has not been completed")
|
||||
ErrProjectNotFound = errors.New("project not found")
|
||||
ErrProjectFolderNotFound = errors.New("project folder not found")
|
||||
ErrInvalidProjectFolderMove = errors.New("invalid project folder move")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
@@ -203,6 +204,12 @@ type RenameProjectFolderInput struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
type MoveProjectFolderInput struct {
|
||||
ProjectID string
|
||||
FolderID string
|
||||
ParentFolderID string
|
||||
}
|
||||
|
||||
type CreateProjectFolderResult struct {
|
||||
ProjectID string `json:"projectId"`
|
||||
CreatedFolder ProjectHierarchyFolderRecord `json:"createdFolder"`
|
||||
@@ -222,6 +229,13 @@ type RenameProjectFolderResult struct {
|
||||
Folders []ProjectHierarchyFolderRecord `json:"folders"`
|
||||
}
|
||||
|
||||
type MoveProjectFolderResult struct {
|
||||
ProjectID string `json:"projectId"`
|
||||
PreviousFolderID string `json:"previousFolderId"`
|
||||
MovedFolder ProjectHierarchyFolderRecord `json:"movedFolder"`
|
||||
Folders []ProjectHierarchyFolderRecord `json:"folders"`
|
||||
}
|
||||
|
||||
type projectHierarchyFolderRow struct {
|
||||
Path string
|
||||
ParentPath string
|
||||
@@ -944,6 +958,14 @@ func (service *Service) RenameProjectTreeFolder(ctx context.Context, input Renam
|
||||
return service.renameProjectHierarchyFolder(ctx, input, projectTreeRootPath, service.renameProjectTreeFolderOnDisk)
|
||||
}
|
||||
|
||||
func (service *Service) MoveProjectFolder(ctx context.Context, input MoveProjectFolderInput) (MoveProjectFolderResult, error) {
|
||||
return service.moveProjectHierarchyFolder(ctx, input, projectHierarchyRootPath, service.moveProjectHierarchyFolderOnDisk)
|
||||
}
|
||||
|
||||
func (service *Service) MoveProjectTreeFolder(ctx context.Context, input MoveProjectFolderInput) (MoveProjectFolderResult, error) {
|
||||
return service.moveProjectHierarchyFolder(ctx, input, projectTreeRootPath, service.moveProjectTreeFolderOnDisk)
|
||||
}
|
||||
|
||||
func (service *Service) createProjectHierarchyFolder(
|
||||
ctx context.Context,
|
||||
input CreateProjectFolderInput,
|
||||
@@ -1059,6 +1081,50 @@ func (service *Service) renameProjectHierarchyFolder(
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (service *Service) moveProjectHierarchyFolder(
|
||||
ctx context.Context,
|
||||
input MoveProjectFolderInput,
|
||||
rootPath func(projectSlug string) string,
|
||||
moveOnDisk func(projectSlug, folderID, parentFolderID string) (string, string, error),
|
||||
) (MoveProjectFolderResult, error) {
|
||||
project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
|
||||
if err != nil {
|
||||
return MoveProjectFolderResult{}, err
|
||||
}
|
||||
|
||||
previousFolderID, movedFolderID, err := moveOnDisk(project.Slug, input.FolderID, input.ParentFolderID)
|
||||
if err != nil {
|
||||
return MoveProjectFolderResult{}, err
|
||||
}
|
||||
|
||||
if err := posixproj.NewProjector(service.db, service.posixRoot).Rebuild(ctx); err != nil {
|
||||
return MoveProjectFolderResult{}, fmt.Errorf("rebuild POSIX projection: %w", err)
|
||||
}
|
||||
|
||||
folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
|
||||
if err != nil {
|
||||
return MoveProjectFolderResult{}, err
|
||||
}
|
||||
|
||||
movedFolder, found := findProjectHierarchyFolder(folders, movedFolderID)
|
||||
if !found {
|
||||
return MoveProjectFolderResult{}, fmt.Errorf("moved project folder missing from projection")
|
||||
}
|
||||
|
||||
if previousFolderID != movedFolderID {
|
||||
if _, found := findProjectHierarchyFolder(folders, previousFolderID); found {
|
||||
return MoveProjectFolderResult{}, fmt.Errorf("previous project folder path still present in projection")
|
||||
}
|
||||
}
|
||||
|
||||
return MoveProjectFolderResult{
|
||||
ProjectID: project.ID,
|
||||
PreviousFolderID: previousFolderID,
|
||||
MovedFolder: movedFolder,
|
||||
Folders: folders,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (service *Service) listWorkspaces(ctx context.Context) ([]WorkspaceRecord, error) {
|
||||
rows, err := service.db.Pool.Query(ctx, `
|
||||
SELECT id::text, organization_id::text, name, slug, kind::text, department_id::text, team_id::text, project_id::text
|
||||
@@ -1358,6 +1424,14 @@ func (service *Service) renameProjectTreeFolderOnDisk(projectSlug, folderID, nam
|
||||
return service.renameProjectFolderOnDisk(projectSlug, folderID, name, projectTreeRootPath)
|
||||
}
|
||||
|
||||
func (service *Service) moveProjectHierarchyFolderOnDisk(projectSlug, folderID, parentFolderID string) (string, string, error) {
|
||||
return service.moveProjectFolderOnDisk(projectSlug, folderID, parentFolderID, projectHierarchyRootPath)
|
||||
}
|
||||
|
||||
func (service *Service) moveProjectTreeFolderOnDisk(projectSlug, folderID, parentFolderID string) (string, string, error) {
|
||||
return service.moveProjectFolderOnDisk(projectSlug, folderID, parentFolderID, projectTreeRootPath)
|
||||
}
|
||||
|
||||
func (service *Service) createProjectFolderOnDisk(
|
||||
projectSlug, parentFolderID, name string,
|
||||
rootPathBuilder func(projectSlug string) string,
|
||||
@@ -1550,6 +1624,136 @@ func (service *Service) renameProjectFolderOnDisk(
|
||||
return folderProjectionPath, renamedProjectionPath, nil
|
||||
}
|
||||
|
||||
func (service *Service) moveProjectFolderOnDisk(
|
||||
projectSlug, folderID, parentFolderID string,
|
||||
rootPathBuilder func(projectSlug string) string,
|
||||
) (string, string, error) {
|
||||
posixRoot := strings.TrimSpace(service.posixRoot)
|
||||
if posixRoot == "" {
|
||||
return "", "", fmt.Errorf("POSIX root is not configured")
|
||||
}
|
||||
|
||||
rootProjectionPath := rootPathBuilder(projectSlug)
|
||||
folderProjectionPath := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+strings.TrimSpace(folderID))), "/")
|
||||
if folderProjectionPath == "." || folderProjectionPath == rootProjectionPath || !strings.HasPrefix(folderProjectionPath, rootProjectionPath+"/") {
|
||||
return "", "", ErrProjectFolderNotFound
|
||||
}
|
||||
|
||||
folderDir := filepath.Join(posixRoot, filepath.FromSlash(folderProjectionPath))
|
||||
info, err := os.Stat(folderDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", "", ErrProjectFolderNotFound
|
||||
}
|
||||
return "", "", fmt.Errorf("stat project folder: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "", "", ErrProjectFolderNotFound
|
||||
}
|
||||
|
||||
trimmedParentFolderID := strings.TrimSpace(parentFolderID)
|
||||
parentChildrenProjectionPath := rootProjectionPath
|
||||
parentDir := filepath.Join(posixRoot, filepath.FromSlash(rootProjectionPath))
|
||||
|
||||
if trimmedParentFolderID != "" {
|
||||
parentProjectionPath := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+trimmedParentFolderID)), "/")
|
||||
if parentProjectionPath == "." || parentProjectionPath == rootProjectionPath || !strings.HasPrefix(parentProjectionPath, rootProjectionPath+"/") {
|
||||
return "", "", ErrProjectFolderNotFound
|
||||
}
|
||||
if parentProjectionPath == folderProjectionPath || strings.HasPrefix(parentProjectionPath, folderProjectionPath+"/children/") {
|
||||
return "", "", ErrInvalidProjectFolderMove
|
||||
}
|
||||
|
||||
parentChildrenProjectionPath = filepath.ToSlash(filepath.Join(parentProjectionPath, "children"))
|
||||
parentDir = filepath.Join(posixRoot, filepath.FromSlash(parentChildrenProjectionPath))
|
||||
}
|
||||
|
||||
parentInfo, err := os.Stat(parentDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", "", ErrProjectFolderNotFound
|
||||
}
|
||||
return "", "", fmt.Errorf("stat project folder parent: %w", err)
|
||||
}
|
||||
if !parentInfo.IsDir() {
|
||||
return "", "", ErrProjectFolderNotFound
|
||||
}
|
||||
|
||||
currentParentDir := filepath.Dir(folderDir)
|
||||
if samePath(currentParentDir, parentDir) {
|
||||
return folderProjectionPath, folderProjectionPath, nil
|
||||
}
|
||||
|
||||
folderPayload := readJSONFileMap(filepath.Join(folderDir, "folder.json"))
|
||||
folderName, _ := folderPayload["name"].(string)
|
||||
if strings.TrimSpace(folderName) == "" {
|
||||
folderName = fallbackFolderLabel(folderProjectionPath)
|
||||
}
|
||||
|
||||
currentBase := filepath.Base(folderDir)
|
||||
baseSlug := strings.TrimPrefix(currentBase, "folder-")
|
||||
if strings.TrimSpace(baseSlug) == "" {
|
||||
baseSlug = normalizePOSIXSlug(folderName)
|
||||
}
|
||||
|
||||
folderSlug := baseSlug
|
||||
folderDirName := slugDir("folder", folderSlug)
|
||||
destinationDir := filepath.Join(parentDir, folderDirName)
|
||||
for attempt := 2; ; attempt += 1 {
|
||||
if _, err := os.Stat(destinationDir); os.IsNotExist(err) {
|
||||
break
|
||||
} else if err != nil {
|
||||
return "", "", fmt.Errorf("stat candidate moved project folder: %w", err)
|
||||
}
|
||||
|
||||
folderSlug = fmt.Sprintf("%s-%d", baseSlug, attempt)
|
||||
folderDirName = slugDir("folder", folderSlug)
|
||||
destinationDir = filepath.Join(parentDir, folderDirName)
|
||||
}
|
||||
|
||||
if err := os.Rename(folderDir, destinationDir); err != nil {
|
||||
return "", "", fmt.Errorf("move project folder: %w", err)
|
||||
}
|
||||
|
||||
folderPayload["name"] = folderName
|
||||
folderPayload["slug"] = folderSlug
|
||||
folderPayload["type"] = "folder"
|
||||
if err := writeJSONFile(filepath.Join(destinationDir, "folder.json"), folderPayload); err != nil {
|
||||
return "", "", fmt.Errorf("write moved project folder.json: %w", err)
|
||||
}
|
||||
|
||||
movedProjectionPath := filepath.ToSlash(filepath.Join(parentChildrenProjectionPath, folderDirName))
|
||||
return folderProjectionPath, movedProjectionPath, nil
|
||||
}
|
||||
|
||||
func samePath(left, right string) bool {
|
||||
cleanLeft := filepath.Clean(left)
|
||||
cleanRight := filepath.Clean(right)
|
||||
if cleanLeft == cleanRight {
|
||||
return true
|
||||
}
|
||||
leftInfo, leftErr := os.Stat(cleanLeft)
|
||||
rightInfo, rightErr := os.Stat(cleanRight)
|
||||
if leftErr == nil && rightErr == nil {
|
||||
return os.SameFile(leftInfo, rightInfo)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func readJSONFileMap(path string) map[string]any {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(data, &payload); err != nil || payload == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
func buildProjectHierarchyFolderTree(rows []projectHierarchyFolderRow, rootParentPath string) []ProjectHierarchyFolderRecord {
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user