Feat: improve sidebar tree usability
This commit is contained in:
@@ -50,7 +50,9 @@ var (
|
||||
ErrBootstrapStructureMissing = errors.New("bootstrap structure is incomplete")
|
||||
ErrProjectNotFound = errors.New("project not found")
|
||||
ErrProjectFolderNotFound = errors.New("project folder not found")
|
||||
ErrProjectItemNotFound = errors.New("project item not found")
|
||||
ErrInvalidProjectFolderMove = errors.New("invalid project folder move")
|
||||
ErrInvalidProjectItemMove = errors.New("invalid project item move")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
@@ -195,6 +197,15 @@ type ProjectHierarchyFolderRecord struct {
|
||||
Children []ProjectHierarchyFolderRecord `json:"children"`
|
||||
}
|
||||
|
||||
type ProjectTreeNodeRecord struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
Label string `json:"label"`
|
||||
Kind string `json:"kind"`
|
||||
ItemType string `json:"itemType,omitempty"`
|
||||
Children []ProjectTreeNodeRecord `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
type CreateProjectFolderInput struct {
|
||||
ProjectID string
|
||||
ParentFolderPath string
|
||||
@@ -221,6 +232,27 @@ type MoveProjectFolderInput struct {
|
||||
TargetIndex int
|
||||
}
|
||||
|
||||
type CreateProjectItemInput struct {
|
||||
ProjectID string
|
||||
ParentFolderPath string
|
||||
Name string
|
||||
ItemType string
|
||||
}
|
||||
|
||||
type DeleteProjectItemInput struct {
|
||||
ProjectID string
|
||||
ItemPath string
|
||||
}
|
||||
|
||||
type MoveProjectItemInput struct {
|
||||
ProjectID string
|
||||
ItemPath string
|
||||
ItemStableID string
|
||||
ParentFolderPath string
|
||||
ParentStableID string
|
||||
TargetIndex int
|
||||
}
|
||||
|
||||
type CreateProjectFolderResult struct {
|
||||
ProjectID string `json:"projectId"`
|
||||
CreatedFolder ProjectHierarchyFolderRecord `json:"createdFolder"`
|
||||
@@ -250,6 +282,27 @@ type MoveProjectFolderResult struct {
|
||||
Folders []ProjectHierarchyFolderRecord `json:"folders"`
|
||||
}
|
||||
|
||||
type CreateProjectItemResult struct {
|
||||
ProjectID string `json:"projectId"`
|
||||
CreatedItem ProjectTreeNodeRecord `json:"createdItem"`
|
||||
Nodes []ProjectTreeNodeRecord `json:"nodes"`
|
||||
}
|
||||
|
||||
type DeleteProjectItemResult struct {
|
||||
ProjectID string `json:"projectId"`
|
||||
DeletedItemStableID string `json:"deletedItemId"`
|
||||
DeletedItemPath string `json:"deletedItemPath"`
|
||||
Nodes []ProjectTreeNodeRecord `json:"nodes"`
|
||||
}
|
||||
|
||||
type MoveProjectItemResult struct {
|
||||
ProjectID string `json:"projectId"`
|
||||
PreviousItemStableID string `json:"previousItemId"`
|
||||
PreviousItemPath string `json:"previousItemPath"`
|
||||
MovedItem ProjectTreeNodeRecord `json:"movedItem"`
|
||||
Nodes []ProjectTreeNodeRecord `json:"nodes"`
|
||||
}
|
||||
|
||||
type projectHierarchyFolderRow struct {
|
||||
ID string
|
||||
Path string
|
||||
@@ -257,6 +310,15 @@ type projectHierarchyFolderRow struct {
|
||||
Label string
|
||||
}
|
||||
|
||||
type projectTreeNodeRow struct {
|
||||
ID string
|
||||
Path string
|
||||
ParentPath string
|
||||
Label string
|
||||
Kind string
|
||||
ItemType string
|
||||
}
|
||||
|
||||
func NewService(db *database.DB, posixRoot string) *Service {
|
||||
return &Service{db: db, posixRoot: strings.TrimSpace(posixRoot)}
|
||||
}
|
||||
@@ -815,6 +877,10 @@ func (service *Service) GetProjectTreeFolders(ctx context.Context, projectID str
|
||||
return service.getProjectHierarchyFoldersByRootPath(ctx, projectID, projectTreeRootPath)
|
||||
}
|
||||
|
||||
func (service *Service) GetProjectTreeNodes(ctx context.Context, projectID string) ([]ProjectTreeNodeRecord, error) {
|
||||
return service.getProjectTreeNodesByRootPath(ctx, projectID, projectTreeRootPath)
|
||||
}
|
||||
|
||||
func (service *Service) getProjectHierarchyFoldersByRootPath(
|
||||
ctx context.Context,
|
||||
projectID string,
|
||||
@@ -870,6 +936,64 @@ func (service *Service) getProjectHierarchyFoldersByRootPath(
|
||||
return applyProjectHierarchyFolderOrdering(folders, folderOrder), nil
|
||||
}
|
||||
|
||||
func (service *Service) getProjectTreeNodesByRootPath(
|
||||
ctx context.Context,
|
||||
projectID string,
|
||||
rootPath func(projectSlug string) string,
|
||||
) ([]ProjectTreeNodeRecord, error) {
|
||||
project, err := service.loadProjectByID(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rootParentPath := rootPath(project.Slug)
|
||||
|
||||
rows, err := service.db.Pool.Query(ctx, `
|
||||
SELECT
|
||||
COALESCE(node_meta.resource_id, ''),
|
||||
directories.path,
|
||||
COALESCE(directories.parent_path, ''),
|
||||
COALESCE(node_meta.resource_name, directories.resource_name, ''),
|
||||
directories.logical_type,
|
||||
COALESCE(node_meta.content_json->>'type', directories.content_json->>'type', '')
|
||||
FROM posix_nodes AS directories
|
||||
LEFT JOIN posix_nodes AS node_meta
|
||||
ON node_meta.path = directories.path || CASE
|
||||
WHEN directories.logical_type = 'hierarchy_folder' THEN '/folder.json'
|
||||
WHEN directories.logical_type = 'item' THEN '/item.json'
|
||||
ELSE ''
|
||||
END
|
||||
AND node_meta.node_kind = 'file'::posix_node_kind
|
||||
WHERE directories.node_kind = 'directory'::posix_node_kind
|
||||
AND directories.logical_type IN ('hierarchy_folder', 'item')
|
||||
AND directories.project_slug = $1
|
||||
AND directories.path LIKE $2
|
||||
ORDER BY directories.depth ASC, directories.path ASC;
|
||||
`, project.Slug, rootParentPath+"/%")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var nodeRows []projectTreeNodeRow
|
||||
for rows.Next() {
|
||||
var row projectTreeNodeRow
|
||||
if err := rows.Scan(&row.ID, &row.Path, &row.ParentPath, &row.Label, &row.Kind, &row.ItemType); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodeRows = append(nodeRows, row)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nodes := buildProjectTreeNodeTree(nodeRows, rootParentPath)
|
||||
folderOrder := service.readProjectFolderOrder(project.Slug, rootParentPath)
|
||||
|
||||
return applyProjectTreeNodeOrdering(nodes, folderOrder), nil
|
||||
}
|
||||
|
||||
func (service *Service) CreateProjectFolder(ctx context.Context, input CreateProjectFolderInput) (CreateProjectFolderResult, error) {
|
||||
return service.createProjectHierarchyFolder(ctx, input, projectHierarchyRootPath, service.createProjectHierarchyFolderOnDisk)
|
||||
}
|
||||
@@ -902,6 +1026,18 @@ func (service *Service) MoveProjectTreeFolder(ctx context.Context, input MovePro
|
||||
return service.moveProjectHierarchyFolder(ctx, input, projectTreeRootPath, service.moveProjectTreeFolderOnDisk)
|
||||
}
|
||||
|
||||
func (service *Service) CreateProjectTreeItem(ctx context.Context, input CreateProjectItemInput) (CreateProjectItemResult, error) {
|
||||
return service.createProjectTreeItem(ctx, input, projectTreeRootPath)
|
||||
}
|
||||
|
||||
func (service *Service) DeleteProjectTreeItem(ctx context.Context, input DeleteProjectItemInput) (DeleteProjectItemResult, error) {
|
||||
return service.deleteProjectTreeItem(ctx, input, projectTreeRootPath)
|
||||
}
|
||||
|
||||
func (service *Service) MoveProjectTreeItem(ctx context.Context, input MoveProjectItemInput) (MoveProjectItemResult, error) {
|
||||
return service.moveProjectTreeItem(ctx, input, projectTreeRootPath)
|
||||
}
|
||||
|
||||
func (service *Service) createProjectHierarchyFolder(
|
||||
ctx context.Context,
|
||||
input CreateProjectFolderInput,
|
||||
@@ -1174,6 +1310,226 @@ func (service *Service) moveProjectHierarchyFolder(
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (service *Service) createProjectTreeItem(
|
||||
ctx context.Context,
|
||||
input CreateProjectItemInput,
|
||||
rootPath func(projectSlug string) string,
|
||||
) (CreateProjectItemResult, error) {
|
||||
project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
|
||||
if err != nil {
|
||||
return CreateProjectItemResult{}, err
|
||||
}
|
||||
|
||||
currentNodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
|
||||
if err != nil {
|
||||
return CreateProjectItemResult{}, err
|
||||
}
|
||||
|
||||
parentOrderID := ""
|
||||
trimmedParentFolderPath := strings.TrimSpace(input.ParentFolderPath)
|
||||
if trimmedParentFolderPath != "" {
|
||||
parentFolder, found := findProjectTreeFolderByPath(currentNodes, trimmedParentFolderPath)
|
||||
if !found {
|
||||
return CreateProjectItemResult{}, ErrProjectFolderNotFound
|
||||
}
|
||||
parentOrderID = parentFolder.ID
|
||||
}
|
||||
|
||||
createdPath, err := service.createProjectTreeItemOnDisk(project.Slug, trimmedParentFolderPath, input.Name, input.ItemType)
|
||||
if err != nil {
|
||||
return CreateProjectItemResult{}, err
|
||||
}
|
||||
|
||||
if err := service.rebuildProjection(ctx); err != nil {
|
||||
return CreateProjectItemResult{}, err
|
||||
}
|
||||
|
||||
nodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
|
||||
if err != nil {
|
||||
return CreateProjectItemResult{}, err
|
||||
}
|
||||
|
||||
createdItem, ok := findProjectTreeNodeByPath(nodes, createdPath)
|
||||
if !ok || createdItem.Kind != "item" {
|
||||
return CreateProjectItemResult{}, fmt.Errorf("created project item missing from projection")
|
||||
}
|
||||
|
||||
folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
|
||||
seedProjectTreeOrderParent(folderOrder, currentNodes, parentOrderID)
|
||||
insertFolderOrder(folderOrder, parentOrderID, createdItem.ID, len(folderOrderChildren(folderOrder, parentOrderID)))
|
||||
if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
|
||||
return CreateProjectItemResult{}, err
|
||||
}
|
||||
|
||||
nodes, err = service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
|
||||
if err != nil {
|
||||
return CreateProjectItemResult{}, err
|
||||
}
|
||||
|
||||
createdItem, ok = findProjectTreeNodeByPath(nodes, createdPath)
|
||||
if !ok || createdItem.Kind != "item" {
|
||||
return CreateProjectItemResult{}, fmt.Errorf("created project item missing from ordered projection")
|
||||
}
|
||||
|
||||
return CreateProjectItemResult{
|
||||
ProjectID: project.ID,
|
||||
CreatedItem: createdItem,
|
||||
Nodes: nodes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (service *Service) deleteProjectTreeItem(
|
||||
ctx context.Context,
|
||||
input DeleteProjectItemInput,
|
||||
rootPath func(projectSlug string) string,
|
||||
) (DeleteProjectItemResult, error) {
|
||||
project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
|
||||
if err != nil {
|
||||
return DeleteProjectItemResult{}, err
|
||||
}
|
||||
|
||||
currentNodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
|
||||
if err != nil {
|
||||
return DeleteProjectItemResult{}, err
|
||||
}
|
||||
|
||||
deletedItem, found := findProjectTreeNodeByPath(currentNodes, strings.TrimSpace(input.ItemPath))
|
||||
if !found || deletedItem.Kind != "item" {
|
||||
return DeleteProjectItemResult{}, ErrProjectItemNotFound
|
||||
}
|
||||
|
||||
deletedItemPath, err := service.deleteProjectTreeItemOnDisk(project.Slug, input.ItemPath)
|
||||
if err != nil {
|
||||
return DeleteProjectItemResult{}, err
|
||||
}
|
||||
|
||||
if err := service.rebuildProjection(ctx); err != nil {
|
||||
return DeleteProjectItemResult{}, err
|
||||
}
|
||||
|
||||
nodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
|
||||
if err != nil {
|
||||
return DeleteProjectItemResult{}, err
|
||||
}
|
||||
|
||||
if _, found := findProjectTreeNodeByPath(nodes, deletedItemPath); found {
|
||||
return DeleteProjectItemResult{}, fmt.Errorf("deleted project item still present in projection")
|
||||
}
|
||||
|
||||
folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
|
||||
removeFolderOrderReference(folderOrder, deletedItem.ID)
|
||||
if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
|
||||
return DeleteProjectItemResult{}, err
|
||||
}
|
||||
|
||||
nodes, err = service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
|
||||
if err != nil {
|
||||
return DeleteProjectItemResult{}, err
|
||||
}
|
||||
|
||||
return DeleteProjectItemResult{
|
||||
ProjectID: project.ID,
|
||||
DeletedItemStableID: deletedItem.ID,
|
||||
DeletedItemPath: deletedItemPath,
|
||||
Nodes: nodes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (service *Service) moveProjectTreeItem(
|
||||
ctx context.Context,
|
||||
input MoveProjectItemInput,
|
||||
rootPath func(projectSlug string) string,
|
||||
) (MoveProjectItemResult, error) {
|
||||
project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
|
||||
if err != nil {
|
||||
return MoveProjectItemResult{}, err
|
||||
}
|
||||
|
||||
currentNodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
|
||||
if err != nil {
|
||||
return MoveProjectItemResult{}, err
|
||||
}
|
||||
|
||||
currentItem, found := findProjectTreeNodeByPath(currentNodes, strings.TrimSpace(input.ItemPath))
|
||||
if !found || currentItem.Kind != "item" {
|
||||
return MoveProjectItemResult{}, ErrProjectItemNotFound
|
||||
}
|
||||
|
||||
movedItemStableID := currentItem.ID
|
||||
providedItemStableID := strings.TrimSpace(input.ItemStableID)
|
||||
if providedItemStableID != "" && providedItemStableID != movedItemStableID {
|
||||
return MoveProjectItemResult{}, ErrInvalidProjectItemMove
|
||||
}
|
||||
|
||||
parentOrderID := ""
|
||||
trimmedParentFolderPath := strings.TrimSpace(input.ParentFolderPath)
|
||||
providedParentStableID := strings.TrimSpace(input.ParentStableID)
|
||||
if trimmedParentFolderPath != "" {
|
||||
parentFolder, found := findProjectTreeFolderByPath(currentNodes, trimmedParentFolderPath)
|
||||
if !found {
|
||||
return MoveProjectItemResult{}, ErrProjectFolderNotFound
|
||||
}
|
||||
parentOrderID = parentFolder.ID
|
||||
if providedParentStableID != "" && providedParentStableID != parentOrderID {
|
||||
return MoveProjectItemResult{}, ErrInvalidProjectItemMove
|
||||
}
|
||||
} else if providedParentStableID != "" {
|
||||
return MoveProjectItemResult{}, ErrInvalidProjectItemMove
|
||||
}
|
||||
|
||||
previousItemPath, movedItemPath, err := service.moveProjectTreeItemOnDisk(project.Slug, input.ItemPath, input.ParentFolderPath)
|
||||
if err != nil {
|
||||
return MoveProjectItemResult{}, err
|
||||
}
|
||||
|
||||
if err := service.rebuildProjection(ctx); err != nil {
|
||||
return MoveProjectItemResult{}, err
|
||||
}
|
||||
|
||||
nodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
|
||||
if err != nil {
|
||||
return MoveProjectItemResult{}, err
|
||||
}
|
||||
|
||||
movedItem, found := findProjectTreeNodeByPath(nodes, movedItemPath)
|
||||
if !found || movedItem.Kind != "item" {
|
||||
return MoveProjectItemResult{}, fmt.Errorf("moved project item missing from projection")
|
||||
}
|
||||
|
||||
if previousItemPath != movedItemPath {
|
||||
if _, found := findProjectTreeNodeByPath(nodes, previousItemPath); found {
|
||||
return MoveProjectItemResult{}, fmt.Errorf("previous project item path still present in projection")
|
||||
}
|
||||
}
|
||||
|
||||
folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
|
||||
seedProjectTreeOrderParent(folderOrder, currentNodes, parentOrderID)
|
||||
removeFolderOrderReference(folderOrder, movedItemStableID)
|
||||
removeFolderOrderReference(folderOrder, movedItem.ID)
|
||||
insertFolderOrder(folderOrder, parentOrderID, movedItem.ID, input.TargetIndex)
|
||||
if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
|
||||
return MoveProjectItemResult{}, err
|
||||
}
|
||||
|
||||
nodes, err = service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
|
||||
if err != nil {
|
||||
return MoveProjectItemResult{}, err
|
||||
}
|
||||
|
||||
movedItem, found = findProjectTreeNodeByPath(nodes, movedItemPath)
|
||||
if !found || movedItem.Kind != "item" {
|
||||
return MoveProjectItemResult{}, fmt.Errorf("moved project item missing from ordered projection")
|
||||
}
|
||||
|
||||
return MoveProjectItemResult{
|
||||
ProjectID: project.ID,
|
||||
PreviousItemStableID: movedItem.ID,
|
||||
PreviousItemPath: previousItemPath,
|
||||
MovedItem: movedItem,
|
||||
Nodes: nodes,
|
||||
}, 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
|
||||
@@ -1796,6 +2152,218 @@ func (service *Service) moveProjectFolderOnDisk(
|
||||
return folderProjectionPath, movedProjectionPath, nil
|
||||
}
|
||||
|
||||
func (service *Service) createProjectTreeItemOnDisk(
|
||||
projectSlug, parentFolderPath, name, itemType string,
|
||||
) (string, error) {
|
||||
posixRoot := strings.TrimSpace(service.posixRoot)
|
||||
if posixRoot == "" {
|
||||
return "", fmt.Errorf("POSIX root is not configured")
|
||||
}
|
||||
|
||||
trimmedName := strings.TrimSpace(name)
|
||||
if trimmedName == "" {
|
||||
return "", fmt.Errorf("item name is required")
|
||||
}
|
||||
|
||||
canonicalItemType := normalizeProjectTreeItemType(itemType)
|
||||
containerProjectionPath := projectTreeRootPath(projectSlug)
|
||||
parentDir := filepath.Join(posixRoot, filepath.FromSlash(containerProjectionPath))
|
||||
|
||||
if trimmedParentFolderPath := strings.TrimSpace(parentFolderPath); trimmedParentFolderPath != "" {
|
||||
containerProjectionPath = trimmedParentFolderPath
|
||||
parentDir = filepath.Join(posixRoot, filepath.FromSlash(containerProjectionPath))
|
||||
info, err := os.Stat(parentDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", ErrProjectFolderNotFound
|
||||
}
|
||||
return "", fmt.Errorf("stat parent project folder: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "", ErrProjectFolderNotFound
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(parentDir, 0o755); err != nil {
|
||||
return "", fmt.Errorf("create parent project item path: %w", err)
|
||||
}
|
||||
|
||||
baseSlug := normalizePOSIXSlug(trimmedName)
|
||||
itemDirName := slugDir("item", baseSlug)
|
||||
itemDir := filepath.Join(parentDir, itemDirName)
|
||||
itemSlug := baseSlug
|
||||
|
||||
for attempt := 2; ; attempt += 1 {
|
||||
if _, err := os.Stat(itemDir); os.IsNotExist(err) {
|
||||
break
|
||||
} else if err != nil {
|
||||
return "", fmt.Errorf("stat candidate project item: %w", err)
|
||||
}
|
||||
|
||||
itemSlug = fmt.Sprintf("%s-%d", baseSlug, attempt)
|
||||
itemDirName = slugDir("item", itemSlug)
|
||||
itemDir = filepath.Join(parentDir, itemDirName)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(itemDir, 0o755); err != nil {
|
||||
return "", fmt.Errorf("create project item: %w", err)
|
||||
}
|
||||
|
||||
itemID := uuid.NewString()
|
||||
if err := writeJSONFile(filepath.Join(itemDir, "item.json"), map[string]any{
|
||||
"id": itemID,
|
||||
"name": trimmedName,
|
||||
"slug": itemSlug,
|
||||
"type": canonicalItemType,
|
||||
}); err != nil {
|
||||
return "", fmt.Errorf("write project item.json: %w", err)
|
||||
}
|
||||
|
||||
if err := writeJSONFile(filepath.Join(itemDir, "schema.json"), defaultProjectTreeItemSchema(canonicalItemType)); err != nil {
|
||||
return "", fmt.Errorf("write project schema.json: %w", err)
|
||||
}
|
||||
|
||||
if err := writeJSONFile(filepath.Join(itemDir, "data.json"), defaultProjectTreeItemData(canonicalItemType, trimmedName)); err != nil {
|
||||
return "", fmt.Errorf("write project data.json: %w", err)
|
||||
}
|
||||
|
||||
return filepath.ToSlash(filepath.Join(containerProjectionPath, itemDirName)), nil
|
||||
}
|
||||
|
||||
func (service *Service) deleteProjectTreeItemOnDisk(projectSlug, itemPath string) (string, error) {
|
||||
posixRoot := strings.TrimSpace(service.posixRoot)
|
||||
if posixRoot == "" {
|
||||
return "", fmt.Errorf("POSIX root is not configured")
|
||||
}
|
||||
|
||||
rootProjectionPath := projectTreeRootPath(projectSlug)
|
||||
itemProjectionPath := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+strings.TrimSpace(itemPath))), "/")
|
||||
if itemProjectionPath == "." || itemProjectionPath == rootProjectionPath || !strings.HasPrefix(itemProjectionPath, rootProjectionPath+"/") {
|
||||
return "", ErrProjectItemNotFound
|
||||
}
|
||||
|
||||
itemDir := filepath.Join(posixRoot, filepath.FromSlash(itemProjectionPath))
|
||||
info, err := os.Stat(itemDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", ErrProjectItemNotFound
|
||||
}
|
||||
return "", fmt.Errorf("stat project item: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "", ErrProjectItemNotFound
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(itemDir); err != nil {
|
||||
return "", fmt.Errorf("delete project item: %w", err)
|
||||
}
|
||||
|
||||
return itemProjectionPath, nil
|
||||
}
|
||||
|
||||
func (service *Service) moveProjectTreeItemOnDisk(projectSlug, itemPath, parentFolderPath string) (string, string, error) {
|
||||
posixRoot := strings.TrimSpace(service.posixRoot)
|
||||
if posixRoot == "" {
|
||||
return "", "", fmt.Errorf("POSIX root is not configured")
|
||||
}
|
||||
|
||||
rootProjectionPath := projectTreeRootPath(projectSlug)
|
||||
itemProjectionPath := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+strings.TrimSpace(itemPath))), "/")
|
||||
if itemProjectionPath == "." || itemProjectionPath == rootProjectionPath || !strings.HasPrefix(itemProjectionPath, rootProjectionPath+"/") {
|
||||
return "", "", ErrProjectItemNotFound
|
||||
}
|
||||
|
||||
itemDir := filepath.Join(posixRoot, filepath.FromSlash(itemProjectionPath))
|
||||
info, err := os.Stat(itemDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", "", ErrProjectItemNotFound
|
||||
}
|
||||
return "", "", fmt.Errorf("stat project item: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "", "", ErrProjectItemNotFound
|
||||
}
|
||||
|
||||
trimmedParentFolderPath := strings.TrimSpace(parentFolderPath)
|
||||
parentProjectionPath := rootProjectionPath
|
||||
parentDir := filepath.Join(posixRoot, filepath.FromSlash(parentProjectionPath))
|
||||
if trimmedParentFolderPath != "" {
|
||||
parentProjectionPath = strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+trimmedParentFolderPath)), "/")
|
||||
if parentProjectionPath == "." || parentProjectionPath == rootProjectionPath || !strings.HasPrefix(parentProjectionPath, rootProjectionPath+"/") {
|
||||
return "", "", ErrProjectFolderNotFound
|
||||
}
|
||||
parentDir = filepath.Join(posixRoot, filepath.FromSlash(parentProjectionPath))
|
||||
}
|
||||
|
||||
parentInfo, err := os.Stat(parentDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", "", ErrProjectFolderNotFound
|
||||
}
|
||||
return "", "", fmt.Errorf("stat project item parent: %w", err)
|
||||
}
|
||||
if !parentInfo.IsDir() {
|
||||
return "", "", ErrProjectFolderNotFound
|
||||
}
|
||||
|
||||
currentParentDir := filepath.Dir(itemDir)
|
||||
currentBase := filepath.Base(itemDir)
|
||||
itemPayload := readJSONFileMap(filepath.Join(itemDir, "item.json"))
|
||||
itemID, _ := itemPayload["id"].(string)
|
||||
if strings.TrimSpace(itemID) == "" {
|
||||
itemID = uuid.NewString()
|
||||
}
|
||||
itemName, _ := itemPayload["name"].(string)
|
||||
if strings.TrimSpace(itemName) == "" {
|
||||
itemName = fallbackItemLabel(itemProjectionPath)
|
||||
}
|
||||
itemType, _ := itemPayload["type"].(string)
|
||||
canonicalItemType := normalizeProjectTreeItemType(itemType)
|
||||
|
||||
baseSlug := strings.TrimPrefix(currentBase, "item-")
|
||||
if strings.TrimSpace(baseSlug) == "" {
|
||||
baseSlug = normalizePOSIXSlug(itemName)
|
||||
}
|
||||
|
||||
itemSlug := baseSlug
|
||||
itemDirName := slugDir("item", itemSlug)
|
||||
destinationDir := filepath.Join(parentDir, itemDirName)
|
||||
for attempt := 2; ; attempt += 1 {
|
||||
if samePath(destinationDir, itemDir) {
|
||||
break
|
||||
}
|
||||
if _, err := os.Stat(destinationDir); os.IsNotExist(err) {
|
||||
break
|
||||
} else if err != nil {
|
||||
return "", "", fmt.Errorf("stat candidate moved project item: %w", err)
|
||||
}
|
||||
|
||||
itemSlug = fmt.Sprintf("%s-%d", baseSlug, attempt)
|
||||
itemDirName = slugDir("item", itemSlug)
|
||||
destinationDir = filepath.Join(parentDir, itemDirName)
|
||||
}
|
||||
|
||||
movedProjectionPath := filepath.ToSlash(filepath.Join(parentProjectionPath, itemDirName))
|
||||
if samePath(currentParentDir, parentDir) && currentBase == itemDirName {
|
||||
return itemProjectionPath, itemProjectionPath, nil
|
||||
}
|
||||
|
||||
if err := os.Rename(itemDir, destinationDir); err != nil {
|
||||
return "", "", fmt.Errorf("move project item: %w", err)
|
||||
}
|
||||
|
||||
itemPayload["id"] = itemID
|
||||
itemPayload["name"] = itemName
|
||||
itemPayload["slug"] = itemSlug
|
||||
itemPayload["type"] = canonicalItemType
|
||||
if err := writeJSONFile(filepath.Join(destinationDir, "item.json"), itemPayload); err != nil {
|
||||
return "", "", fmt.Errorf("write moved project item.json: %w", err)
|
||||
}
|
||||
|
||||
return itemProjectionPath, movedProjectionPath, nil
|
||||
}
|
||||
|
||||
func samePath(left, right string) bool {
|
||||
cleanLeft := filepath.Clean(left)
|
||||
cleanRight := filepath.Clean(right)
|
||||
@@ -2144,6 +2712,136 @@ func buildProjectHierarchyFolderTree(rows []projectHierarchyFolderRow, rootParen
|
||||
return build(rootParentPath)
|
||||
}
|
||||
|
||||
func buildProjectTreeNodeTree(rows []projectTreeNodeRow, rootParentPath string) []ProjectTreeNodeRecord {
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
nodesByPath := make(map[string]*ProjectTreeNodeRecord, len(rows))
|
||||
childrenByParent := make(map[string][]string)
|
||||
|
||||
for _, row := range rows {
|
||||
nodeKind := normalizeProjectTreeNodeKind(row.Kind)
|
||||
nodeID := strings.TrimSpace(row.ID)
|
||||
if nodeID == "" {
|
||||
nodeID = row.Path
|
||||
}
|
||||
label := strings.TrimSpace(row.Label)
|
||||
if label == "" {
|
||||
if nodeKind == "item" {
|
||||
label = fallbackItemLabel(row.Path)
|
||||
} else {
|
||||
label = fallbackFolderLabel(row.Path)
|
||||
}
|
||||
}
|
||||
|
||||
nodesByPath[row.Path] = &ProjectTreeNodeRecord{
|
||||
ID: nodeID,
|
||||
Path: row.Path,
|
||||
Label: label,
|
||||
Kind: nodeKind,
|
||||
ItemType: normalizeProjectTreeItemType(row.ItemType),
|
||||
Children: []ProjectTreeNodeRecord{},
|
||||
}
|
||||
childrenByParent[normalizeProjectTreeParentPath(nodeKind, row.ParentPath)] = append(childrenByParent[normalizeProjectTreeParentPath(nodeKind, row.ParentPath)], row.Path)
|
||||
}
|
||||
|
||||
var build func(parentPath string) []ProjectTreeNodeRecord
|
||||
build = func(parentPath string) []ProjectTreeNodeRecord {
|
||||
childPaths := childrenByParent[parentPath]
|
||||
if len(childPaths) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
nodes := make([]ProjectTreeNodeRecord, 0, len(childPaths))
|
||||
for _, childPath := range childPaths {
|
||||
node := nodesByPath[childPath]
|
||||
if node == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
nextNode := ProjectTreeNodeRecord{
|
||||
ID: node.ID,
|
||||
Path: node.Path,
|
||||
Label: node.Label,
|
||||
Kind: node.Kind,
|
||||
ItemType: node.ItemType,
|
||||
}
|
||||
if node.Kind == "folder" {
|
||||
nextNode.Children = build(node.Path)
|
||||
}
|
||||
nodes = append(nodes, nextNode)
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
return build(rootParentPath)
|
||||
}
|
||||
|
||||
func normalizeProjectTreeParentPath(kind, parentPath string) string {
|
||||
if kind == "folder" && strings.HasSuffix(parentPath, "/children") {
|
||||
return filepath.ToSlash(filepath.Dir(parentPath))
|
||||
}
|
||||
|
||||
return parentPath
|
||||
}
|
||||
|
||||
func normalizeProjectTreeNodeKind(kind string) string {
|
||||
switch strings.TrimSpace(kind) {
|
||||
case "item":
|
||||
return "item"
|
||||
case "folder", "hierarchy_folder":
|
||||
return "folder"
|
||||
default:
|
||||
return "folder"
|
||||
}
|
||||
}
|
||||
|
||||
func applyProjectTreeNodeOrdering(nodes []ProjectTreeNodeRecord, folderOrder map[string][]string) []ProjectTreeNodeRecord {
|
||||
return applyProjectTreeNodeOrderingForParent(nodes, "", folderOrder)
|
||||
}
|
||||
|
||||
func applyProjectTreeNodeOrderingForParent(nodes []ProjectTreeNodeRecord, parentID string, folderOrder map[string][]string) []ProjectTreeNodeRecord {
|
||||
if len(nodes) == 0 {
|
||||
return nodes
|
||||
}
|
||||
|
||||
nextNodes := make([]ProjectTreeNodeRecord, len(nodes))
|
||||
copy(nextNodes, nodes)
|
||||
for index := range nextNodes {
|
||||
if nextNodes[index].Kind == "folder" {
|
||||
nextNodes[index].Children = applyProjectTreeNodeOrderingForParent(nextNodes[index].Children, nextNodes[index].ID, folderOrder)
|
||||
}
|
||||
}
|
||||
|
||||
orderIDs := folderOrder[projectFolderOrderParentKey(parentID)]
|
||||
if len(orderIDs) == 0 {
|
||||
return nextNodes
|
||||
}
|
||||
|
||||
rankByID := make(map[string]int, len(orderIDs))
|
||||
for index, id := range orderIDs {
|
||||
if _, exists := rankByID[id]; !exists {
|
||||
rankByID[id] = index
|
||||
}
|
||||
}
|
||||
|
||||
sort.SliceStable(nextNodes, func(left, right int) bool {
|
||||
leftRank, leftOrdered := rankByID[nextNodes[left].ID]
|
||||
rightRank, rightOrdered := rankByID[nextNodes[right].ID]
|
||||
if leftOrdered && rightOrdered {
|
||||
return leftRank < rightRank
|
||||
}
|
||||
if leftOrdered != rightOrdered {
|
||||
return leftOrdered
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
return nextNodes
|
||||
}
|
||||
|
||||
func findProjectHierarchyFolder(folders []ProjectHierarchyFolderRecord, folderID string) (ProjectHierarchyFolderRecord, bool) {
|
||||
for _, folder := range folders {
|
||||
if folder.ID == folderID {
|
||||
@@ -2172,6 +2870,41 @@ func findProjectHierarchyFolderByPath(folders []ProjectHierarchyFolderRecord, fo
|
||||
return ProjectHierarchyFolderRecord{}, false
|
||||
}
|
||||
|
||||
func findProjectTreeNode(nodes []ProjectTreeNodeRecord, nodeID string) (ProjectTreeNodeRecord, bool) {
|
||||
for _, node := range nodes {
|
||||
if node.ID == nodeID {
|
||||
return node, true
|
||||
}
|
||||
if child, ok := findProjectTreeNode(node.Children, nodeID); ok {
|
||||
return child, true
|
||||
}
|
||||
}
|
||||
|
||||
return ProjectTreeNodeRecord{}, false
|
||||
}
|
||||
|
||||
func findProjectTreeNodeByPath(nodes []ProjectTreeNodeRecord, nodePath string) (ProjectTreeNodeRecord, bool) {
|
||||
for _, node := range nodes {
|
||||
if node.Path == nodePath {
|
||||
return node, true
|
||||
}
|
||||
if child, ok := findProjectTreeNodeByPath(node.Children, nodePath); ok {
|
||||
return child, true
|
||||
}
|
||||
}
|
||||
|
||||
return ProjectTreeNodeRecord{}, false
|
||||
}
|
||||
|
||||
func findProjectTreeFolderByPath(nodes []ProjectTreeNodeRecord, folderPath string) (ProjectTreeNodeRecord, bool) {
|
||||
node, ok := findProjectTreeNodeByPath(nodes, folderPath)
|
||||
if !ok || node.Kind != "folder" {
|
||||
return ProjectTreeNodeRecord{}, false
|
||||
}
|
||||
|
||||
return node, true
|
||||
}
|
||||
|
||||
func projectHierarchyRootPath(projectSlug string) string {
|
||||
return filepath.ToSlash(filepath.Join("projects", slugDir("project", projectSlug), "children"))
|
||||
}
|
||||
@@ -2226,6 +2959,80 @@ func fallbackFolderLabel(path string) string {
|
||||
return label
|
||||
}
|
||||
|
||||
func fallbackItemLabel(path string) string {
|
||||
base := filepath.Base(filepath.FromSlash(path))
|
||||
trimmed := strings.TrimPrefix(base, "item-")
|
||||
parts := strings.FieldsFunc(trimmed, func(r rune) bool { return r == '-' || r == '_' })
|
||||
for index, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
parts[index] = strings.ToUpper(part[:1]) + part[1:]
|
||||
}
|
||||
label := strings.Join(parts, " ")
|
||||
if label == "" {
|
||||
return base
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
func normalizeProjectTreeItemType(itemType string) string {
|
||||
switch strings.TrimSpace(strings.ToLower(itemType)) {
|
||||
case "", "board", "core.board", "core.board.kanban", "kanban":
|
||||
return "core.board.kanban"
|
||||
case "core.doc", "doc", "document":
|
||||
return "core.doc"
|
||||
case "core.board.list", "list", "list-board":
|
||||
return "core.board.list"
|
||||
default:
|
||||
return strings.TrimSpace(itemType)
|
||||
}
|
||||
}
|
||||
|
||||
func seedProjectTreeOrderParent(folderOrder map[string][]string, nodes []ProjectTreeNodeRecord, parentID string) {
|
||||
children := nodes
|
||||
trimmedParentID := strings.TrimSpace(parentID)
|
||||
if trimmedParentID != "" {
|
||||
parent, found := findProjectTreeNode(nodes, trimmedParentID)
|
||||
if !found || parent.Kind != "folder" {
|
||||
return
|
||||
}
|
||||
children = parent.Children
|
||||
}
|
||||
|
||||
parentKey := projectFolderOrderParentKey(parentID)
|
||||
if len(folderOrder[parentKey]) > 0 {
|
||||
return
|
||||
}
|
||||
|
||||
orderedIDs := make([]string, 0, len(children))
|
||||
for _, child := range children {
|
||||
trimmedChildID := strings.TrimSpace(child.ID)
|
||||
if trimmedChildID == "" || slicesContains(orderedIDs, trimmedChildID) {
|
||||
continue
|
||||
}
|
||||
orderedIDs = append(orderedIDs, trimmedChildID)
|
||||
}
|
||||
|
||||
if len(orderedIDs) > 0 {
|
||||
folderOrder[parentKey] = orderedIDs
|
||||
}
|
||||
}
|
||||
|
||||
func defaultProjectTreeItemSchema(itemType string) map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"itemType": normalizeProjectTreeItemType(itemType),
|
||||
}
|
||||
}
|
||||
|
||||
func defaultProjectTreeItemData(itemType, name string) map[string]any {
|
||||
return map[string]any{
|
||||
"title": strings.TrimSpace(name),
|
||||
"itemType": normalizeProjectTreeItemType(itemType),
|
||||
}
|
||||
}
|
||||
|
||||
func slugDir(prefix, slug string) string {
|
||||
trimmedSlug := strings.TrimSpace(slug)
|
||||
if trimmedSlug == "" {
|
||||
|
||||
Reference in New Issue
Block a user