532 lines
13 KiB
Go
532 lines
13 KiB
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)}
|
|
}
|
|
|
|
func (projector *Projector) Rebuild(ctx context.Context) error {
|
|
_, err := projector.RebuildWithSummary(ctx)
|
|
return err
|
|
}
|
|
|
|
func (projector *Projector) RebuildWithSummary(ctx context.Context) (RebuildSummary, error) {
|
|
if projector == nil || projector.db == nil || projector.db.Pool == nil {
|
|
return RebuildSummary{}, nil
|
|
}
|
|
|
|
nodes, err := ScanRoot(projector.root)
|
|
if err != nil {
|
|
return RebuildSummary{}, err
|
|
}
|
|
|
|
summary := summarizeNodes(nodes)
|
|
|
|
tx, err := projector.db.Pool.Begin(ctx)
|
|
if err != nil {
|
|
return RebuildSummary{}, err
|
|
}
|
|
defer func() {
|
|
_ = tx.Rollback(ctx)
|
|
}()
|
|
|
|
if _, err := tx.Exec(ctx, `DELETE FROM posix_nodes;`); err != nil {
|
|
return RebuildSummary{}, fmt.Errorf("clear posix_nodes: %w", err)
|
|
}
|
|
|
|
for _, node := range nodes {
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO posix_nodes (
|
|
path,
|
|
parent_path,
|
|
name,
|
|
depth,
|
|
node_kind,
|
|
logical_type,
|
|
file_role,
|
|
resource_id,
|
|
resource_name,
|
|
resource_slug,
|
|
installation_id,
|
|
organization_id,
|
|
organization_slug,
|
|
department_slug,
|
|
team_slug,
|
|
project_slug,
|
|
personal_slug,
|
|
content_json,
|
|
size_bytes,
|
|
checksum
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5::posix_node_kind, $6, $7, $8, $9, $10,
|
|
$11, $12, $13, $14, $15, $16, $17, $18::jsonb, $19, $20
|
|
);
|
|
`,
|
|
node.Path,
|
|
node.ParentPath,
|
|
node.Name,
|
|
node.Depth,
|
|
string(node.NodeKind),
|
|
node.LogicalType,
|
|
node.FileRole,
|
|
node.ResourceID,
|
|
node.ResourceName,
|
|
node.ResourceSlug,
|
|
node.InstallationID,
|
|
node.OrganizationID,
|
|
node.OrganizationSlug,
|
|
node.DepartmentSlug,
|
|
node.TeamSlug,
|
|
node.ProjectSlug,
|
|
node.PersonalSlug,
|
|
node.ContentJSON,
|
|
node.SizeBytes,
|
|
node.Checksum,
|
|
); err != nil {
|
|
return RebuildSummary{}, fmt.Errorf("insert posix node %s: %w", node.Path, err)
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return RebuildSummary{}, err
|
|
}
|
|
|
|
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, "/")
|
|
if len(parts) >= 2 && parts[0] == "departments" && strings.HasPrefix(parts[1], "department-") {
|
|
scope.DepartmentSlug = strings.TrimPrefix(parts[1], "department-")
|
|
}
|
|
if len(parts) >= 4 && parts[0] == "departments" && parts[2] == "teams" && strings.HasPrefix(parts[3], "team-") {
|
|
scope.DepartmentSlug = strings.TrimPrefix(parts[1], "department-")
|
|
scope.TeamSlug = strings.TrimPrefix(parts[3], "team-")
|
|
}
|
|
if len(parts) >= 2 && parts[0] == "projects" && strings.HasPrefix(parts[1], "project-") {
|
|
scope.ProjectSlug = strings.TrimPrefix(parts[1], "project-")
|
|
}
|
|
if len(parts) >= 3 && parts[0] == "users" && parts[1] == "personals" && strings.HasPrefix(parts[2], "personal-") {
|
|
scope.PersonalSlug = strings.TrimPrefix(parts[2], "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))
|
|
}
|
|
|
|
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 len(parts) == 2 {
|
|
return "project", ""
|
|
}
|
|
if len(parts) == 3 && parts[2] == "tree" {
|
|
return "project_tree", ""
|
|
}
|
|
if strings.Contains(relPath, "/tree/") || strings.HasSuffix(relPath, "/tree") {
|
|
if strings.HasPrefix(name, "folder-") {
|
|
return "folder", ""
|
|
}
|
|
if strings.HasPrefix(name, "item-") {
|
|
return "item", ""
|
|
}
|
|
}
|
|
}
|
|
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 "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 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)
|
|
}
|