Refactor: improve backend modularity
This commit is contained in:
@@ -1,72 +1,15 @@
|
||||
// Path: Backend/internal/posixproj/projector.go
|
||||
|
||||
package posixproj
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"moku-backend/internal/database"
|
||||
)
|
||||
|
||||
const rootProjectionPath = "/"
|
||||
|
||||
type Projector struct {
|
||||
db *database.DB
|
||||
root string
|
||||
}
|
||||
|
||||
type RebuildSummary struct {
|
||||
TotalNodes int
|
||||
DirectoryCount int
|
||||
FileCount int
|
||||
}
|
||||
|
||||
type NodeKind string
|
||||
|
||||
const (
|
||||
NodeKindDirectory NodeKind = "directory"
|
||||
NodeKindFile NodeKind = "file"
|
||||
)
|
||||
|
||||
type Scope struct {
|
||||
InstallationID string
|
||||
OrganizationID string
|
||||
OrganizationSlug string
|
||||
DepartmentSlug string
|
||||
TeamSlug string
|
||||
ProjectSlug string
|
||||
PersonalSlug string
|
||||
}
|
||||
|
||||
type Node struct {
|
||||
Path string
|
||||
ParentPath *string
|
||||
Name string
|
||||
Depth int
|
||||
NodeKind NodeKind
|
||||
LogicalType string
|
||||
FileRole string
|
||||
ResourceID string
|
||||
ResourceName string
|
||||
ResourceSlug string
|
||||
InstallationID string
|
||||
OrganizationID string
|
||||
OrganizationSlug string
|
||||
DepartmentSlug string
|
||||
TeamSlug string
|
||||
ProjectSlug string
|
||||
PersonalSlug string
|
||||
ContentJSON []byte
|
||||
SizeBytes int64
|
||||
Checksum string
|
||||
}
|
||||
|
||||
func NewProjector(db *database.DB, root string) *Projector {
|
||||
return &Projector{db: db, root: strings.TrimSpace(root)}
|
||||
}
|
||||
@@ -145,9 +88,9 @@ func (projector *Projector) RebuildWithSummary(ctx context.Context) (RebuildSumm
|
||||
node.TeamSlug,
|
||||
node.ProjectSlug,
|
||||
node.PersonalSlug,
|
||||
node.ContentJSON,
|
||||
node.SizeBytes,
|
||||
node.Checksum,
|
||||
node.ContentJSON,
|
||||
node.SizeBytes,
|
||||
node.Checksum,
|
||||
); err != nil {
|
||||
return RebuildSummary{}, fmt.Errorf("insert posix node %s: %w", node.Path, err)
|
||||
}
|
||||
@@ -159,395 +102,3 @@ func (projector *Projector) RebuildWithSummary(ctx context.Context) (RebuildSumm
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func ScanRoot(root string) ([]Node, error) {
|
||||
rootPath := strings.TrimSpace(root)
|
||||
if rootPath == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
info, err := os.Stat(rootPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat POSIX root: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("POSIX root is not a directory: %s", rootPath)
|
||||
}
|
||||
|
||||
rootScope, err := loadRootScope(rootPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nodes := []Node{{
|
||||
Path: rootProjectionPath,
|
||||
ParentPath: nil,
|
||||
Name: filepath.Base(rootPath),
|
||||
Depth: 0,
|
||||
NodeKind: NodeKindDirectory,
|
||||
LogicalType: "tenant_root",
|
||||
InstallationID: rootScope.InstallationID,
|
||||
OrganizationID: rootScope.OrganizationID,
|
||||
OrganizationSlug: rootScope.OrganizationSlug,
|
||||
}}
|
||||
|
||||
err = filepath.WalkDir(rootPath, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if path == rootPath {
|
||||
return nil
|
||||
}
|
||||
|
||||
relPath, err := filepath.Rel(rootPath, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
relPath = filepath.ToSlash(relPath)
|
||||
if relPath == "." {
|
||||
return nil
|
||||
}
|
||||
|
||||
node, err := buildNode(rootPath, relPath, entry, rootScope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
nodes = append(nodes, node)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan POSIX root: %w", err)
|
||||
}
|
||||
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
func loadRootScope(rootPath string) (Scope, error) {
|
||||
settingsPath := filepath.Join(rootPath, "settings.json")
|
||||
content, err := os.ReadFile(settingsPath)
|
||||
if err != nil {
|
||||
if errorsIsNotExist(err) {
|
||||
return Scope{}, nil
|
||||
}
|
||||
return Scope{}, fmt.Errorf("read root settings.json: %w", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(content, &payload); err != nil {
|
||||
return Scope{}, fmt.Errorf("decode root settings.json: %w", err)
|
||||
}
|
||||
|
||||
installation, _ := payload["installation"].(map[string]any)
|
||||
organization, _ := payload["organization"].(map[string]any)
|
||||
|
||||
return Scope{
|
||||
InstallationID: stringValue(installation["id"]),
|
||||
OrganizationID: stringValue(organization["id"]),
|
||||
OrganizationSlug: stringValue(organization["slug"]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildNode(rootPath, relPath string, entry fs.DirEntry, rootScope Scope) (Node, error) {
|
||||
scope := deriveScope(relPath, rootScope)
|
||||
parentPath := projectionParentPath(relPath)
|
||||
logicalType, fileRole := classifyPath(relPath, entry.IsDir())
|
||||
|
||||
node := Node{
|
||||
Path: relPath,
|
||||
ParentPath: parentPath,
|
||||
Name: entry.Name(),
|
||||
Depth: strings.Count(relPath, "/") + 1,
|
||||
NodeKind: NodeKindDirectory,
|
||||
LogicalType: logicalType,
|
||||
FileRole: fileRole,
|
||||
InstallationID: scope.InstallationID,
|
||||
OrganizationID: scope.OrganizationID,
|
||||
OrganizationSlug: scope.OrganizationSlug,
|
||||
DepartmentSlug: scope.DepartmentSlug,
|
||||
TeamSlug: scope.TeamSlug,
|
||||
ProjectSlug: scope.ProjectSlug,
|
||||
PersonalSlug: scope.PersonalSlug,
|
||||
}
|
||||
|
||||
if entry.IsDir() {
|
||||
return node, nil
|
||||
}
|
||||
|
||||
absPath := filepath.Join(rootPath, filepath.FromSlash(relPath))
|
||||
content, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return Node{}, fmt.Errorf("read POSIX file %s: %w", relPath, err)
|
||||
}
|
||||
|
||||
hash := sha256.Sum256(content)
|
||||
node.NodeKind = NodeKindFile
|
||||
node.SizeBytes = int64(len(content))
|
||||
node.Checksum = hex.EncodeToString(hash[:])
|
||||
|
||||
if strings.EqualFold(filepath.Ext(entry.Name()), ".json") {
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(content, &payload); err == nil {
|
||||
jsonContent, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return Node{}, fmt.Errorf("remarshal POSIX file %s: %w", relPath, err)
|
||||
}
|
||||
node.ContentJSON = jsonContent
|
||||
node.ResourceID = stringValue(payload["id"])
|
||||
node.ResourceName = stringValue(payload["name"])
|
||||
node.ResourceSlug = stringValue(payload["slug"])
|
||||
if node.ResourceID == "" && fileRole == "settings" && logicalType == "tenant" {
|
||||
installation, _ := payload["installation"].(map[string]any)
|
||||
organization, _ := payload["organization"].(map[string]any)
|
||||
node.ResourceID = stringValue(installation["id"])
|
||||
node.ResourceName = stringValue(installation["name"])
|
||||
node.InstallationID = stringValue(installation["id"])
|
||||
node.OrganizationID = stringValue(organization["id"])
|
||||
node.OrganizationSlug = firstNonEmpty(node.OrganizationSlug, stringValue(organization["slug"]))
|
||||
}
|
||||
if node.ResourceID == "" && fileRole == "users" {
|
||||
node.ResourceName = firstNonEmpty(node.ResourceName, parentEntityName(logicalType, scope))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if node.ResourceSlug == "" {
|
||||
node.ResourceSlug = inferredResourceSlug(logicalType, scope)
|
||||
}
|
||||
|
||||
return node, nil
|
||||
}
|
||||
|
||||
func deriveScope(relPath string, rootScope Scope) Scope {
|
||||
scope := rootScope
|
||||
parts := strings.Split(relPath, "/")
|
||||
for _, part := range parts {
|
||||
switch {
|
||||
case strings.HasPrefix(part, "department-"):
|
||||
scope.DepartmentSlug = strings.TrimPrefix(part, "department-")
|
||||
case strings.HasPrefix(part, "team-"):
|
||||
scope.TeamSlug = strings.TrimPrefix(part, "team-")
|
||||
case strings.HasPrefix(part, "project-"):
|
||||
scope.ProjectSlug = strings.TrimPrefix(part, "project-")
|
||||
case strings.HasPrefix(part, "personal-"):
|
||||
scope.PersonalSlug = strings.TrimPrefix(part, "personal-")
|
||||
}
|
||||
}
|
||||
return scope
|
||||
}
|
||||
|
||||
func classifyPath(relPath string, isDir bool) (logicalType, fileRole string) {
|
||||
parts := strings.Split(relPath, "/")
|
||||
name := parts[len(parts)-1]
|
||||
if !isDir {
|
||||
fileRole = strings.TrimSuffix(name, filepath.Ext(name))
|
||||
}
|
||||
|
||||
hasChildrenAncestor := pathContainsSegment(parts, "children")
|
||||
hasTreeAncestor := pathContainsSegment(parts, "tree")
|
||||
parentName := ""
|
||||
if len(parts) >= 2 {
|
||||
parentName = parts[len(parts)-2]
|
||||
}
|
||||
|
||||
switch {
|
||||
case relPath == "settings.json":
|
||||
return "tenant", "settings"
|
||||
case relPath == "layout.json":
|
||||
return "tenant", "layout"
|
||||
case len(parts) >= 1 && parts[0] == "catalog":
|
||||
if isDir {
|
||||
if len(parts) == 1 {
|
||||
return "catalog", ""
|
||||
}
|
||||
if len(parts) >= 2 && parts[1] == "packs" {
|
||||
if len(parts) == 2 {
|
||||
return "catalog_packs", ""
|
||||
}
|
||||
if len(parts) == 3 {
|
||||
return "catalog_pack", ""
|
||||
}
|
||||
if len(parts) >= 4 && parts[3] == "entries" {
|
||||
return "catalog_pack_entries", ""
|
||||
}
|
||||
return "catalog_entry", ""
|
||||
}
|
||||
if len(parts) >= 2 && parts[1] == "standalone" {
|
||||
if len(parts) == 2 {
|
||||
return "catalog_standalone", ""
|
||||
}
|
||||
return "catalog_entry", ""
|
||||
}
|
||||
}
|
||||
return "catalog", fileRole
|
||||
case len(parts) >= 2 && parts[0] == "departments" && strings.HasPrefix(parts[1], "department-"):
|
||||
if isDir {
|
||||
if len(parts) == 2 {
|
||||
return "department", ""
|
||||
}
|
||||
if len(parts) == 3 && parts[2] == "teams" {
|
||||
return "department_teams", ""
|
||||
}
|
||||
if len(parts) >= 4 && strings.HasPrefix(parts[3], "team-") {
|
||||
return "team", ""
|
||||
}
|
||||
}
|
||||
if len(parts) >= 4 && strings.HasPrefix(parts[3], "team-") {
|
||||
return "team", fileRole
|
||||
}
|
||||
return "department", fileRole
|
||||
case len(parts) >= 2 && parts[0] == "projects" && strings.HasPrefix(parts[1], "project-"):
|
||||
if isDir {
|
||||
if strings.HasPrefix(name, "project-") {
|
||||
return "project", ""
|
||||
}
|
||||
if name == "children" {
|
||||
return "project_children", ""
|
||||
}
|
||||
if name == "tree" {
|
||||
return "project_tree", ""
|
||||
}
|
||||
if hasChildrenAncestor && strings.HasPrefix(name, "folder-") {
|
||||
return "hierarchy_folder", ""
|
||||
}
|
||||
if hasTreeAncestor && strings.HasPrefix(name, "folder-") {
|
||||
return "hierarchy_folder", ""
|
||||
}
|
||||
if hasTreeAncestor && strings.HasPrefix(name, "item-") {
|
||||
return "item", ""
|
||||
}
|
||||
}
|
||||
if hasChildrenAncestor && strings.HasPrefix(parentName, "folder-") {
|
||||
return "hierarchy_folder", fileRole
|
||||
}
|
||||
if hasTreeAncestor {
|
||||
if strings.HasPrefix(parentName, "item-") {
|
||||
return "item", fileRole
|
||||
}
|
||||
if strings.HasPrefix(parentName, "folder-") {
|
||||
return "hierarchy_folder", fileRole
|
||||
}
|
||||
}
|
||||
return "project", fileRole
|
||||
case len(parts) >= 1 && parts[0] == "users":
|
||||
if isDir {
|
||||
if len(parts) == 1 {
|
||||
return "users", ""
|
||||
}
|
||||
if len(parts) == 2 && parts[1] == "personals" {
|
||||
return "personals", ""
|
||||
}
|
||||
if len(parts) >= 3 && parts[1] == "personals" && strings.HasPrefix(parts[2], "personal-") {
|
||||
return "personal", ""
|
||||
}
|
||||
if strings.Contains(relPath, "/tree/") || strings.HasSuffix(relPath, "/tree") {
|
||||
if strings.HasPrefix(name, "folder-") {
|
||||
return "folder", ""
|
||||
}
|
||||
if strings.HasPrefix(name, "item-") {
|
||||
return "item", ""
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(parts) >= 3 && parts[1] == "personals" && strings.HasPrefix(parts[2], "personal-") {
|
||||
if strings.Contains(relPath, "/tree/") {
|
||||
if strings.HasPrefix(parts[len(parts)-2], "item-") {
|
||||
return "item", fileRole
|
||||
}
|
||||
if strings.HasPrefix(parts[len(parts)-2], "folder-") {
|
||||
return "folder", fileRole
|
||||
}
|
||||
}
|
||||
return "personal", fileRole
|
||||
}
|
||||
return "users", fileRole
|
||||
default:
|
||||
if isDir {
|
||||
return "directory", ""
|
||||
}
|
||||
return "file", fileRole
|
||||
}
|
||||
}
|
||||
|
||||
func pathContainsSegment(parts []string, target string) bool {
|
||||
for _, part := range parts {
|
||||
if part == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func projectionParentPath(relPath string) *string {
|
||||
if relPath == "" || relPath == rootProjectionPath {
|
||||
return nil
|
||||
}
|
||||
parent := filepath.ToSlash(filepath.Dir(relPath))
|
||||
if parent == "." || parent == "" {
|
||||
root := rootProjectionPath
|
||||
return &root
|
||||
}
|
||||
return &parent
|
||||
}
|
||||
|
||||
func inferredResourceSlug(logicalType string, scope Scope) string {
|
||||
switch logicalType {
|
||||
case "department":
|
||||
return scope.DepartmentSlug
|
||||
case "team":
|
||||
return scope.TeamSlug
|
||||
case "project":
|
||||
return scope.ProjectSlug
|
||||
case "personal":
|
||||
return scope.PersonalSlug
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func parentEntityName(logicalType string, scope Scope) string {
|
||||
switch logicalType {
|
||||
case "department":
|
||||
return scope.DepartmentSlug
|
||||
case "team":
|
||||
return scope.TeamSlug
|
||||
case "project":
|
||||
return scope.ProjectSlug
|
||||
case "personal":
|
||||
return scope.PersonalSlug
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func stringValue(value any) string {
|
||||
stringValue, _ := value.(string)
|
||||
return strings.TrimSpace(stringValue)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func summarizeNodes(nodes []Node) RebuildSummary {
|
||||
summary := RebuildSummary{TotalNodes: len(nodes)}
|
||||
for _, node := range nodes {
|
||||
switch node.NodeKind {
|
||||
case NodeKindDirectory:
|
||||
summary.DirectoryCount++
|
||||
case NodeKindFile:
|
||||
summary.FileCount++
|
||||
}
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func errorsIsNotExist(err error) bool {
|
||||
return err != nil && os.IsNotExist(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
// Path: Backend/internal/posixproj/projector_classify.go
|
||||
|
||||
package posixproj
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func deriveScope(relPath string, rootScope Scope) Scope {
|
||||
scope := rootScope
|
||||
parts := strings.Split(relPath, "/")
|
||||
for _, part := range parts {
|
||||
switch {
|
||||
case strings.HasPrefix(part, "department-"):
|
||||
scope.DepartmentSlug = strings.TrimPrefix(part, "department-")
|
||||
case strings.HasPrefix(part, "team-"):
|
||||
scope.TeamSlug = strings.TrimPrefix(part, "team-")
|
||||
case strings.HasPrefix(part, "project-"):
|
||||
scope.ProjectSlug = strings.TrimPrefix(part, "project-")
|
||||
case strings.HasPrefix(part, "personal-"):
|
||||
scope.PersonalSlug = strings.TrimPrefix(part, "personal-")
|
||||
}
|
||||
}
|
||||
return scope
|
||||
}
|
||||
|
||||
func classifyPath(relPath string, isDir bool) (logicalType, fileRole string) {
|
||||
parts := strings.Split(relPath, "/")
|
||||
name := parts[len(parts)-1]
|
||||
if !isDir {
|
||||
fileRole = strings.TrimSuffix(name, filepath.Ext(name))
|
||||
}
|
||||
|
||||
hasChildrenAncestor := pathContainsSegment(parts, "children")
|
||||
hasTreeAncestor := pathContainsSegment(parts, "tree")
|
||||
parentName := ""
|
||||
if len(parts) >= 2 {
|
||||
parentName = parts[len(parts)-2]
|
||||
}
|
||||
|
||||
switch {
|
||||
case relPath == "settings.json":
|
||||
return "tenant", "settings"
|
||||
case relPath == "layout.json":
|
||||
return "tenant", "layout"
|
||||
case len(parts) >= 1 && parts[0] == "catalog":
|
||||
if isDir {
|
||||
if len(parts) == 1 {
|
||||
return "catalog", ""
|
||||
}
|
||||
if len(parts) >= 2 && parts[1] == "packs" {
|
||||
if len(parts) == 2 { return "catalog_packs", "" }
|
||||
if len(parts) == 3 { return "catalog_pack", "" }
|
||||
if len(parts) >= 4 && parts[3] == "entries" { return "catalog_pack_entries", "" }
|
||||
return "catalog_entry", ""
|
||||
}
|
||||
if len(parts) >= 2 && parts[1] == "standalone" {
|
||||
if len(parts) == 2 { return "catalog_standalone", "" }
|
||||
return "catalog_entry", ""
|
||||
}
|
||||
}
|
||||
return "catalog", fileRole
|
||||
case len(parts) >= 2 && parts[0] == "departments" && strings.HasPrefix(parts[1], "department-"):
|
||||
if isDir {
|
||||
if len(parts) == 2 { return "department", "" }
|
||||
if len(parts) == 3 && parts[2] == "teams" { return "department_teams", "" }
|
||||
if len(parts) >= 4 && strings.HasPrefix(parts[3], "team-") { return "team", "" }
|
||||
}
|
||||
if len(parts) >= 4 && strings.HasPrefix(parts[3], "team-") { return "team", fileRole }
|
||||
return "department", fileRole
|
||||
case len(parts) >= 2 && parts[0] == "projects" && strings.HasPrefix(parts[1], "project-"):
|
||||
if isDir {
|
||||
if strings.HasPrefix(name, "project-") { return "project", "" }
|
||||
if name == "children" { return "project_children", "" }
|
||||
if name == "tree" { return "project_tree", "" }
|
||||
if hasChildrenAncestor && strings.HasPrefix(name, "folder-") { return "hierarchy_folder", "" }
|
||||
if hasTreeAncestor && strings.HasPrefix(name, "folder-") { return "hierarchy_folder", "" }
|
||||
if hasTreeAncestor && strings.HasPrefix(name, "item-") { return "item", "" }
|
||||
}
|
||||
if hasChildrenAncestor && strings.HasPrefix(parentName, "folder-") { return "hierarchy_folder", fileRole }
|
||||
if hasTreeAncestor {
|
||||
if strings.HasPrefix(parentName, "item-") { return "item", fileRole }
|
||||
if strings.HasPrefix(parentName, "folder-") { return "hierarchy_folder", fileRole }
|
||||
}
|
||||
return "project", fileRole
|
||||
case len(parts) >= 1 && parts[0] == "users":
|
||||
if isDir {
|
||||
if len(parts) == 1 { return "users", "" }
|
||||
if len(parts) == 2 && parts[1] == "personals" { return "personals", "" }
|
||||
if len(parts) >= 3 && parts[1] == "personals" && strings.HasPrefix(parts[2], "personal-") { return "personal", "" }
|
||||
if strings.Contains(relPath, "/tree/") || strings.HasSuffix(relPath, "/tree") {
|
||||
if strings.HasPrefix(name, "folder-") { return "folder", "" }
|
||||
if strings.HasPrefix(name, "item-") { return "item", "" }
|
||||
}
|
||||
}
|
||||
if len(parts) >= 3 && parts[1] == "personals" && strings.HasPrefix(parts[2], "personal-") {
|
||||
if strings.Contains(relPath, "/tree/") {
|
||||
if strings.HasPrefix(parts[len(parts)-2], "item-") { return "item", fileRole }
|
||||
if strings.HasPrefix(parts[len(parts)-2], "folder-") { return "folder", fileRole }
|
||||
}
|
||||
return "personal", fileRole
|
||||
}
|
||||
return "users", fileRole
|
||||
default:
|
||||
if isDir { return "directory", "" }
|
||||
return "file", fileRole
|
||||
}
|
||||
}
|
||||
|
||||
func pathContainsSegment(parts []string, target string) bool {
|
||||
for _, part := range parts {
|
||||
if part == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func projectionParentPath(relPath string) *string {
|
||||
if relPath == "" || relPath == rootProjectionPath {
|
||||
return nil
|
||||
}
|
||||
parent := filepath.ToSlash(filepath.Dir(relPath))
|
||||
if parent == "." || parent == "" {
|
||||
root := rootProjectionPath
|
||||
return &root
|
||||
}
|
||||
return &parent
|
||||
}
|
||||
|
||||
func inferredResourceSlug(logicalType string, scope Scope) string {
|
||||
switch logicalType {
|
||||
case "department":
|
||||
return scope.DepartmentSlug
|
||||
case "team":
|
||||
return scope.TeamSlug
|
||||
case "project":
|
||||
return scope.ProjectSlug
|
||||
case "personal":
|
||||
return scope.PersonalSlug
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func parentEntityName(logicalType string, scope Scope) string {
|
||||
switch logicalType {
|
||||
case "department":
|
||||
return scope.DepartmentSlug
|
||||
case "team":
|
||||
return scope.TeamSlug
|
||||
case "project":
|
||||
return scope.ProjectSlug
|
||||
case "personal":
|
||||
return scope.PersonalSlug
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func stringValue(value any) string {
|
||||
stringValue, _ := value.(string)
|
||||
return strings.TrimSpace(stringValue)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// Path: Backend/internal/posixproj/projector_scan.go
|
||||
|
||||
package posixproj
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ScanRoot(root string) ([]Node, error) {
|
||||
rootPath := strings.TrimSpace(root)
|
||||
if rootPath == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
info, err := os.Stat(rootPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat POSIX root: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("POSIX root is not a directory: %s", rootPath)
|
||||
}
|
||||
|
||||
rootScope, err := loadRootScope(rootPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nodes := []Node{{
|
||||
Path: rootProjectionPath,
|
||||
ParentPath: nil,
|
||||
Name: filepath.Base(rootPath),
|
||||
Depth: 0,
|
||||
NodeKind: NodeKindDirectory,
|
||||
LogicalType: "tenant_root",
|
||||
InstallationID: rootScope.InstallationID,
|
||||
OrganizationID: rootScope.OrganizationID,
|
||||
OrganizationSlug: rootScope.OrganizationSlug,
|
||||
}}
|
||||
|
||||
err = filepath.WalkDir(rootPath, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if path == rootPath {
|
||||
return nil
|
||||
}
|
||||
|
||||
relPath, err := filepath.Rel(rootPath, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
relPath = filepath.ToSlash(relPath)
|
||||
if relPath == "." {
|
||||
return nil
|
||||
}
|
||||
|
||||
node, err := buildNode(rootPath, relPath, entry, rootScope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
nodes = append(nodes, node)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan POSIX root: %w", err)
|
||||
}
|
||||
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
func loadRootScope(rootPath string) (Scope, error) {
|
||||
settingsPath := filepath.Join(rootPath, "settings.json")
|
||||
content, err := os.ReadFile(settingsPath)
|
||||
if err != nil {
|
||||
if errorsIsNotExist(err) {
|
||||
return Scope{}, nil
|
||||
}
|
||||
return Scope{}, fmt.Errorf("read root settings.json: %w", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(content, &payload); err != nil {
|
||||
return Scope{}, fmt.Errorf("decode root settings.json: %w", err)
|
||||
}
|
||||
|
||||
installation, _ := payload["installation"].(map[string]any)
|
||||
organization, _ := payload["organization"].(map[string]any)
|
||||
|
||||
return Scope{
|
||||
InstallationID: stringValue(installation["id"]),
|
||||
OrganizationID: stringValue(organization["id"]),
|
||||
OrganizationSlug: stringValue(organization["slug"]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildNode(rootPath, relPath string, entry fs.DirEntry, rootScope Scope) (Node, error) {
|
||||
scope := deriveScope(relPath, rootScope)
|
||||
parentPath := projectionParentPath(relPath)
|
||||
logicalType, fileRole := classifyPath(relPath, entry.IsDir())
|
||||
|
||||
node := Node{
|
||||
Path: relPath,
|
||||
ParentPath: parentPath,
|
||||
Name: entry.Name(),
|
||||
Depth: strings.Count(relPath, "/") + 1,
|
||||
NodeKind: NodeKindDirectory,
|
||||
LogicalType: logicalType,
|
||||
FileRole: fileRole,
|
||||
InstallationID: scope.InstallationID,
|
||||
OrganizationID: scope.OrganizationID,
|
||||
OrganizationSlug: scope.OrganizationSlug,
|
||||
DepartmentSlug: scope.DepartmentSlug,
|
||||
TeamSlug: scope.TeamSlug,
|
||||
ProjectSlug: scope.ProjectSlug,
|
||||
PersonalSlug: scope.PersonalSlug,
|
||||
}
|
||||
|
||||
if entry.IsDir() {
|
||||
return node, nil
|
||||
}
|
||||
|
||||
absPath := filepath.Join(rootPath, filepath.FromSlash(relPath))
|
||||
content, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return Node{}, fmt.Errorf("read POSIX file %s: %w", relPath, err)
|
||||
}
|
||||
|
||||
hash := sha256.Sum256(content)
|
||||
node.NodeKind = NodeKindFile
|
||||
node.SizeBytes = int64(len(content))
|
||||
node.Checksum = hex.EncodeToString(hash[:])
|
||||
|
||||
if strings.EqualFold(filepath.Ext(entry.Name()), ".json") {
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(content, &payload); err == nil {
|
||||
jsonContent, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return Node{}, fmt.Errorf("remarshal POSIX file %s: %w", relPath, err)
|
||||
}
|
||||
node.ContentJSON = jsonContent
|
||||
node.ResourceID = stringValue(payload["id"])
|
||||
node.ResourceName = stringValue(payload["name"])
|
||||
node.ResourceSlug = stringValue(payload["slug"])
|
||||
if node.ResourceID == "" && fileRole == "settings" && logicalType == "tenant" {
|
||||
installation, _ := payload["installation"].(map[string]any)
|
||||
organization, _ := payload["organization"].(map[string]any)
|
||||
node.ResourceID = stringValue(installation["id"])
|
||||
node.ResourceName = stringValue(installation["name"])
|
||||
node.InstallationID = stringValue(installation["id"])
|
||||
node.OrganizationID = stringValue(organization["id"])
|
||||
node.OrganizationSlug = firstNonEmpty(node.OrganizationSlug, stringValue(organization["slug"]))
|
||||
}
|
||||
if node.ResourceID == "" && fileRole == "users" {
|
||||
node.ResourceName = firstNonEmpty(node.ResourceName, parentEntityName(logicalType, scope))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if node.ResourceSlug == "" {
|
||||
node.ResourceSlug = inferredResourceSlug(logicalType, scope)
|
||||
}
|
||||
|
||||
return node, nil
|
||||
}
|
||||
|
||||
func summarizeNodes(nodes []Node) RebuildSummary {
|
||||
summary := RebuildSummary{TotalNodes: len(nodes)}
|
||||
for _, node := range nodes {
|
||||
switch node.NodeKind {
|
||||
case NodeKindDirectory:
|
||||
summary.DirectoryCount++
|
||||
case NodeKindFile:
|
||||
summary.FileCount++
|
||||
}
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func errorsIsNotExist(err error) bool {
|
||||
return err != nil && os.IsNotExist(err)
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
// Path: Backend/internal/posixproj/projector_test.go
|
||||
|
||||
package posixproj
|
||||
|
||||
import (
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// Path: Backend/internal/posixproj/projector_types.go
|
||||
|
||||
package posixproj
|
||||
|
||||
import "moku-backend/internal/database"
|
||||
|
||||
const rootProjectionPath = "/"
|
||||
|
||||
type Projector struct {
|
||||
db *database.DB
|
||||
root string
|
||||
}
|
||||
|
||||
type RebuildSummary struct {
|
||||
TotalNodes int
|
||||
DirectoryCount int
|
||||
FileCount int
|
||||
}
|
||||
|
||||
type NodeKind string
|
||||
|
||||
const (
|
||||
NodeKindDirectory NodeKind = "directory"
|
||||
NodeKindFile NodeKind = "file"
|
||||
)
|
||||
|
||||
type Scope struct {
|
||||
InstallationID string
|
||||
OrganizationID string
|
||||
OrganizationSlug string
|
||||
DepartmentSlug string
|
||||
TeamSlug string
|
||||
ProjectSlug string
|
||||
PersonalSlug string
|
||||
}
|
||||
|
||||
type Node struct {
|
||||
Path string
|
||||
ParentPath *string
|
||||
Name string
|
||||
Depth int
|
||||
NodeKind NodeKind
|
||||
LogicalType string
|
||||
FileRole string
|
||||
ResourceID string
|
||||
ResourceName string
|
||||
ResourceSlug string
|
||||
InstallationID string
|
||||
OrganizationID string
|
||||
OrganizationSlug string
|
||||
DepartmentSlug string
|
||||
TeamSlug string
|
||||
ProjectSlug string
|
||||
PersonalSlug string
|
||||
ContentJSON []byte
|
||||
SizeBytes int64
|
||||
Checksum string
|
||||
}
|
||||
Reference in New Issue
Block a user