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:
@@ -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
216
db/util/category.go
Normal 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
|
||||
}
|
||||
289
db/util/category_defaults.go
Normal file
289
db/util/category_defaults.go
Normal 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
|
||||
}
|
||||
241
db/util/category_preference.go
Normal file
241
db/util/category_preference.go
Normal 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
1301
db/util/category_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
230
db/util/subcategory.go
Normal 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
|
||||
}
|
||||
284
db/util/subcategory_defaults.go
Normal file
284
db/util/subcategory_defaults.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user