// 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 } }