472 lines
14 KiB
Go
472 lines
14 KiB
Go
// Path: Backend/internal/bootstrap/project_order.go
|
|
|
|
package bootstrap
|
|
|
|
import (
|
|
"fmt"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
func (service *Service) readProjectFolderOrder(projectSlug, rootProjectionPath string) map[string][]string {
|
|
settingsPath := service.projectSettingsPath(projectSlug)
|
|
settingsPayload := readStructuredFileMap(settingsPath)
|
|
folderOrderPayload, _ := settingsPayload["folderOrder"].(map[string]any)
|
|
if folderOrderPayload == nil {
|
|
return map[string][]string{}
|
|
}
|
|
scopePayload, _ := folderOrderPayload[projectFolderOrderScope(rootProjectionPath)].(map[string]any)
|
|
if scopePayload == nil {
|
|
return map[string][]string{}
|
|
}
|
|
byParentPayload, _ := scopePayload["byParent"].(map[string]any)
|
|
if byParentPayload == nil {
|
|
return map[string][]string{}
|
|
}
|
|
order := make(map[string][]string, len(byParentPayload))
|
|
for key, raw := range byParentPayload {
|
|
for _, id := range stringSliceValue(raw) {
|
|
trimmedID := strings.TrimSpace(id)
|
|
if trimmedID == "" || slicesContains(order[key], trimmedID) {
|
|
continue
|
|
}
|
|
order[key] = append(order[key], trimmedID)
|
|
}
|
|
}
|
|
return order
|
|
}
|
|
|
|
func (service *Service) writeProjectFolderOrder(projectSlug, rootProjectionPath string, folderOrder map[string][]string) error {
|
|
settingsPath := service.projectSettingsPath(projectSlug)
|
|
settingsPayload := readStructuredFileMap(settingsPath)
|
|
if settingsPayload == nil {
|
|
settingsPayload = map[string]any{}
|
|
}
|
|
folderOrderPayload, _ := settingsPayload["folderOrder"].(map[string]any)
|
|
if folderOrderPayload == nil {
|
|
folderOrderPayload = map[string]any{}
|
|
}
|
|
scopeKey := projectFolderOrderScope(rootProjectionPath)
|
|
scopePayload, _ := folderOrderPayload[scopeKey].(map[string]any)
|
|
if scopePayload == nil {
|
|
scopePayload = map[string]any{}
|
|
}
|
|
byParentPayload := map[string]any{}
|
|
for key, ids := range folderOrder {
|
|
if len(ids) == 0 {
|
|
continue
|
|
}
|
|
copied := make([]string, 0, len(ids))
|
|
for _, id := range ids {
|
|
trimmedID := strings.TrimSpace(id)
|
|
if trimmedID == "" || slicesContains(copied, trimmedID) {
|
|
continue
|
|
}
|
|
copied = append(copied, trimmedID)
|
|
}
|
|
if len(copied) > 0 {
|
|
byParentPayload[key] = copied
|
|
}
|
|
}
|
|
scopePayload["byParent"] = byParentPayload
|
|
folderOrderPayload[scopeKey] = scopePayload
|
|
settingsPayload["folderOrder"] = folderOrderPayload
|
|
if err := writeCBORFile(settingsPath, settingsPayload); err != nil {
|
|
return fmt.Errorf("write project %s: %w", posixSettingsFileName, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (service *Service) projectSettingsPath(projectSlug string) string {
|
|
return filepath.Join(strings.TrimSpace(service.posixRoot), "projects", slugDir("project", projectSlug), posixSettingsFileName)
|
|
}
|
|
|
|
func projectFolderOrderScope(rootProjectionPath string) string {
|
|
if strings.HasSuffix(rootProjectionPath, "/tree") {
|
|
return projectFolderOrderTree
|
|
}
|
|
return projectFolderOrderHierarchy
|
|
}
|
|
|
|
func applyProjectHierarchyFolderOrdering(folders []ProjectHierarchyFolderRecord, folderOrder map[string][]string) []ProjectHierarchyFolderRecord {
|
|
return applyProjectHierarchyFolderOrderingForParent(folders, "", folderOrder)
|
|
}
|
|
|
|
func applyProjectHierarchyFolderOrderingForParent(folders []ProjectHierarchyFolderRecord, parentID string, folderOrder map[string][]string) []ProjectHierarchyFolderRecord {
|
|
if len(folders) == 0 {
|
|
return folders
|
|
}
|
|
nextFolders := make([]ProjectHierarchyFolderRecord, len(folders))
|
|
copy(nextFolders, folders)
|
|
for index := range nextFolders {
|
|
nextFolders[index].Children = applyProjectHierarchyFolderOrderingForParent(nextFolders[index].Children, nextFolders[index].ID, folderOrder)
|
|
}
|
|
orderIDs := folderOrder[projectFolderOrderParentKey(parentID)]
|
|
if len(orderIDs) == 0 {
|
|
return nextFolders
|
|
}
|
|
rankByID := make(map[string]int, len(orderIDs))
|
|
for index, id := range orderIDs {
|
|
if _, exists := rankByID[id]; !exists {
|
|
rankByID[id] = index
|
|
}
|
|
}
|
|
sort.SliceStable(nextFolders, func(left, right int) bool {
|
|
leftRank, leftOrdered := rankByID[nextFolders[left].ID]
|
|
rightRank, rightOrdered := rankByID[nextFolders[right].ID]
|
|
if leftOrdered && rightOrdered {
|
|
return leftRank < rightRank
|
|
}
|
|
if leftOrdered != rightOrdered {
|
|
return leftOrdered
|
|
}
|
|
return false
|
|
})
|
|
return nextFolders
|
|
}
|
|
|
|
func removeFolderOrder(folderOrder map[string][]string, folderID string) {
|
|
removeFolderOrderReference(folderOrder, folderID)
|
|
trimmedFolderID := strings.TrimSpace(folderID)
|
|
if trimmedFolderID == "" {
|
|
return
|
|
}
|
|
delete(folderOrder, projectFolderOrderParentKey(trimmedFolderID))
|
|
}
|
|
|
|
func removeFolderOrderReference(folderOrder map[string][]string, folderID string) {
|
|
trimmedFolderID := strings.TrimSpace(folderID)
|
|
if trimmedFolderID == "" {
|
|
return
|
|
}
|
|
for key, ids := range folderOrder {
|
|
nextIDs := ids[:0]
|
|
for _, id := range ids {
|
|
if strings.TrimSpace(id) == trimmedFolderID {
|
|
continue
|
|
}
|
|
nextIDs = append(nextIDs, id)
|
|
}
|
|
if len(nextIDs) == 0 {
|
|
delete(folderOrder, key)
|
|
continue
|
|
}
|
|
folderOrder[key] = append([]string(nil), nextIDs...)
|
|
}
|
|
}
|
|
|
|
func insertFolderOrder(folderOrder map[string][]string, parentID, folderID string, index int) {
|
|
trimmedFolderID := strings.TrimSpace(folderID)
|
|
if trimmedFolderID == "" {
|
|
return
|
|
}
|
|
removeFolderOrderReference(folderOrder, trimmedFolderID)
|
|
parentKey := projectFolderOrderParentKey(parentID)
|
|
children := append([]string(nil), folderOrder[parentKey]...)
|
|
if index < 0 {
|
|
index = 0
|
|
}
|
|
if index > len(children) {
|
|
index = len(children)
|
|
}
|
|
children = slicesInsert(children, index, trimmedFolderID)
|
|
folderOrder[parentKey] = children
|
|
}
|
|
|
|
func seedFolderOrderParent(folderOrder map[string][]string, folders []ProjectHierarchyFolderRecord, parentID string) {
|
|
children := folders
|
|
trimmedParentID := strings.TrimSpace(parentID)
|
|
if trimmedParentID != "" {
|
|
parent, found := findProjectHierarchyFolder(folders, trimmedParentID)
|
|
if !found {
|
|
return
|
|
}
|
|
children = parent.Children
|
|
}
|
|
parentKey := projectFolderOrderParentKey(trimmedParentID)
|
|
seeded := make([]string, 0, len(children))
|
|
for _, child := range children {
|
|
childID := strings.TrimSpace(child.ID)
|
|
if childID == "" || slicesContains(seeded, childID) {
|
|
continue
|
|
}
|
|
seeded = append(seeded, childID)
|
|
}
|
|
if len(seeded) == 0 {
|
|
delete(folderOrder, parentKey)
|
|
return
|
|
}
|
|
folderOrder[parentKey] = seeded
|
|
}
|
|
|
|
func folderOrderChildren(folderOrder map[string][]string, parentID string) []string {
|
|
return append([]string(nil), folderOrder[projectFolderOrderParentKey(parentID)]...)
|
|
}
|
|
|
|
func projectFolderOrderParentKey(parentID string) string {
|
|
trimmedParentID := strings.TrimSpace(parentID)
|
|
if trimmedParentID == "" {
|
|
return projectFolderOrderRootKey
|
|
}
|
|
return trimmedParentID
|
|
}
|
|
|
|
func stringSliceValue(value any) []string {
|
|
items, ok := value.([]any)
|
|
if !ok {
|
|
if typed, ok := value.([]string); ok {
|
|
return typed
|
|
}
|
|
return nil
|
|
}
|
|
result := make([]string, 0, len(items))
|
|
for _, item := range items {
|
|
if text, ok := item.(string); ok {
|
|
result = append(result, text)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func slicesContains(values []string, value string) bool {
|
|
for _, existing := range values {
|
|
if existing == value {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func slicesInsert(values []string, index int, value string) []string {
|
|
values = append(values, "")
|
|
copy(values[index+1:], values[index:])
|
|
values[index] = value
|
|
return values
|
|
}
|
|
|
|
func buildProjectHierarchyFolderTree(rows []projectHierarchyFolderRow, rootParentPath string) []ProjectHierarchyFolderRecord {
|
|
if len(rows) == 0 {
|
|
return nil
|
|
}
|
|
nodesByPath := make(map[string]*ProjectHierarchyFolderRecord, len(rows))
|
|
childrenByParent := make(map[string][]string)
|
|
for _, row := range rows {
|
|
folderID := strings.TrimSpace(row.ID)
|
|
if folderID == "" {
|
|
folderID = row.Path
|
|
}
|
|
label := strings.TrimSpace(row.Label)
|
|
if label == "" {
|
|
label = fallbackFolderLabel(row.Path)
|
|
}
|
|
nodesByPath[row.Path] = &ProjectHierarchyFolderRecord{ID: folderID, Path: row.Path, Label: label, Children: []ProjectHierarchyFolderRecord{}}
|
|
childrenByParent[row.ParentPath] = append(childrenByParent[row.ParentPath], row.Path)
|
|
}
|
|
var build func(parentPath string) []ProjectHierarchyFolderRecord
|
|
build = func(parentPath string) []ProjectHierarchyFolderRecord {
|
|
childPaths := childrenByParent[parentPath]
|
|
if len(childPaths) == 0 {
|
|
return nil
|
|
}
|
|
folders := make([]ProjectHierarchyFolderRecord, 0, len(childPaths))
|
|
for _, childPath := range childPaths {
|
|
node := nodesByPath[childPath]
|
|
if node == nil {
|
|
continue
|
|
}
|
|
folder := ProjectHierarchyFolderRecord{ID: node.ID, Path: node.Path, Label: node.Label, Children: build(filepath.ToSlash(filepath.Join(childPath, "children")))}
|
|
folders = append(folders, folder)
|
|
}
|
|
return folders
|
|
}
|
|
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{}}
|
|
parentKey := normalizeProjectTreeParentPath(nodeKind, row.ParentPath)
|
|
childrenByParent[parentKey] = append(childrenByParent[parentKey], 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 {
|
|
return folder, true
|
|
}
|
|
if child, ok := findProjectHierarchyFolder(folder.Children, folderID); ok {
|
|
return child, true
|
|
}
|
|
}
|
|
return ProjectHierarchyFolderRecord{}, false
|
|
}
|
|
|
|
func findProjectHierarchyFolderByPath(folders []ProjectHierarchyFolderRecord, folderPath string) (ProjectHierarchyFolderRecord, bool) {
|
|
for _, folder := range folders {
|
|
if folder.Path == folderPath {
|
|
return folder, true
|
|
}
|
|
if child, ok := findProjectHierarchyFolderByPath(folder.Children, folderPath); ok {
|
|
return child, true
|
|
}
|
|
}
|
|
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 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
|
|
}
|
|
}
|