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 <>
This commit is contained in:
slothful-vassal
2026-06-29 03:16:26 +02:00
committed by GitHub
parent 50cb6c7f5f
commit 3b8f00fd26
116 changed files with 9363 additions and 453 deletions

View File

@@ -173,7 +173,15 @@ func TrailFromActivity(activity pub.Activity, app core.App, actor *core.Record)
}
} else {
// this trail exists already
// ensure that it is fully synced to catch waypoint/summit log updates
// keep searchable category metadata fresh from the update activity while
// still requiring a full sync to catch waypoint/summit log updates.
categoryMetadata, err := trailCategoryMetadataFromActivityObject(t)
if err != nil {
return nil, err
}
if err := applyTrailActivityCategoryMetadata(app, record, categoryMetadata); err != nil {
return nil, err
}
record.Set("needs_full_sync", true)
err = app.Save(record)
@@ -185,22 +193,22 @@ func TrailFromActivity(activity pub.Activity, app core.App, actor *core.Record)
}
var distance, duration, elevation_gain, elevation_loss float64
var diffculty, category string
var diffculty string
trailTags := []string{}
tags, err := pub.ToItemCollection(t.Tag)
if err != nil {
return nil, err
}
categoryMetadata := trailCategoryMetadataFromTags(tags)
for _, tag := range tags.Collection() {
tagObj, err := pub.ToObject(tag)
if err != nil {
continue
}
content := tagObj.Content.First().Value.String()
switch tagObj.Name.First().Value.String() {
case "category":
category = content
case "difficulty":
diffculty = content
case "elevation_gain":
@@ -254,9 +262,8 @@ func TrailFromActivity(activity pub.Activity, app core.App, actor *core.Record)
record.Set("author", actor.Id)
record.Set("needs_full_sync", true)
categoryRecord, err := app.FindFirstRecordByData("categories", "name", category)
if err == nil {
record.Set("category", categoryRecord.Id)
if err := applyTrailActivityCategoryMetadata(app, record, categoryMetadata); err != nil {
return nil, err
}
if t.Attachment != nil {
@@ -282,12 +289,12 @@ func TrailFromActivity(activity pub.Activity, app core.App, actor *core.Record)
if len(photoURLs) > 0 {
photos := []*filesystem.File{}
for i, purl := range photoURLs {
for _, purl := range photoURLs {
photo, err := filesystem.NewFileFromURL(context.Background(), purl)
if err != nil {
continue
}
photos[i] = photo
photos = append(photos, photo)
}
record.Set("photos", photos)
@@ -306,6 +313,80 @@ func TrailFromActivity(activity pub.Activity, app core.App, actor *core.Record)
return record, app.Save(record)
}
type trailActivityCategoryMetadata struct {
category string
subcategory string
categorySet bool
subcategorySet bool
}
func trailCategoryMetadataFromActivityObject(object *pub.Object) (trailActivityCategoryMetadata, error) {
if len(object.Tag) == 0 {
return trailActivityCategoryMetadata{}, nil
}
tags, err := pub.ToItemCollection(object.Tag)
if err != nil {
return trailActivityCategoryMetadata{}, err
}
return trailCategoryMetadataFromTags(tags), nil
}
func trailCategoryMetadataFromTags(tags *pub.ItemCollection) trailActivityCategoryMetadata {
metadata := trailActivityCategoryMetadata{}
for _, tag := range tags.Collection() {
tagObj, err := pub.ToObject(tag)
if err != nil {
continue
}
switch tagObj.Name.First().Value.String() {
case "category":
metadata.category = tagObj.Content.First().Value.String()
metadata.categorySet = true
case "subcategory":
metadata.subcategory = tagObj.Content.First().Value.String()
metadata.subcategorySet = true
}
}
return metadata
}
func applyTrailActivityCategoryMetadata(app core.App, record *core.Record, metadata trailActivityCategoryMetadata) error {
if metadata.categorySet {
record.Set("federated_category_name", metadata.category)
}
if metadata.subcategorySet {
record.Set("federated_subcategory_name", metadata.subcategory)
} else if metadata.categorySet {
record.Set("federated_subcategory_name", "")
}
if !metadata.categorySet {
return nil
}
categoryRecord, subcategoryRecord, err := ResolveCategoryAndSubcategoryByNormalizedNames(app, metadata.category, metadata.subcategory)
if err != nil {
return err
}
if categoryRecord != nil {
record.Set("category", categoryRecord.Id)
if subcategoryRecord != nil {
record.Set("subcategory", subcategoryRecord.Id)
} else {
record.Set("subcategory", "")
}
} else {
record.Set("category", "")
record.Set("subcategory", "")
}
return nil
}
func ObjectFromTrail(app core.App, trail *core.Record, mentions *pub.ItemCollection) (*pub.Object, error) {
origin := os.Getenv("ORIGIN")
if origin == "" {
@@ -320,9 +401,9 @@ func ObjectFromTrail(app core.App, trail *core.Record, mentions *pub.ItemCollect
if len(errs) > 0 {
return nil, fmt.Errorf("failed to expand tags: %v", errs)
}
errs = app.ExpandRecord(trail, []string{"category"}, nil)
errs = app.ExpandRecord(trail, []string{"category", "subcategory"}, nil)
if len(errs) > 0 {
return nil, fmt.Errorf("failed to expand category: %v", errs)
return nil, fmt.Errorf("failed to expand category/subcategory: %v", errs)
}
category := ""
@@ -330,6 +411,11 @@ func ObjectFromTrail(app core.App, trail *core.Record, mentions *pub.ItemCollect
if categoryRecord != nil {
category = categoryRecord.GetString("name")
}
subcategory := ""
subcategoryRecord := trail.ExpandedOne("subcategory")
if subcategoryRecord != nil {
subcategory = subcategoryRecord.GetString("name")
}
tagRecords := trail.ExpandedAll("tags")
@@ -372,6 +458,14 @@ func ObjectFromTrail(app core.App, trail *core.Record, mentions *pub.ItemCollect
}
}
if subcategory != "" {
tags.Append(pub.Object{
Type: pub.NoteType,
Name: pub.NaturalLanguageValuesNew(pub.LangRefValueNew(pub.NilLangRef, "subcategory")),
Content: pub.NaturalLanguageValuesNew(pub.LangRefValueNew(pub.NilLangRef, subcategory)),
})
}
for _, v := range tagRecords {
hashtag := pub.ObjectNew(pub.NoteType)
hashtag.Name = pub.NaturalLanguageValuesNew(pub.LangRefValueNew(pub.NilLangRef, "tag"))

216
db/util/category.go Normal file
View File

@@ -0,0 +1,216 @@
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
}

View File

@@ -0,0 +1,289 @@
package util
import (
"fmt"
"github.com/pocketbase/pocketbase/core"
)
var supportedCategoryLocales = map[string]struct{}{
"cs": {},
"de": {},
"en": {},
"es": {},
"eu": {},
"fr": {},
"hu": {},
"it": {},
"nl": {},
"no": {},
"pl": {},
"pt": {},
"ru": {},
"zh": {},
}
var defaultCategoryNames = []string{"Hiking", "Walking", "Running", "Climbing", "Skiing", "Canoeing", "Biking", "Other"}
func DefaultCategoryNames() []string {
return append([]string(nil), defaultCategoryNames...)
}
func SeedDefaultCategories(app core.App) error {
collection, err := app.FindCollectionByNameOrId("categories")
if err != nil {
return err
}
allCategories, err := app.FindAllRecords("categories")
if err != nil {
return err
}
existing := make(map[string]struct{}, len(allCategories))
for _, category := range allCategories {
existing[NormalizeCategoryName(category.GetString("name"))] = struct{}{}
}
for _, name := range defaultCategoryNames {
if _, ok := existing[NormalizeCategoryName(name)]; ok {
continue
}
record := core.NewRecord(collection)
record.Set("name", name)
if collection.Fields.GetByName("settings") != nil {
record.Set("settings", defaultCategorySettings())
}
if err := app.Save(record); err != nil {
return fmt.Errorf("failed to seed default category %q: %w", name, err)
}
}
return nil
}
func defaultCategorySettings() map[string]any {
return map[string]any{
"wp_merge_enabled": true,
"wp_merge_radius": 50,
}
}
var defaultCategoryTranslations = map[string]map[string]string{
"Biking": {
"cs": "Cyklistika",
"de": "Radfahren",
"en": "Biking",
"es": "Ciclismo",
"eu": "Bizikleta",
"fr": "Vélo",
"hu": "Biking",
"it": "Ciclismo",
"nl": "Fietsen",
"no": "Sykling",
"pl": "Rower",
"pt": "Ciclismo",
"ru": "Велоспорт",
"zh": "骑行",
},
"Canoeing": {
"cs": "Kanoistika",
"de": "Kanufahren",
"en": "Canoeing",
"es": "Remo",
"eu": "Kanoa",
"fr": "Canoë",
"hu": "Canoeing",
"it": "Canoa",
"nl": "Kanoën",
"no": "Padling",
"pl": "Kajak",
"pt": "Canoagem",
"ru": "Каякинг",
"zh": "划艇",
},
"Climbing": {
"cs": "Horolezectví",
"de": "Klettern",
"en": "Climbing",
"es": "Escalada",
"eu": "Eskalada",
"fr": "Escalade",
"hu": "Climbing",
"it": "Arrampicata",
"nl": "Klimmen",
"no": "Klatring",
"pl": "Wspinaczka",
"pt": "Escalada",
"ru": "Скалолазание",
"zh": "攀岩",
},
"Hiking": {
"cs": "Turistika",
"de": "Wandern",
"en": "Hiking",
"es": "Senderismo",
"eu": "Mendi-ibilaldia",
"fr": "Randonnée",
"hu": "Hiking",
"it": "Escursionismo",
"nl": "Hiken",
"no": "Vandring",
"pl": "Wędrówka",
"pt": "Montanhismo",
"ru": "Пеший туризм",
"zh": "徒步",
},
"Other": {
"cs": "Ostatní",
"de": "Sonstiges",
"en": "Other",
"es": "Otros",
"eu": "Bestelakoak",
"fr": "Autre",
"hu": "Egyéb",
"it": "Altro",
"nl": "Overig",
"no": "Annet",
"pl": "Inne",
"pt": "Outros",
"ru": "Другое",
"zh": "其他",
},
"Running": {
"cs": "Běh",
"de": "Laufen",
"en": "Running",
"es": "Carrera",
"eu": "Korrika",
"fr": "Course à pied",
"hu": "Futás",
"it": "Corsa",
"nl": "Hardlopen",
"no": "Løping",
"pl": "Bieganie",
"pt": "Corrida",
"ru": "Бег",
"zh": "跑步",
},
"Skiing": {
"de": "Skifahren",
"no": "Skisport",
},
"Walking": {
"cs": "Chůze",
"de": "Spazieren",
"en": "Walking",
"es": "Paseo",
"eu": "Oinez",
"fr": "Marche",
"hu": "Walking",
"it": "Camminare",
"nl": "Wandelen",
"no": "Gåtur",
"pl": "Spacer",
"pt": "Caminhada",
"ru": "Прогулка",
"zh": "步行",
},
}
var defaultCategoryIcons = map[string]string{
"Biking": "person-biking",
"Canoeing": "sailboat",
"Climbing": "mountain",
"Hiking": "person-hiking",
"Other": "shapes",
"Running": "person-running",
"Skiing": "person-skiing-nordic",
"Walking": "person-walking",
}
func PrepopulateDefaultCategoryTranslations(app core.App) error {
allCategories, err := app.FindAllRecords("categories")
if err != nil {
return err
}
for _, category := range allCategories {
staticTranslations, ok := defaultCategoryTranslations[category.GetString("name")]
if !ok {
continue
}
currentTranslations, err := ParseCategoryTranslations(category.Get("translations"))
if err != nil {
return fmt.Errorf("invalid existing translations for category %q: %w", category.GetString("name"), err)
}
mergedTranslations, changed := mergeDefaultCategoryTranslations(staticTranslations, currentTranslations)
if !changed {
continue
}
category.Set("translations", mergedTranslations)
if err := app.Save(category); err != nil {
return fmt.Errorf("failed to prepopulate translations for category %q: %w", category.GetString("name"), err)
}
}
return nil
}
func PrepopulateDefaultCategoryIcons(app core.App) error {
collection, err := app.FindCollectionByNameOrId("categories")
if err != nil {
return err
}
if collection.Fields.GetByName("icon") == nil {
return nil
}
allCategories, err := app.FindAllRecords("categories")
if err != nil {
return err
}
for _, category := range allCategories {
categoryName := category.GetString("name")
defaultIcon, ok := defaultCategoryIcons[categoryName]
if !ok {
continue
}
if category.GetString("icon") != "" {
continue
}
category.Set("icon", defaultIcon)
if err := app.Save(category); err != nil {
return fmt.Errorf("failed to prepopulate icon for category %q: %w", category.GetString("name"), err)
}
}
return nil
}
func mergeDefaultCategoryTranslations(staticTranslations map[string]string, currentTranslations map[string]CategoryTranslation) (map[string]CategoryTranslation, bool) {
if currentTranslations == nil {
currentTranslations = map[string]CategoryTranslation{}
}
changed := false
for locale, name := range staticTranslations {
if name == "" {
continue
}
translation := currentTranslations[locale]
if translation.Name != "" {
continue
}
translation.Name = name
currentTranslations[locale] = translation
changed = true
}
return currentTranslations, changed
}

View File

@@ -0,0 +1,241 @@
package util
import (
"fmt"
"github.com/pocketbase/pocketbase/core"
)
const DefaultPriorityCategoryName = "Hiking"
func ValidateUserCategoryPreferenceRequest(priorityExplicit bool) error {
if priorityExplicit {
return fmt.Errorf("category preference priority can only be changed through the reorder endpoint")
}
return nil
}
func ValidateUserSubcategoryPreferenceRequest(priorityExplicit bool) error {
if priorityExplicit {
return fmt.Errorf("subcategory preference priority can only be changed through the reorder endpoint")
}
return nil
}
func EnsureUserCategoryPriority(app core.App, userID, categoryID string) error {
if userID == "" {
return nil
}
prioritized, err := app.FindRecordsByFilter(
"user_category_preferences",
"user = {:user} && priority > 0",
"",
1,
0,
map[string]any{"user": userID},
)
if err != nil {
return err
}
if len(prioritized) > 0 {
return nil
}
if categoryID == "" {
category, err := FindCategoryByNormalizedName(app, DefaultPriorityCategoryName)
if err != nil {
return err
}
if category == nil {
return nil
}
categoryID = category.Id
}
collection, err := app.FindCollectionByNameOrId("user_category_preferences")
if err != nil {
return err
}
existing, err := app.FindRecordsByFilter(
"user_category_preferences",
"user = {:user} && category = {:category}",
"",
1,
0,
map[string]any{"user": userID, "category": categoryID},
)
if err != nil {
return err
}
var record *core.Record
if len(existing) > 0 {
record = existing[0]
} else {
record = core.NewRecord(collection)
record.Set("user", userID)
record.Set("category", categoryID)
record.Set("visible", true)
}
record.Set("priority", 1)
return app.SaveNoValidate(record)
}
func ReorderUserCategoryPreferences(app core.App, userID string, categoryIDs []string) error {
if userID == "" {
return fmt.Errorf("authentication required")
}
categories, err := app.FindAllRecords("categories")
if err != nil {
return err
}
if len(categoryIDs) != len(categories) {
return fmt.Errorf("reorder request must include all categories")
}
validCategories := make(map[string]struct{}, len(categories))
for _, category := range categories {
validCategories[category.Id] = struct{}{}
}
seen := make(map[string]struct{}, len(categoryIDs))
for _, categoryID := range categoryIDs {
if _, ok := validCategories[categoryID]; !ok {
return fmt.Errorf("unknown category %q", categoryID)
}
if _, ok := seen[categoryID]; ok {
return fmt.Errorf("duplicate category %q", categoryID)
}
seen[categoryID] = struct{}{}
}
return app.RunInTransaction(func(txApp core.App) error {
collection, err := txApp.FindCollectionByNameOrId("user_category_preferences")
if err != nil {
return err
}
existing, err := txApp.FindRecordsByFilter(
"user_category_preferences",
"user = {:user}",
"",
0,
0,
map[string]any{"user": userID},
)
if err != nil {
return err
}
byCategory := make(map[string]*core.Record, len(existing))
for _, record := range existing {
byCategory[record.GetString("category")] = record
}
for index, categoryID := range categoryIDs {
record := byCategory[categoryID]
if record == nil {
record = core.NewRecord(collection)
record.Set("user", userID)
record.Set("category", categoryID)
record.Set("visible", true)
}
record.Set("priority", index+1)
if err := txApp.SaveNoValidate(record); err != nil {
return err
}
}
return nil
})
}
func ReorderUserSubcategoryPreferences(app core.App, userID, categoryID string, subcategoryIDs []string) error {
if userID == "" {
return fmt.Errorf("authentication required")
}
if categoryID == "" {
return fmt.Errorf("category is required")
}
subcategories, err := app.FindRecordsByFilter(
"subcategories",
"category = {:category}",
"",
0,
0,
map[string]any{"category": categoryID},
)
if err != nil {
return err
}
if len(subcategoryIDs) != len(subcategories) {
return fmt.Errorf("reorder request must include all subcategories for the category")
}
validSubcategories := make(map[string]struct{}, len(subcategories))
for _, subcategory := range subcategories {
validSubcategories[subcategory.Id] = struct{}{}
}
seen := make(map[string]struct{}, len(subcategoryIDs))
for _, subcategoryID := range subcategoryIDs {
if _, ok := validSubcategories[subcategoryID]; !ok {
return fmt.Errorf("unknown subcategory %q", subcategoryID)
}
if _, ok := seen[subcategoryID]; ok {
return fmt.Errorf("duplicate subcategory %q", subcategoryID)
}
seen[subcategoryID] = struct{}{}
}
return app.RunInTransaction(func(txApp core.App) error {
collection, err := txApp.FindCollectionByNameOrId("user_subcategory_preferences")
if err != nil {
return err
}
existing, err := txApp.FindRecordsByFilter(
"user_subcategory_preferences",
"user = {:user}",
"",
0,
0,
map[string]any{"user": userID},
)
if err != nil {
return err
}
bySubcategory := make(map[string]*core.Record, len(existing))
for _, record := range existing {
bySubcategory[record.GetString("subcategory")] = record
}
for index, subcategoryID := range subcategoryIDs {
record := bySubcategory[subcategoryID]
if record == nil {
record = core.NewRecord(collection)
record.Set("user", userID)
record.Set("subcategory", subcategoryID)
record.Set("visible", true)
}
record.Set("priority", index+1)
if err := txApp.SaveNoValidate(record); err != nil {
return err
}
}
return nil
})
}

1301
db/util/category_test.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -33,10 +33,24 @@ func documentFromTrailRecord(r *core.Record, author *core.Record, includeShares
tags[i] = v.GetString("name")
}
categoryID := r.GetString("category")
var categoryIDValue any
if categoryID != "" {
categoryIDValue = categoryID
}
subcategoryID := r.GetString("subcategory")
var subcategoryIDValue any
if subcategoryID != "" {
subcategoryIDValue = subcategoryID
}
category := ""
categoryIcon := ""
trailCategory := r.ExpandedOne("category")
if trailCategory != nil {
category = trailCategory.GetString("name")
categoryIcon = trailCategory.GetString("icon")
}
bounds := getStoredBounds(r)
@@ -52,34 +66,40 @@ func documentFromTrailRecord(r *core.Record, author *core.Record, includeShares
}
document := map[string]any{
"id": r.Id,
"author": author.Id,
"author_name": author.GetString("preferred_username"),
"author_avatar": author.GetString("icon"),
"name": r.GetString("name"),
"description": r.GetString("description"),
"location": r.GetString("location"),
"distance": r.GetFloat("distance"),
"elevation_gain": r.GetFloat("elevation_gain"),
"elevation_loss": r.GetFloat("elevation_loss"),
"duration": r.GetFloat("duration"),
"difficulty": difficultyToNumber(r.GetString("difficulty")),
"category": category,
"completed": r.GetBool("completed"),
"date": r.GetDateTime("date").Time().Unix(),
"created": r.GetDateTime("created").Time().Unix(),
"public": r.GetBool("public"),
"thumbnail": thumbnail,
"gpx": r.GetString("gpx"),
"tags": tags,
"polyline": r.GetString("polyline"),
"domain": domain,
"iri": r.GetString("iri"),
"min_lat": bounds[0],
"max_lat": bounds[1],
"min_lon": bounds[2],
"max_lon": bounds[3],
"bounding_box_diagonal": diagonal,
"id": r.Id,
"author": author.Id,
"author_name": author.GetString("preferred_username"),
"author_avatar": author.GetString("icon"),
"name": r.GetString("name"),
"description": r.GetString("description"),
"location": r.GetString("location"),
"distance": r.GetFloat("distance"),
"elevation_gain": r.GetFloat("elevation_gain"),
"elevation_loss": r.GetFloat("elevation_loss"),
"duration": r.GetFloat("duration"),
"difficulty": difficultyToNumber(r.GetString("difficulty")),
"category": category,
"category_id": categoryIDValue,
"category_icon": categoryIcon,
"subcategory_id": subcategoryIDValue,
"is_federated": !author.GetBool("is_local"),
"federated_category_name": r.GetString("federated_category_name"),
"federated_subcategory_name": r.GetString("federated_subcategory_name"),
"completed": r.GetBool("completed"),
"date": r.GetDateTime("date").Time().Unix(),
"created": r.GetDateTime("created").Time().Unix(),
"public": r.GetBool("public"),
"thumbnail": thumbnail,
"gpx": r.GetString("gpx"),
"tags": tags,
"polyline": r.GetString("polyline"),
"domain": domain,
"iri": r.GetString("iri"),
"min_lat": bounds[0],
"max_lat": bounds[1],
"min_lon": bounds[2],
"max_lon": bounds[3],
"bounding_box_diagonal": diagonal,
"_geo": map[string]float64{
"lat": r.GetFloat("lat"),
"lng": r.GetFloat("lon"),

230
db/util/subcategory.go Normal file
View File

@@ -0,0 +1,230 @@
package util
import (
"fmt"
"github.com/pocketbase/pocketbase/core"
)
func ValidateSubcategoryRecord(app core.App, record *core.Record) error {
parentCategory := record.GetString("category")
if parentCategory == "" {
return fmt.Errorf("subcategory category is required")
}
name := record.GetString("name")
normalizedName := NormalizeCategoryName(name)
allSubcategories, err := app.FindAllRecords("subcategories")
if err != nil {
return err
}
for _, existing := range allSubcategories {
if existing.Id == record.Id || existing.GetString("category") != parentCategory {
continue
}
if NormalizeCategoryName(existing.GetString("name")) == normalizedName {
return fmt.Errorf("subcategory name %q collides with existing subcategory %q in the same category after normalization", name, existing.GetString("name"))
}
}
if _, err := ParseCategoryTranslations(record.Get("translations")); err != nil {
return err
}
return nil
}
func ValidateTrailSubcategoryRecord(app core.App, record *core.Record, subcategoryExplicit bool) error {
subcategoryID := record.GetString("subcategory")
if subcategoryID == "" {
return nil
}
categoryID := record.GetString("category")
if categoryID == "" {
if !subcategoryExplicit {
record.Set("subcategory", "")
return nil
}
return fmt.Errorf("trail subcategory requires a category")
}
subcategory, err := app.FindRecordById("subcategories", subcategoryID)
if err != nil {
if !subcategoryExplicit {
record.Set("subcategory", "")
return nil
}
return fmt.Errorf("trail subcategory %q does not exist: %w", subcategoryID, err)
}
parentCategory := subcategory.GetString("category")
if parentCategory != categoryID {
if !subcategoryExplicit {
record.Set("subcategory", "")
return nil
}
return fmt.Errorf("trail subcategory %q belongs to category %q, not %q", subcategoryID, parentCategory, categoryID)
}
return nil
}
func FindSubcategoryByNormalizedName(app core.App, categoryID string, name string) (*core.Record, error) {
normalizedName := NormalizeCategoryName(name)
if categoryID == "" || normalizedName == "" {
return nil, nil
}
subcategories, err := app.FindRecordsByFilter(
"subcategories",
"category = {:category}",
"",
0,
0,
map[string]any{"category": categoryID},
)
if err != nil {
return nil, err
}
for _, subcategory := range subcategories {
if NormalizeCategoryName(subcategory.GetString("name")) == normalizedName {
return subcategory, nil
}
}
return nil, nil
}
func ResolveCategoryAndSubcategoryByNormalizedNames(app core.App, categoryName string, subcategoryName string) (*core.Record, *core.Record, error) {
category, err := FindCategoryByNormalizedName(app, categoryName)
if err != nil || category == nil {
return category, nil, err
}
subcategory, err := FindSubcategoryByNormalizedName(app, category.Id, subcategoryName)
if err != nil {
return category, nil, err
}
return category, subcategory, nil
}
func BackfillRemoteTrailCategory(app core.App, category *core.Record) error {
if category == nil || category.Id == "" {
return nil
}
subcategoriesByName, err := normalizedSubcategoriesByName(app, category.Id)
if err != nil {
return err
}
trails, err := app.FindRecordsByFilter(
"trails",
"federated_category_name != '' && category = ''",
"",
0,
0,
nil,
)
if err != nil {
return err
}
normalizedCategoryName := NormalizeCategoryName(category.GetString("name"))
for _, trail := range trails {
if NormalizeCategoryName(trail.GetString("federated_category_name")) != normalizedCategoryName {
continue
}
trail.Set("category", category.Id)
if subcategory, ok := subcategoriesByName[NormalizeCategoryName(trail.GetString("federated_subcategory_name"))]; ok {
trail.Set("subcategory", subcategory.Id)
}
if err := app.Save(trail); err != nil {
return err
}
}
return nil
}
func BackfillRemoteTrailSubcategory(app core.App, subcategory *core.Record) error {
if subcategory == nil || subcategory.Id == "" {
return nil
}
category, err := app.FindRecordById("categories", subcategory.GetString("category"))
if err != nil {
return err
}
trails, err := app.FindRecordsByFilter(
"trails",
"federated_subcategory_name != '' && subcategory = '' && (category = {:category} || category = '')",
"",
0,
0,
map[string]any{"category": category.Id},
)
if err != nil {
return err
}
normalizedCategoryName := NormalizeCategoryName(category.GetString("name"))
normalizedSubcategoryName := NormalizeCategoryName(subcategory.GetString("name"))
for _, trail := range trails {
categoryID := trail.GetString("category")
if categoryID == "" {
if NormalizeCategoryName(trail.GetString("federated_category_name")) != normalizedCategoryName {
continue
}
trail.Set("category", category.Id)
}
if NormalizeCategoryName(trail.GetString("federated_subcategory_name")) != normalizedSubcategoryName {
continue
}
trail.Set("subcategory", subcategory.Id)
if err := app.Save(trail); err != nil {
return err
}
}
return nil
}
func normalizedSubcategoriesByName(app core.App, categoryID string) (map[string]*core.Record, error) {
subcategories, err := app.FindRecordsByFilter(
"subcategories",
"category = {:category}",
"",
0,
0,
map[string]any{"category": categoryID},
)
if err != nil {
return nil, err
}
byName := make(map[string]*core.Record, len(subcategories))
for _, subcategory := range subcategories {
normalizedName := NormalizeCategoryName(subcategory.GetString("name"))
if normalizedName == "" {
continue
}
byName[normalizedName] = subcategory
}
return byName, nil
}

View File

@@ -0,0 +1,284 @@
package util
import (
"fmt"
"github.com/pocketbase/pocketbase/core"
)
type defaultSubcategorySeed struct {
parentCategory string
name string
shortName string
badgeIcon string
translations map[string]CategoryTranslation
aliases []string
}
var defaultSubcategories = []defaultSubcategorySeed{
{parentCategory: "Biking", name: "MTB", shortName: "MTB", badgeIcon: "mountain"},
{parentCategory: "Biking", name: "Gravel", shortName: "GRVL"},
{
parentCategory: "Biking",
name: "Touring",
shortName: "TOUR",
aliases: []string{"Touring Bike", "City Bike"},
translations: subcategoryTranslations("Touring", "Tourenrad", "TOUR"),
},
{
parentCategory: "Biking",
name: "Road",
shortName: "ROAD",
badgeIcon: "grip-lines-vertical",
translations: subcategoryTranslations("Road", "Rennrad", "ROAD"),
},
{parentCategory: "Biking", name: "E-Bike", shortName: "EBIKE", badgeIcon: "bolt"},
{
parentCategory: "Hiking",
name: "Winter",
shortName: "WINT",
badgeIcon: "snowflake",
aliases: []string{"Winter Hiking"},
translations: map[string]CategoryTranslation{
"de": {Name: "Winterwandern", ShortName: "WINT"},
"en": {Name: "Winter", ShortName: "WINT"},
},
},
{
parentCategory: "Hiking",
name: "Alpine",
shortName: "ALP",
badgeIcon: "mountain",
aliases: []string{"Alpine Hiking"},
translations: subcategoryTranslations("Alpine", "Bergwandern", "ALP"),
},
{
parentCategory: "Hiking",
name: "Long-distance",
shortName: "LONG",
aliases: []string{"Long-distance Hiking"},
translations: subcategoryTranslations("Long-distance", "Fernwandern", "LONG"),
},
{
parentCategory: "Hiking",
name: "Snowshoeing",
shortName: "SNOW",
badgeIcon: "snowflake",
translations: subcategoryTranslations("Snowshoeing", "Schneeschuhwandern", "SNOW"),
},
{
parentCategory: "Hiking",
name: "Family",
shortName: "FAM",
badgeIcon: "child",
aliases: []string{"Family Hiking"},
translations: subcategoryTranslations("Family", "Familienwandern", "FAM"),
},
{
parentCategory: "Hiking",
name: "Pilgrimage",
shortName: "PILG",
badgeIcon: "cross",
translations: subcategoryTranslations("Pilgrimage", "Pilgern", "PILG"),
},
{
parentCategory: "Running",
name: "Trail",
shortName: "TRAIL",
aliases: []string{"Trail Running"},
translations: subcategoryTranslations("Trail", "Trailrunning", "TRAIL"),
},
{
parentCategory: "Running",
name: "Road",
shortName: "ROAD",
badgeIcon: "grip-lines-vertical",
aliases: []string{"Road Running"},
translations: subcategoryTranslations("Road", "Straßenlauf", "ROAD"),
},
{
parentCategory: "Skiing",
name: "Cross-country",
shortName: "NORD",
aliases: []string{"Cross-country Skiing"},
translations: subcategoryTranslations("Cross-country", "Langlauf", "NORD"),
},
{
parentCategory: "Skiing",
name: "Skating",
shortName: "SKATE",
translations: subcategoryTranslations("Skating", "Skating", "SKATE"),
},
{
parentCategory: "Skiing",
name: "Backcountry",
shortName: "BACK",
aliases: []string{"Backcountry Skiing"},
translations: subcategoryTranslations("Backcountry", "Skitour", "BACK"),
},
}
func subcategoryTranslations(en string, de string, shortName string) map[string]CategoryTranslation {
return map[string]CategoryTranslation{
"de": {Name: de, ShortName: shortName},
"en": {Name: en, ShortName: shortName},
}
}
func SeedDefaultSubcategories(app core.App) error {
subcategoriesCollection, err := app.FindCollectionByNameOrId("subcategories")
if err != nil {
return err
}
for _, seed := range defaultSubcategories {
categories, err := app.FindRecordsByFilter(
"categories",
"name = {:name}",
"",
1,
0,
map[string]any{"name": seed.parentCategory},
)
if err != nil {
return err
}
if len(categories) == 0 {
continue
}
category := categories[0]
existing, err := app.FindRecordsByFilter(
"subcategories",
"category = {:category}",
"",
0,
0,
map[string]any{"category": category.Id},
)
if err != nil {
return err
}
if existingRecord := findDefaultSubcategory(existing, seed.name, seed.aliases); existingRecord != nil {
changed, err := applyDefaultSubcategorySeed(existingRecord, seed)
if err != nil {
return err
}
if changed {
if err := app.Save(existingRecord); err != nil {
return fmt.Errorf("failed to update seeded subcategory %q: %w", seed.name, err)
}
}
continue
}
record := core.NewRecord(subcategoriesCollection)
record.Set("category", category.Id)
record.Set("name", seed.name)
record.Set("short_name", seed.shortName)
if seed.badgeIcon != "" {
record.Set("badge_icon", seed.badgeIcon)
}
if len(seed.translations) > 0 {
record.Set("translations", seed.translations)
}
if err := app.Save(record); err != nil {
return fmt.Errorf("failed to seed subcategory %q: %w", seed.name, err)
}
}
return nil
}
func applyDefaultSubcategorySeed(record *core.Record, seed defaultSubcategorySeed) (bool, error) {
changed := false
if isDefaultSubcategoryAlias(record.GetString("name"), seed.aliases) {
record.Set("name", seed.name)
changed = true
}
if record.GetString("short_name") == "" && seed.shortName != "" {
record.Set("short_name", seed.shortName)
changed = true
}
if record.GetString("badge_icon") == "" && seed.badgeIcon != "" {
record.Set("badge_icon", seed.badgeIcon)
changed = true
}
if len(seed.translations) > 0 {
currentTranslations, err := ParseCategoryTranslations(record.Get("translations"))
if err != nil {
return false, fmt.Errorf("invalid existing translations for subcategory %q: %w", record.GetString("name"), err)
}
mergedTranslations, translationsChanged := mergeDefaultSubcategoryTranslations(seed.translations, currentTranslations)
if translationsChanged {
record.Set("translations", mergedTranslations)
changed = true
}
}
return changed, nil
}
func isDefaultSubcategoryAlias(name string, aliases []string) bool {
normalizedName := NormalizeCategoryName(name)
for _, alias := range aliases {
if normalizedName == NormalizeCategoryName(alias) {
return true
}
}
return false
}
func mergeDefaultSubcategoryTranslations(staticTranslations map[string]CategoryTranslation, currentTranslations map[string]CategoryTranslation) (map[string]CategoryTranslation, bool) {
if currentTranslations == nil {
currentTranslations = map[string]CategoryTranslation{}
}
changed := false
for locale, staticTranslation := range staticTranslations {
translation := currentTranslations[locale]
localeChanged := false
if translation.Name == "" && staticTranslation.Name != "" {
translation.Name = staticTranslation.Name
localeChanged = true
}
if translation.ShortName == "" && staticTranslation.ShortName != "" {
translation.ShortName = staticTranslation.ShortName
localeChanged = true
}
if localeChanged {
changed = true
currentTranslations[locale] = translation
}
}
return currentTranslations, changed
}
func findDefaultSubcategory(records []*core.Record, name string, aliases []string) *core.Record {
normalizedName := NormalizeCategoryName(name)
for _, record := range records {
if NormalizeCategoryName(record.GetString("name")) == normalizedName {
return record
}
}
normalizedAliases := map[string]struct{}{}
for _, alias := range aliases {
normalizedAliases[NormalizeCategoryName(alias)] = struct{}{}
}
for _, record := range records {
if _, ok := normalizedAliases[NormalizeCategoryName(record.GetString("name"))]; ok {
return record
}
}
return nil
}