Files
wanderer/db/util/category.go
slothful-vassal 3b8f00fd26 feat: Refine trail category model (#1059)
* feat: advanced trail categories

* rename remote_category

* fix merge issues

* subcategories for plugins mapping

* fix refresh

* remove vertical trail filter category scrolling

* cleanup

* add confirm modal for disabling a category

* Fix federation issues

* fix review findings

* redesign category settings page

* remove prio badge

* fix subcategory badge layout

* optimize subcategory settings layout

* further settings page layout optimization

* fix subcategory icon position

* fix

* update docs

---------

Co-authored-by: Christian Beutel <>
2026-06-29 03:16:26 +02:00

217 lines
5.2 KiB
Go

package util
import (
"encoding/json"
"fmt"
"strings"
"unicode"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/text/cases"
"golang.org/x/text/language"
"golang.org/x/text/unicode/norm"
)
type CategoryTranslation struct {
Name string `json:"name"`
ShortName string `json:"short_name"`
}
func NormalizeCategoryName(name string) string {
decomposed := norm.NFD.String(name)
var b strings.Builder
b.Grow(len(decomposed))
for _, r := range decomposed {
if unicode.Is(unicode.Mn, r) {
continue
}
b.WriteRune(r)
}
folded := cases.Fold().String(b.String())
b.Reset()
b.Grow(len(folded))
lastWasSeparator := false
for _, r := range folded {
if unicode.IsSpace(r) || r == '-' || r == '_' {
if !lastWasSeparator {
b.WriteByte(' ')
lastWasSeparator = true
}
continue
}
b.WriteRune(r)
lastWasSeparator = false
}
return strings.TrimSpace(b.String())
}
func ParseCategoryTranslations(raw any) (map[string]CategoryTranslation, error) {
if raw == nil {
return nil, nil
}
switch value := raw.(type) {
case map[string]CategoryTranslation:
return value, nil
case map[string]any:
return normalizeCategoryTranslations(value)
case types.JSONRaw:
if len(value) == 0 {
return nil, nil
}
var decoded map[string]any
if err := json.Unmarshal(value, &decoded); err != nil {
return nil, fmt.Errorf("translations must be valid JSON: %w", err)
}
return normalizeCategoryTranslations(decoded)
case []byte:
if len(value) == 0 {
return nil, nil
}
var decoded map[string]any
if err := json.Unmarshal(value, &decoded); err != nil {
return nil, fmt.Errorf("translations must be valid JSON: %w", err)
}
return normalizeCategoryTranslations(decoded)
case string:
if strings.TrimSpace(value) == "" {
return nil, nil
}
var decoded map[string]any
if err := json.Unmarshal([]byte(value), &decoded); err != nil {
return nil, fmt.Errorf("translations must be valid JSON: %w", err)
}
return normalizeCategoryTranslations(decoded)
default:
return nil, fmt.Errorf("translations must be a JSON object")
}
}
func ValidateCategoryRecord(app core.App, record *core.Record) error {
name := record.GetString("name")
normalizedName := NormalizeCategoryName(name)
allCategories, err := app.FindAllRecords("categories")
if err != nil {
return err
}
for _, existing := range allCategories {
if existing.Id == record.Id {
continue
}
if NormalizeCategoryName(existing.GetString("name")) == normalizedName {
return fmt.Errorf("category name %q collides with existing category %q after normalization", name, existing.GetString("name"))
}
}
if _, err := ParseCategoryTranslations(record.Get("translations")); err != nil {
return err
}
return nil
}
func FindCategoryByNormalizedName(app core.App, name string) (*core.Record, error) {
normalizedName := NormalizeCategoryName(name)
if normalizedName == "" {
return nil, nil
}
allCategories, err := app.FindAllRecords("categories")
if err != nil {
return nil, err
}
for _, category := range allCategories {
if NormalizeCategoryName(category.GetString("name")) == normalizedName {
return category, nil
}
}
return nil, nil
}
func ValidateCategoryCollectionState(app core.App) error {
allCategories, err := app.FindAllRecords("categories")
if err != nil {
return err
}
seen := map[string]string{}
for _, category := range allCategories {
name := category.GetString("name")
normalizedName := NormalizeCategoryName(name)
if other, ok := seen[normalizedName]; ok {
return fmt.Errorf("category normalization collision: %q conflicts with %q", name, other)
}
seen[normalizedName] = name
if _, err := ParseCategoryTranslations(category.Get("translations")); err != nil {
return fmt.Errorf("invalid translations for category %q: %w", name, err)
}
}
return nil
}
func normalizeCategoryTranslations(raw map[string]any) (map[string]CategoryTranslation, error) {
if len(raw) == 0 {
return nil, nil
}
translations := make(map[string]CategoryTranslation, len(raw))
for locale, entry := range raw {
tag, err := language.Parse(locale)
if err != nil {
return nil, fmt.Errorf("translations locale %q is invalid", locale)
}
base, _ := tag.Base()
if locale != base.String() {
return nil, fmt.Errorf("translations locale %q must use the base locale %q", locale, base.String())
}
if _, ok := supportedCategoryLocales[base.String()]; !ok {
return nil, fmt.Errorf("translations locale %q is not supported", locale)
}
entryMap, ok := entry.(map[string]any)
if !ok {
return nil, fmt.Errorf("translations[%s] must be an object", locale)
}
translation := CategoryTranslation{}
if name, ok := entryMap["name"]; ok {
nameString, ok := name.(string)
if !ok {
return nil, fmt.Errorf("translations[%s].name must be a string", locale)
}
translation.Name = nameString
}
if shortName, ok := entryMap["short_name"]; ok {
shortNameString, ok := shortName.(string)
if !ok {
return nil, fmt.Errorf("translations[%s].short_name must be a string", locale)
}
translation.ShortName = shortNameString
}
translations[locale] = translation
}
return translations, nil
}