234 lines
5.8 KiB
Go
234 lines
5.8 KiB
Go
// Path: Backend/internal/bootstrap/project_disk_helpers.go
|
|
|
|
package bootstrap
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"github.com/fxamacker/cbor/v2"
|
|
"github.com/tailscale/hujson"
|
|
)
|
|
|
|
const (
|
|
posixSettingsFileName = "settings.cbor"
|
|
posixLayoutFileName = "layout.cbor"
|
|
posixHomeFileName = "home.cbor"
|
|
posixUsersFileName = "users.cbor"
|
|
posixACLFileName = "acl.cbor"
|
|
posixFolderFileName = "folder.cbor"
|
|
posixItemFileName = "item.cbor"
|
|
posixDataFileName = "data.cbor"
|
|
posixSchemaFileName = "schema.json"
|
|
posixManifestFileName = "manifest.jsonc"
|
|
)
|
|
|
|
func samePath(left, right string) bool {
|
|
cleanLeft := filepath.Clean(left)
|
|
cleanRight := filepath.Clean(right)
|
|
if cleanLeft == cleanRight {
|
|
return true
|
|
}
|
|
leftInfo, leftErr := os.Stat(cleanLeft)
|
|
rightInfo, rightErr := os.Stat(cleanRight)
|
|
if leftErr == nil && rightErr == nil {
|
|
return os.SameFile(leftInfo, rightInfo)
|
|
}
|
|
return false
|
|
}
|
|
|
|
func normalizePOSIXSlug(value string) string {
|
|
trimmed := strings.TrimSpace(strings.ToLower(value))
|
|
if trimmed == "" {
|
|
return "untitled"
|
|
}
|
|
var builder strings.Builder
|
|
lastDash := false
|
|
for _, r := range trimmed {
|
|
switch {
|
|
case unicode.IsLetter(r) || unicode.IsDigit(r):
|
|
builder.WriteRune(r)
|
|
lastDash = false
|
|
case r == '-' || r == '_' || unicode.IsSpace(r):
|
|
if !lastDash && builder.Len() > 0 {
|
|
builder.WriteByte('-')
|
|
lastDash = true
|
|
}
|
|
}
|
|
}
|
|
slug := strings.Trim(builder.String(), "-")
|
|
if slug == "" {
|
|
return "untitled"
|
|
}
|
|
return slug
|
|
}
|
|
|
|
func fallbackFolderLabel(path string) string {
|
|
base := filepath.Base(filepath.FromSlash(path))
|
|
trimmed := strings.TrimPrefix(base, "folder-")
|
|
parts := strings.FieldsFunc(trimmed, func(r rune) bool { return r == '-' || r == '_' })
|
|
for index, part := range parts {
|
|
if part != "" {
|
|
parts[index] = strings.ToUpper(part[:1]) + part[1:]
|
|
}
|
|
}
|
|
label := strings.Join(parts, " ")
|
|
if label == "" {
|
|
return base
|
|
}
|
|
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 != "" {
|
|
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 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 == "" {
|
|
return prefix
|
|
}
|
|
return fmt.Sprintf("%s-%s", prefix, trimmedSlug)
|
|
}
|
|
|
|
func writeJSONFile(path string, payload any) error {
|
|
parentDir := filepath.Dir(path)
|
|
if err := os.MkdirAll(parentDir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
data, err := json.MarshalIndent(payload, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data = append(data, '\n')
|
|
return os.WriteFile(path, data, 0o644)
|
|
}
|
|
|
|
func writeJSONCFile(path string, payload any) error {
|
|
parentDir := filepath.Dir(path)
|
|
if err := os.MkdirAll(parentDir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
data, err := json.MarshalIndent(payload, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
content := append([]byte("// This file is JSONC. Comments and trailing commas are allowed.\n"), data...)
|
|
content = append(content, '\n')
|
|
return os.WriteFile(path, content, 0o644)
|
|
}
|
|
|
|
func writeCBORFile(path string, payload any) error {
|
|
parentDir := filepath.Dir(path)
|
|
if err := os.MkdirAll(parentDir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
data, err := cbor.Marshal(payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(path, data, 0o644)
|
|
}
|
|
|
|
func readStructuredFileMap(path string) map[string]any {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return map[string]any{}
|
|
}
|
|
var payload any
|
|
switch strings.ToLower(filepath.Ext(path)) {
|
|
case ".cbor":
|
|
err = cbor.Unmarshal(data, &payload)
|
|
case ".json":
|
|
err = json.Unmarshal(data, &payload)
|
|
case ".jsonc":
|
|
payload, err = decodeJSONCToAny(data)
|
|
default:
|
|
return map[string]any{}
|
|
}
|
|
if err != nil || payload == nil {
|
|
return map[string]any{}
|
|
}
|
|
normalized, ok := normalizeStructuredValue(payload).(map[string]any)
|
|
if !ok || normalized == nil {
|
|
return map[string]any{}
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
func decodeJSONCToAny(data []byte) (any, error) {
|
|
ast, err := hujson.Parse(data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ast.Standardize()
|
|
standardized := ast.Pack()
|
|
var payload any
|
|
if err := json.Unmarshal(standardized, &payload); err != nil {
|
|
return nil, err
|
|
}
|
|
return payload, nil
|
|
}
|
|
|
|
func normalizeStructuredValue(value any) any {
|
|
switch typed := value.(type) {
|
|
case map[string]any:
|
|
normalized := make(map[string]any, len(typed))
|
|
for key, child := range typed {
|
|
normalized[key] = normalizeStructuredValue(child)
|
|
}
|
|
return normalized
|
|
case map[any]any:
|
|
normalized := make(map[string]any, len(typed))
|
|
for key, child := range typed {
|
|
normalized[fmt.Sprint(key)] = normalizeStructuredValue(child)
|
|
}
|
|
return normalized
|
|
case []any:
|
|
normalized := make([]any, len(typed))
|
|
for index, child := range typed {
|
|
normalized[index] = normalizeStructuredValue(child)
|
|
}
|
|
return normalized
|
|
default:
|
|
return value
|
|
}
|
|
}
|