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

@@ -682,17 +682,21 @@ func processCreateOrUpdateSummitLogActivity(activity pub.Activity, app core.App,
}
}
if len(photoURLs) > 0 {
photos := make([]*filesystem.File, len(photoURLs))
for i, purl := range photoURLs {
if len(photoURLs) == 0 {
record.Set("photos", []*filesystem.File{})
} else {
photos := []*filesystem.File{}
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)
if len(photos) > 0 {
record.Set("photos", photos)
}
}
if gpxURL != "" {

125
db/hooks/categories.go Normal file
View File

@@ -0,0 +1,125 @@
package hooks
import (
"pocketbase/util"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
)
func ValidateCategoryHandler() func(e *core.RecordRequestEvent) error {
return func(e *core.RecordRequestEvent) error {
if err := util.ValidateCategoryRecord(e.App, e.Record); err != nil {
return apis.NewBadRequestError(err.Error(), err)
}
return e.Next()
}
}
func ValidateSubcategoryHandler() func(e *core.RecordRequestEvent) error {
return func(e *core.RecordRequestEvent) error {
if err := util.ValidateSubcategoryRecord(e.App, e.Record); err != nil {
return apis.NewBadRequestError(err.Error(), err)
}
return e.Next()
}
}
func BackfillRemoteTrailCategoryHandler() func(e *core.RecordEvent) error {
return func(e *core.RecordEvent) error {
if e.Record.Original().Id != "" && e.Record.GetString("name") == e.Record.Original().GetString("name") {
return e.Next()
}
if err := util.BackfillRemoteTrailCategory(e.App, e.Record); err != nil {
e.App.Logger().Warn("failed to backfill remote trail categories after category save", "category", e.Record.Id, "error", err)
}
return e.Next()
}
}
func BackfillRemoteTrailSubcategoryHandler() func(e *core.RecordEvent) error {
return func(e *core.RecordEvent) error {
original := e.Record.Original()
if original.Id != "" &&
e.Record.GetString("name") == original.GetString("name") &&
e.Record.GetString("category") == original.GetString("category") {
return e.Next()
}
if err := util.BackfillRemoteTrailSubcategory(e.App, e.Record); err != nil {
e.App.Logger().Warn("failed to backfill remote trail subcategories after subcategory save", "subcategory", e.Record.Id, "error", err)
}
return e.Next()
}
}
func ValidateUserCategoryPreferenceHandler() func(e *core.RecordRequestEvent) error {
return func(e *core.RecordRequestEvent) error {
requestInfo, err := e.RequestInfo()
if err != nil {
return err
}
if err := util.ValidateUserCategoryPreferenceRequest(requestBodyHasField(requestInfo.Body, "priority")); err != nil {
return apis.NewBadRequestError(err.Error(), err)
}
return e.Next()
}
}
func ValidateUserSubcategoryPreferenceHandler() func(e *core.RecordRequestEvent) error {
return func(e *core.RecordRequestEvent) error {
requestInfo, err := e.RequestInfo()
if err != nil {
return err
}
if err := util.ValidateUserSubcategoryPreferenceRequest(requestBodyHasField(requestInfo.Body, "priority")); err != nil {
return apis.NewBadRequestError(err.Error(), err)
}
return e.Next()
}
}
func ValidateTrailSubcategoryHandler() func(e *core.RecordRequestEvent) error {
return func(e *core.RecordRequestEvent) error {
requestInfo, err := e.RequestInfo()
if err != nil {
return err
}
subcategoryExplicit := requestBodyHasField(requestInfo.Body, "subcategory")
if err := util.ValidateTrailSubcategoryRecord(e.App, e.Record, subcategoryExplicit); err != nil {
return apis.NewBadRequestError(err.Error(), err)
}
return e.Next()
}
}
func requestBodyHasField(body map[string]any, field string) bool {
_, ok := body[field]
if ok {
return true
}
_, ok = body[field+"+"]
if ok {
return true
}
_, ok = body["+"+field]
if ok {
return true
}
_, ok = body[field+"-"]
return ok
}

View File

@@ -17,6 +17,10 @@ func CreateUserHandler(client meilisearch.ServiceManager) func(e *core.RecordEve
return err
}
if err := util.EnsureUserCategoryPriority(e.App, e.Record.Id, ""); err != nil {
return err
}
_, err = util.ActorFromUser(e.App, e.Record)
if err != nil {
return err

View File

@@ -5,14 +5,12 @@ import (
"fmt"
"log"
"os"
"strings"
"github.com/meilisearch/meilisearch-go"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/plugins/migratecmd"
"github.com/pocketbase/pocketbase/tools/filesystem"
"pocketbase/commands"
"pocketbase/hooks"
@@ -96,6 +94,22 @@ func setupEventHandlers(app *pocketbase.PocketBase, client meilisearch.ServiceMa
app.OnRecordAfterUpdateSuccess("activitypub_actors").BindFunc(hooks.UpdateActorHandler(client))
app.OnRecordAfterDeleteSuccess("activitypub_actors").BindFunc(hooks.DeleteActorHandler(client))
app.OnRecordCreateRequest("categories").BindFunc(hooks.ValidateCategoryHandler())
app.OnRecordUpdateRequest("categories").BindFunc(hooks.ValidateCategoryHandler())
app.OnRecordAfterCreateSuccess("categories").BindFunc(hooks.BackfillRemoteTrailCategoryHandler())
app.OnRecordAfterUpdateSuccess("categories").BindFunc(hooks.BackfillRemoteTrailCategoryHandler())
app.OnRecordCreateRequest("subcategories").BindFunc(hooks.ValidateSubcategoryHandler())
app.OnRecordUpdateRequest("subcategories").BindFunc(hooks.ValidateSubcategoryHandler())
app.OnRecordAfterCreateSuccess("subcategories").BindFunc(hooks.BackfillRemoteTrailSubcategoryHandler())
app.OnRecordAfterUpdateSuccess("subcategories").BindFunc(hooks.BackfillRemoteTrailSubcategoryHandler())
app.OnRecordCreateRequest("user_category_preferences").BindFunc(hooks.ValidateUserCategoryPreferenceHandler())
app.OnRecordUpdateRequest("user_category_preferences").BindFunc(hooks.ValidateUserCategoryPreferenceHandler())
app.OnRecordCreateRequest("user_subcategory_preferences").BindFunc(hooks.ValidateUserSubcategoryPreferenceHandler())
app.OnRecordUpdateRequest("user_subcategory_preferences").BindFunc(hooks.ValidateUserSubcategoryPreferenceHandler())
app.OnRecordCreateRequest("trails").BindFunc(hooks.ValidateTrailSubcategoryHandler())
app.OnRecordUpdateRequest("trails").BindFunc(hooks.ValidateTrailSubcategoryHandler())
app.OnRecordAfterCreateSuccess("trails").BindFunc(hooks.CreateTrailHandler(client))
app.OnRecordAfterUpdateSuccess("trails").BindFunc(hooks.UpdateTrailHandler(client))
app.OnRecordAfterDeleteSuccess("trails").BindFunc(hooks.DeleteTrailHandler(client))
@@ -166,6 +180,8 @@ func registerRoutes(se *core.ServeEvent, client meilisearch.ServiceManager) {
se.Router.POST("/auth/token", routes.AuthToken)
se.Router.POST("/user/email", routes.UserEmailChange)
se.Router.POST("/waypoint/cluster", routes.WaypointCluster)
se.Router.POST("/category-preferences/reorder", routes.CategoryPreferencesReorder)
se.Router.POST("/subcategory-preferences/reorder", routes.SubcategoryPreferencesReorder)
se.Router.POST("/trail-merge/suggest", routes.TrailMergeSuggest)
se.Router.POST("/trail-merge", routes.TrailMerge(client))
@@ -213,6 +229,9 @@ func registerCronJobs(app core.App, client meilisearch.ServiceManager) {
func initData(app core.App, client meilisearch.ServiceManager) error {
initCategories(app)
if err := util.SeedDefaultSubcategories(app); err != nil {
return err
}
initPlugins(app)
initMeilisearchConfig(client)
go func() {
@@ -280,31 +299,29 @@ func initCategories(app core.App) error {
if err := query.All(&records); err != nil {
return err
}
if len(records) != 0 {
return nil
}
collection, err := app.FindCollectionByNameOrId("categories")
if err != nil {
return err
}
categories := []string{"Hiking", "Walking", "Climbing", "Skiing", "Canoeing", "Biking", "Other"}
for _, element := range categories {
record := core.NewRecord(collection)
record.Set("name", element)
record.Set("settings", map[string]any{
"wp_merge_enabled": true,
"wp_merge_radius": 50,
})
if f, err := filesystem.NewFileFromPath("migrations/initial_data/" + strings.ToLower(element) + ".jpg"); err == nil {
record.Set("img", f)
}
if err := app.Save(record); err != nil {
return err
if len(records) == 0 {
for _, element := range util.DefaultCategoryNames() {
record := core.NewRecord(collection)
record.Set("name", element)
record.Set("settings", map[string]any{
"wp_merge_enabled": true,
"wp_merge_radius": 50,
})
err := app.Save(record)
if err != nil {
return err
}
}
}
return nil
if err := util.PrepopulateDefaultCategoryTranslations(app); err != nil {
return err
}
return util.PrepopulateDefaultCategoryIcons(app)
}
func initMeilisearchConfig(client meilisearch.ServiceManager) {
@@ -312,13 +329,15 @@ func initMeilisearchConfig(client meilisearch.ServiceManager) {
"trails": {
SearchableAttributes: []string{"author_name", "name", "description", "location", "tags"},
FilterableAttributes: []string{
"id", "_geo", "author", "category", "completed", "date", "difficulty",
"distance", "elevation_gain", "elevation_loss", "likes", "public",
"shares", "tags", "min_lat", "max_lat", "min_lon", "max_lon", "bounding_box_diagonal",
"id", "_geo", "author", "category_id", "subcategory_id",
"is_federated", "completed", "date", "difficulty", "distance",
"elevation_gain", "elevation_loss", "likes", "public", "shares",
"tags", "min_lat", "max_lat", "min_lon", "max_lon", "bounding_box_diagonal",
},
SortableAttributes: []string{
"author", "created", "date", "difficulty", "distance",
"duration", "elevation_gain", "elevation_loss", "like_count", "name",
"min_lat", "max_lat", "min_lon", "max_lon",
},
RankingRules: []string{"words", "typo", "proximity", "attribute", "sort", "exactness"},
},

View File

@@ -0,0 +1,803 @@
package migrations
import (
"encoding/json"
"errors"
"fmt"
"pocketbase/util"
"sort"
"strings"
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/tools/filesystem"
)
func init() {
m.Register(func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("categories")
if err != nil {
return err
}
if err := resolveCategoryNameCollisions(app); err != nil {
return fmt.Errorf("failed to resolve category name collisions: %w", err)
}
if err := deleteCategoryImageFiles(app); err != nil {
return fmt.Errorf("failed to delete category image files: %w", err)
}
collection.Fields.RemoveById("64dsnxtb")
if err := collection.Fields.AddMarshaledJSONAt(len(collection.Fields), []byte(`{
"hidden": false,
"id": "texti4ksx4gm",
"max": 0,
"min": 0,
"name": "short_name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
}`)); err != nil {
return err
}
if err := collection.Fields.AddMarshaledJSONAt(len(collection.Fields), []byte(`{
"hidden": false,
"id": "text0r6k2h4gi",
"max": 0,
"min": 0,
"name": "icon",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
}`)); err != nil {
return err
}
if err := collection.Fields.AddMarshaledJSONAt(len(collection.Fields), []byte(`{
"hidden": false,
"id": "jsonvkf7o88i",
"maxSize": 0,
"name": "translations",
"presentable": false,
"required": false,
"system": false,
"type": "json"
}`)); err != nil {
return err
}
if err := app.Save(collection); err != nil {
return err
}
if err := ensureRunningCategory(app); err != nil {
return fmt.Errorf("failed to ensure running category: %w", err)
}
if err := createSubcategoriesCollection(app); err != nil {
return err
}
trailsCollection, err := app.FindCollectionByNameOrId("trails")
if err != nil {
return err
}
if err := trailsCollection.Fields.AddMarshaledJSONAt(len(trailsCollection.Fields), []byte(`{
"cascadeDelete": false,
"collectionId": "pbc_1781100000",
"hidden": false,
"id": "relphase2subct",
"maxSelect": 1,
"minSelect": 0,
"name": "subcategory",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
}`)); err != nil {
return err
}
if err := trailsCollection.Fields.AddMarshaledJSONAt(len(trailsCollection.Fields), []byte(`{
"hidden": false,
"id": "textremotecat1",
"max": 0,
"min": 0,
"name": "federated_category_name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
}`)); err != nil {
return err
}
if err := trailsCollection.Fields.AddMarshaledJSONAt(len(trailsCollection.Fields), []byte(`{
"hidden": false,
"id": "textremotesub1",
"max": 0,
"min": 0,
"name": "federated_subcategory_name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
}`)); err != nil {
return err
}
if err := app.Save(trailsCollection); err != nil {
return err
}
if err := util.PrepopulateDefaultCategoryTranslations(app); err != nil {
return fmt.Errorf("failed to prepopulate default category translations: %w", err)
}
if err := util.PrepopulateDefaultCategoryIcons(app); err != nil {
return fmt.Errorf("failed to prepopulate default category icons: %w", err)
}
if err := util.SeedDefaultSubcategories(app); err != nil {
return fmt.Errorf("failed to seed default subcategories: %w", err)
}
if err := createUserCategoryPreferencesCollection(app); err != nil {
return err
}
if err := createUserSubcategoryPreferencesCollection(app); err != nil {
return err
}
if err := migrateFavouriteSportToPriority(app); err != nil {
return fmt.Errorf("failed to migrate favourite sport to category priority: %w", err)
}
if err := removeSettingsCategoryField(app); err != nil {
return fmt.Errorf("failed to remove settings.category field: %w", err)
}
if err := util.ValidateCategoryCollectionState(app); err != nil {
return fmt.Errorf("categories redesign migration failed validation: %w", err)
}
return nil
}, func(app core.App) error {
if collection, err := app.FindCollectionByNameOrId("pbc_1781250000"); err == nil {
if err := app.Delete(collection); err != nil {
return err
}
}
if collection, err := app.FindCollectionByNameOrId("pbc_1781200000"); err == nil {
if err := app.Delete(collection); err != nil {
return err
}
}
if trailsCollection, err := app.FindCollectionByNameOrId("trails"); err == nil {
trailsCollection.Fields.RemoveById("relphase2subct")
trailsCollection.Fields.RemoveById("textremotecat1")
trailsCollection.Fields.RemoveById("textremotesub1")
if err := app.Save(trailsCollection); err != nil {
return err
}
}
if collection, err := app.FindCollectionByNameOrId("pbc_1781100000"); err == nil {
if err := app.Delete(collection); err != nil {
return err
}
}
if settingsCollection, err := app.FindCollectionByNameOrId("settings"); err == nil {
if err := settingsCollection.Fields.AddMarshaledJSONAt(len(settingsCollection.Fields), []byte(`{
"cascadeDelete": false,
"collectionId": "kjxvi8asj2igqwf",
"hidden": false,
"id": "owlyzl1x",
"maxSelect": 1,
"minSelect": 0,
"name": "category",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
}`)); err != nil {
return err
}
if err := app.Save(settingsCollection); err != nil {
return err
}
}
collection, err := app.FindCollectionByNameOrId("categories")
if err != nil {
return err
}
collection.Fields.RemoveById("texti4ksx4gm")
collection.Fields.RemoveById("text0r6k2h4gi")
collection.Fields.RemoveById("jsonvkf7o88i")
if err := collection.Fields.AddMarshaledJSONAt(2, []byte(`{
"hidden": false,
"id": "64dsnxtb",
"maxSelect": 1,
"maxSize": 5242880,
"mimeTypes": null,
"name": "img",
"presentable": false,
"protected": false,
"required": false,
"system": false,
"thumbs": null,
"type": "file"
}`)); err != nil {
return err
}
return app.Save(collection)
})
}
type categoryCollisionCandidate struct {
id string
name string
created string
}
func resolveCategoryNameCollisions(app core.App) error {
allCategories, err := app.FindAllRecords("categories")
if err != nil {
return err
}
candidates := make([]categoryCollisionCandidate, 0, len(allCategories))
for _, category := range allCategories {
candidates = append(candidates, categoryCollisionCandidate{
id: category.Id,
name: category.GetString("name"),
created: category.GetString("created"),
})
}
resolvedNames := resolveCategoryNameCollisionCandidates(candidates)
for _, category := range allCategories {
resolvedName, ok := resolvedNames[category.Id]
if !ok {
continue
}
originalName := category.GetString("name")
category.Set("name", resolvedName)
if err := app.Save(category); err != nil {
return fmt.Errorf("failed to resolve category name collision for %q: %w", originalName, err)
}
}
return nil
}
func collisionResolvedCategoryName(name string, id string, seen map[string]struct{}) string {
baseName := strings.TrimSpace(name)
if baseName == "" {
baseName = "Category"
}
for i := 0; ; i++ {
suffix := id
if i > 0 {
suffix = fmt.Sprintf("%s-%d", id, i+1)
}
candidate := fmt.Sprintf("%s (%s)", baseName, suffix)
if _, ok := seen[util.NormalizeCategoryName(candidate)]; !ok {
return candidate
}
}
}
func resolveCategoryNameCollisionCandidates(candidates []categoryCollisionCandidate) map[string]string {
sorted := append([]categoryCollisionCandidate(nil), candidates...)
sort.SliceStable(sorted, func(i, j int) bool {
left := sorted[i]
right := sorted[j]
leftName := util.NormalizeCategoryName(left.name)
rightName := util.NormalizeCategoryName(right.name)
if leftName != rightName {
return leftName < rightName
}
if left.created != right.created {
return left.created < right.created
}
return left.id < right.id
})
seen := map[string]struct{}{}
resolved := map[string]string{}
for _, category := range sorted {
normalizedName := util.NormalizeCategoryName(category.name)
if _, ok := seen[normalizedName]; !ok {
seen[normalizedName] = struct{}{}
continue
}
resolvedName := collisionResolvedCategoryName(category.name, category.id, seen)
resolved[category.id] = resolvedName
seen[util.NormalizeCategoryName(resolvedName)] = struct{}{}
}
return resolved
}
func ensureRunningCategory(app core.App) error {
allCategories, err := app.FindAllRecords("categories")
if err != nil {
return err
}
if len(allCategories) == 0 {
return util.SeedDefaultCategories(app)
}
for _, category := range allCategories {
if util.NormalizeCategoryName(category.GetString("name")) == util.NormalizeCategoryName("Running") {
return nil
}
}
collection, err := app.FindCollectionByNameOrId("categories")
if err != nil {
return err
}
record := core.NewRecord(collection)
record.Set("name", "Running")
record.Set("settings", map[string]any{
"wp_merge_enabled": true,
"wp_merge_radius": 50,
})
return app.Save(record)
}
func deleteCategoryImageFiles(app core.App) error {
allCategories, err := app.FindAllRecords("categories")
if err != nil {
return err
}
fsys, err := app.NewFilesystem()
if err != nil {
return err
}
defer fsys.Close()
var failures []error
for _, category := range allCategories {
for _, filename := range categoryImageFilenames(category) {
if filename == "" || strings.ContainsAny(filename, `/\`) {
continue
}
path := category.BaseFilesPath() + "/" + filename
if err := fsys.Delete(path); err != nil && !errors.Is(err, filesystem.ErrNotFound) {
failures = append(failures, fmt.Errorf("failed to delete category image %q: %w", path, err))
}
if errs := fsys.DeletePrefix(category.BaseFilesPath() + "/thumbs_" + filename + "/"); len(errs) > 0 {
failures = append(failures, fmt.Errorf("failed to delete category image thumbs for %q: %w", path, errors.Join(errs...)))
}
}
}
if len(failures) > 0 {
return errors.Join(failures...)
}
return nil
}
func categoryImageFilenames(record *core.Record) []string {
filenames := record.GetStringSlice("img")
if len(filenames) > 0 {
return filenames
}
filename := record.GetString("img")
if filename == "" {
return nil
}
return []string{filename}
}
func createSubcategoriesCollection(app core.App) error {
jsonData := `{
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "kjxvi8asj2igqwf",
"hidden": false,
"id": "relphase2cat01",
"maxSelect": 1,
"minSelect": 0,
"name": "category",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "textphase2name",
"max": 0,
"min": 1,
"name": "name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "textphase2shrt",
"max": 0,
"min": 0,
"name": "short_name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "textphase2icon",
"max": 0,
"min": 0,
"name": "icon",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "textphase2badge",
"max": 0,
"min": 0,
"name": "badge_icon",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "jsonphase2trns",
"maxSize": 0,
"name": "translations",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_1781100000",
"indexes": [],
"listRule": "",
"name": "subcategories",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": ""
}`
return saveCollectionFromJSON(app, jsonData)
}
func createUserCategoryPreferencesCollection(app core.App) error {
jsonData := `{
"createRule": "@request.auth.id != \"\" && user = @request.auth.id",
"deleteRule": "@request.auth.id != \"\" && user = @request.auth.id",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "_pb_users_auth_",
"hidden": false,
"id": "relphase3user",
"maxSelect": 1,
"minSelect": 0,
"name": "user",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"cascadeDelete": true,
"collectionId": "kjxvi8asj2igqwf",
"hidden": false,
"id": "relphase3cat",
"maxSelect": 1,
"minSelect": 0,
"name": "category",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"hidden": false,
"id": "boolphase3visible",
"name": "visible",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{
"hidden": false,
"id": "numphase3prio",
"max": null,
"min": 1,
"name": "priority",
"onlyInt": true,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_1781200000",
"indexes": [
"CREATE UNIQUE INDEX ` + "`" + `idx_user_category_preferences_user_category` + "`" + ` ON ` + "`" + `user_category_preferences` + "`" + ` (` + "`" + `user` + "`" + `, ` + "`" + `category` + "`" + `)"
],
"listRule": "@request.auth.id != \"\" && user = @request.auth.id",
"name": "user_category_preferences",
"system": false,
"type": "base",
"updateRule": "@request.auth.id != \"\" && user = @request.auth.id",
"viewRule": "@request.auth.id != \"\" && user = @request.auth.id"
}`
return saveCollectionFromJSON(app, jsonData)
}
func createUserSubcategoryPreferencesCollection(app core.App) error {
jsonData := `{
"createRule": "@request.auth.id != \"\" && user = @request.auth.id",
"deleteRule": "@request.auth.id != \"\" && user = @request.auth.id",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text178125id",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "_pb_users_auth_",
"hidden": false,
"id": "rel178125user",
"maxSelect": 1,
"minSelect": 0,
"name": "user",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"cascadeDelete": true,
"collectionId": "pbc_1781100000",
"hidden": false,
"id": "rel178125subcat",
"maxSelect": 1,
"minSelect": 0,
"name": "subcategory",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"hidden": false,
"id": "bool178125visible",
"name": "visible",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{
"hidden": false,
"id": "num178125prio",
"max": null,
"min": 1,
"name": "priority",
"onlyInt": true,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "autodate178125created",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate178125updated",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_1781250000",
"indexes": [
"CREATE UNIQUE INDEX ` + "`" + `idx_user_subcategory_preferences_user_subcategory` + "`" + ` ON ` + "`" + `user_subcategory_preferences` + "`" + ` (` + "`" + `user` + "`" + `, ` + "`" + `subcategory` + "`" + `)"
],
"listRule": "@request.auth.id != \"\" && user = @request.auth.id",
"name": "user_subcategory_preferences",
"system": false,
"type": "base",
"updateRule": "@request.auth.id != \"\" && user = @request.auth.id",
"viewRule": "@request.auth.id != \"\" && user = @request.auth.id"
}`
return saveCollectionFromJSON(app, jsonData)
}
// migrateFavouriteSportToPriority carries the former per-user "favourite sport"
// (settings.category) over to the new category priority model: the favourite becomes
// the user's priority-1 category. Users without a favourite get a common default
// instead of falling back to category sort order. Users who already organized
// categories by priority are left untouched.
func migrateFavouriteSportToPriority(app core.App) error {
settingsRecords, err := app.FindAllRecords("settings")
if err != nil {
return err
}
for _, settings := range settingsRecords {
if err := util.EnsureUserCategoryPriority(app, settings.GetString("user"), settings.GetString("category")); err != nil {
return err
}
}
return nil
}
func removeSettingsCategoryField(app core.App) error {
settings, err := app.FindCollectionByNameOrId("settings")
if err != nil {
return err
}
settings.Fields.RemoveById("owlyzl1x")
return app.Save(settings)
}
func saveCollectionFromJSON(app core.App, jsonData string) error {
collection := &core.Collection{}
if err := json.Unmarshal([]byte(jsonData), collection); err != nil {
return err
}
return app.Save(collection)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 573 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 664 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 846 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 432 KiB

View File

@@ -29,7 +29,7 @@ type Options struct {
ActorID string
DefaultPublic bool
CreateSummitLogForCompleted bool
CategoryMapping map[string]string
CategoryMapping map[string]CategoryMappingValue
Manifest pluginsystem.Manifest
Policy pluginsystem.RequestPolicyContext
Auth map[string]any
@@ -43,6 +43,16 @@ type Result struct {
Skipped bool
}
type CategoryMappingTarget struct {
CategoryID string
SubcategoryID string
}
type CategoryMappingValue struct {
Category string
Subcategory string
}
// ImportTrail is the boundary between plugin output and wanderer records. It
// validates the provider identity, deduplicates by trail_external_reference,
// stores the GPX/photos, maps GPX metrics onto the trail record, and creates the
@@ -78,7 +88,7 @@ func ImportTrail(ctx context.Context, app core.App, item pluginsystem.TrailImpor
applyProviderStart(&metrics, trackIndex, item.Metadata)
applyProviderMetrics(&metrics, item.Metadata)
public := publicFromPrivacy(item.Privacy, opts.DefaultPublic)
categoryID := categoryIDForImport(app, item, opts.CategoryMapping)
categoryTarget := categoryTargetForImport(app, item, opts.CategoryMapping)
date := dateFromImport(item, metrics)
mediaBudget := &pluginMediaBudget{}
photos := photoFiles(ctx, app, item.Photos, opts, mediaBudget)
@@ -96,7 +106,8 @@ func ImportTrail(ctx context.Context, app core.App, item pluginsystem.TrailImpor
"lat": metrics.StartLat,
"lon": metrics.StartLon,
"difficulty": "easy",
"category": categoryID,
"category": categoryTarget.CategoryID,
"subcategory": categoryTarget.SubcategoryID,
"author": opts.ActorID,
})
record.Set("gpx", gpxFile)
@@ -772,11 +783,15 @@ func createSummitLog(app core.App, trailID string, actorID string, date time.Tim
return app.Save(record)
}
func categoryIDForImport(app core.App, item pluginsystem.TrailImport, mapping map[string]string) string {
if category, matched := CategoryFromProviderMapping(app, ProviderCategoryFromImport(item), mapping); matched {
return category
func categoryTargetForImport(app core.App, item pluginsystem.TrailImport, mapping map[string]CategoryMappingValue) CategoryMappingTarget {
if categoryTarget, matched := CategoryTargetFromProviderMapping(app, ProviderCategoryFromImport(item), mapping); matched {
return categoryTarget
}
return categoryIDForActivityType(app, item.ActivityType)
return categoryTargetForActivityType(app, item.ActivityType)
}
func categoryIDForImport(app core.App, item pluginsystem.TrailImport, mapping map[string]CategoryMappingValue) string {
return categoryTargetForImport(app, item, mapping).CategoryID
}
func ProviderCategoryFromImport(item pluginsystem.TrailImport) string {
@@ -788,58 +803,109 @@ func ProviderCategoryFromImport(item pluginsystem.TrailImport) string {
return strings.TrimSpace(value)
}
func CategoryFromProviderMapping(app core.App, providerCategory string, mapping map[string]string) (string, bool) {
func CategoryFromProviderMapping(app core.App, providerCategory string, mapping map[string]CategoryMappingValue) (string, bool) {
target, matched := CategoryTargetFromProviderMapping(app, providerCategory, mapping)
return target.CategoryID, matched
}
func CategoryTargetFromProviderMapping(app core.App, providerCategory string, mapping map[string]CategoryMappingValue) (CategoryMappingTarget, bool) {
providerCategory = strings.TrimSpace(providerCategory)
if providerCategory == "" || len(mapping) == 0 {
return "", false
return CategoryMappingTarget{}, false
}
rawTarget, matched := mapping[providerCategory]
mappingTarget, matched := mapping[providerCategory]
if !matched {
return "", false
return CategoryMappingTarget{}, false
}
target := strings.TrimSpace(rawTarget)
if target == "" {
return "", true
if mappingTarget.Category == "" && mappingTarget.Subcategory == "" {
return CategoryMappingTarget{}, true
}
if category, err := app.FindRecordById("categories", target); err == nil && category != nil {
return category.Id, true
}
category, _ := app.FindFirstRecordByData("categories", "name", target)
if category == nil {
return "", false
}
return category.Id, true
return resolveCategoryMappingTarget(app, mappingTarget)
}
// categoryIDForActivityType maps common provider activity labels to wanderer's
// built-in categories. Unknown labels intentionally leave the category empty.
func categoryIDForActivityType(app core.App, activityType string) string {
categoryMap := map[string]string{
"hiking": "Hiking",
"hike": "Hiking",
"walking": "Walking",
"walk": "Walking",
"running": "Walking",
"run": "Walking",
"biking": "Biking",
"cycling": "Biking",
"ride": "Biking",
"mtb": "Biking",
"skiing": "Skiing",
"canoeing": "Canoeing",
"climbing": "Climbing",
}
return categoryTargetForActivityType(app, activityType).CategoryID
}
name := categoryMap[strings.ToLower(activityType)]
func categoryTargetForActivityType(app core.App, activityType string) CategoryMappingTarget {
name := categoryNameForActivityType(activityType)
if name == "" {
return ""
return CategoryMappingTarget{}
}
category, _ := app.FindFirstRecordByData("categories", "name", name)
if category == nil {
return ""
target, matched := resolveCategoryMappingTarget(app, CategoryMappingValue{Category: name})
if !matched {
return CategoryMappingTarget{}
}
return category.Id
return target
}
func categoryNameForActivityType(activityType string) string {
categoryMap := map[string]string{
"hiking": "Hiking",
"hike": "Hiking",
"walking": "Walking",
"walk": "Walking",
"running": "Running",
"run": "Running",
"virtualrun": "Running",
"trailrun": "Running",
"jogging": "Running",
"biking": "Biking",
"cycling": "Biking",
"ride": "Biking",
"mtb": "Biking",
"skiing": "Skiing",
"canoeing": "Canoeing",
"climbing": "Climbing",
}
return categoryMap[strings.ToLower(strings.TrimSpace(activityType))]
}
func resolveCategoryMappingTarget(app core.App, target CategoryMappingValue) (CategoryMappingTarget, bool) {
categoryNameOrID := strings.TrimSpace(target.Category)
subcategoryNameOrID := strings.TrimSpace(target.Subcategory)
if categoryNameOrID == "" && subcategoryNameOrID == "" {
return CategoryMappingTarget{}, false
}
if subcategoryNameOrID != "" {
if subcategory, err := app.FindRecordById("subcategories", subcategoryNameOrID); err == nil && subcategory != nil {
categoryID := subcategory.GetString("category")
if categoryID == "" {
return CategoryMappingTarget{}, false
}
return CategoryMappingTarget{CategoryID: categoryID, SubcategoryID: subcategory.Id}, true
}
category, subcategory, err := util.ResolveCategoryAndSubcategoryByNormalizedNames(app, categoryNameOrID, subcategoryNameOrID)
if err == nil && category != nil && subcategory != nil {
return CategoryMappingTarget{CategoryID: category.Id, SubcategoryID: subcategory.Id}, true
}
return CategoryMappingTarget{}, false
}
if category, err := app.FindRecordById("categories", categoryNameOrID); err == nil && category != nil {
return CategoryMappingTarget{CategoryID: category.Id}, true
}
if subcategory, err := app.FindRecordById("subcategories", categoryNameOrID); err == nil && subcategory != nil {
categoryID := subcategory.GetString("category")
if categoryID == "" {
return CategoryMappingTarget{}, false
}
return CategoryMappingTarget{CategoryID: categoryID, SubcategoryID: subcategory.Id}, true
}
category, _ := util.FindCategoryByNormalizedName(app, categoryNameOrID)
if category != nil {
return CategoryMappingTarget{CategoryID: category.Id}, true
}
return CategoryMappingTarget{}, false
}
func fallbackName(name string) string {

View File

@@ -7,6 +7,9 @@ import (
"testing"
"time"
"github.com/pocketbase/pocketbase/core"
pbtests "github.com/pocketbase/pocketbase/tests"
pluginsystem "pocketbase/pluginsystem"
"pocketbase/util"
)
@@ -264,7 +267,7 @@ func TestCategoryIDForImportDoesNotFallbackWhenProviderMappingIsBlank(t *testing
},
}
if got := categoryIDForImport(nil, item, map[string]string{"Ride": ""}); got != "" {
if got := categoryIDForImport(nil, item, map[string]CategoryMappingValue{"Ride": {}}); got != "" {
t.Fatalf("expected blank provider mapping to suppress activity fallback, got %q", got)
}
}
@@ -282,6 +285,121 @@ func TestProviderCategoryFromImport(t *testing.T) {
}
}
func TestCategoryNameForActivityType(t *testing.T) {
cases := map[string]string{
"run": "Running",
"running": "Running",
"VirtualRun": "Running",
"trailrun": "Running",
"jogging": "Running",
"walk": "Walking",
"hike": "Hiking",
"unknown": "",
}
for activityType, want := range cases {
if got := categoryNameForActivityType(activityType); got != want {
t.Fatalf("categoryNameForActivityType(%q) = %q, want %q", activityType, got, want)
}
}
}
func TestCategoryFromProviderMappingUsesNormalizedCategoryName(t *testing.T) {
app := setupImporterCategoryTestApp(t)
category := core.NewRecord(mustFindImporterTestCollection(t, app, "categories"))
category.Set("name", "Trail Running")
if err := app.Save(category); err != nil {
t.Fatal(err)
}
got, matched := CategoryFromProviderMapping(app, "Run", map[string]CategoryMappingValue{"Run": {Category: "trail-running"}})
if !matched {
t.Fatal("expected provider mapping to match")
}
if got != category.Id {
t.Fatalf("CategoryFromProviderMapping() = %q, want %q", got, category.Id)
}
}
func TestCategoryTargetFromProviderMappingSupportsSubcategoryPath(t *testing.T) {
app := setupImporterCategoryTestApp(t)
category := core.NewRecord(mustFindImporterTestCollection(t, app, "categories"))
category.Set("name", "Running")
if err := app.Save(category); err != nil {
t.Fatal(err)
}
subcategory := core.NewRecord(mustFindImporterTestCollection(t, app, "subcategories"))
subcategory.Set("category", category.Id)
subcategory.Set("name", "Trail")
if err := app.Save(subcategory); err != nil {
t.Fatal(err)
}
target, matched := CategoryTargetFromProviderMapping(app, "TrailRun", map[string]CategoryMappingValue{"TrailRun": {Category: "Running", Subcategory: "Trail"}})
if !matched {
t.Fatal("expected provider mapping to match")
}
if target.CategoryID != category.Id || target.SubcategoryID != subcategory.Id {
t.Fatalf("CategoryTargetFromProviderMapping() = %#v, want category=%q subcategory=%q", target, category.Id, subcategory.Id)
}
}
func TestCategoryTargetFromProviderMappingPrefersLiteralCategoryWithSlash(t *testing.T) {
app := setupImporterCategoryTestApp(t)
slashCategory := core.NewRecord(mustFindImporterTestCollection(t, app, "categories"))
slashCategory.Set("name", "Foo/Bar")
if err := app.Save(slashCategory); err != nil {
t.Fatal(err)
}
parentCategory := core.NewRecord(mustFindImporterTestCollection(t, app, "categories"))
parentCategory.Set("name", "Foo")
if err := app.Save(parentCategory); err != nil {
t.Fatal(err)
}
subcategory := core.NewRecord(mustFindImporterTestCollection(t, app, "subcategories"))
subcategory.Set("category", parentCategory.Id)
subcategory.Set("name", "Bar")
if err := app.Save(subcategory); err != nil {
t.Fatal(err)
}
target, matched := CategoryTargetFromProviderMapping(app, "Provider", map[string]CategoryMappingValue{"Provider": {Category: "Foo/Bar"}})
if !matched {
t.Fatal("expected provider mapping to match")
}
if target.CategoryID != slashCategory.Id || target.SubcategoryID != "" {
t.Fatalf("CategoryTargetFromProviderMapping() = %#v, want literal category %q", target, slashCategory.Id)
}
}
func TestCategoryTargetFromProviderMappingDoesNotSplitSlashCategoryName(t *testing.T) {
app := setupImporterCategoryTestApp(t)
category := core.NewRecord(mustFindImporterTestCollection(t, app, "categories"))
category.Set("name", "Foo")
if err := app.Save(category); err != nil {
t.Fatal(err)
}
subcategory := core.NewRecord(mustFindImporterTestCollection(t, app, "subcategories"))
subcategory.Set("category", category.Id)
subcategory.Set("name", "Bar")
if err := app.Save(subcategory); err != nil {
t.Fatal(err)
}
target, matched := CategoryTargetFromProviderMapping(app, "Provider", map[string]CategoryMappingValue{"Provider": {Category: "Foo/Bar"}})
if matched {
t.Fatalf("CategoryTargetFromProviderMapping() = %#v, expected slash category name not to be split", target)
}
}
func TestDateFromImport(t *testing.T) {
started := time.Date(2025, 6, 1, 8, 0, 0, 0, time.UTC)
@@ -430,3 +548,42 @@ func TestRemoveRawQueryParamOrdered(t *testing.T) {
t.Fatalf("unexpected query: %q", got)
}
}
func setupImporterCategoryTestApp(t *testing.T) *pbtests.TestApp {
t.Helper()
app, err := pbtests.NewTestApp(t.TempDir())
if err != nil {
t.Fatal(err)
}
categories := core.NewBaseCollection("categories")
categories.Fields.Add(&core.TextField{Name: "name", Required: true})
if err := app.Save(categories); err != nil {
app.Cleanup()
t.Fatal(err)
}
subcategories := core.NewBaseCollection("subcategories")
subcategories.Fields.Add(
&core.RelationField{Name: "category", CollectionId: categories.Id, MaxSelect: 1, Required: true},
&core.TextField{Name: "name", Required: true},
)
if err := app.Save(subcategories); err != nil {
app.Cleanup()
t.Fatal(err)
}
return app
}
func mustFindImporterTestCollection(t *testing.T, app core.App, name string) *core.Collection {
t.Helper()
collection, err := app.FindCollectionByNameOrId(name)
if err != nil {
t.Fatal(err)
}
return collection
}

View File

@@ -0,0 +1,52 @@
package routes
import (
"net/http"
"pocketbase/util"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
)
type categoryPreferenceReorderRequest struct {
Categories []string `json:"categories"`
}
type subcategoryPreferenceReorderRequest struct {
Category string `json:"category"`
Subcategories []string `json:"subcategories"`
}
func CategoryPreferencesReorder(e *core.RequestEvent) error {
if e.Auth == nil {
return apis.NewUnauthorizedError("authentication required", nil)
}
var request categoryPreferenceReorderRequest
if err := e.BindBody(&request); err != nil {
return apis.NewBadRequestError("failed to read request data", err)
}
if err := util.ReorderUserCategoryPreferences(e.App, e.Auth.Id, request.Categories); err != nil {
return apis.NewBadRequestError(err.Error(), err)
}
return e.JSON(http.StatusOK, map[string]any{"acknowledged": true})
}
func SubcategoryPreferencesReorder(e *core.RequestEvent) error {
if e.Auth == nil {
return apis.NewUnauthorizedError("authentication required", nil)
}
var request subcategoryPreferenceReorderRequest
if err := e.BindBody(&request); err != nil {
return apis.NewBadRequestError("failed to read request data", err)
}
if err := util.ReorderUserSubcategoryPreferences(e.App, e.Auth.Id, request.Category, request.Subcategories); err != nil {
return apis.NewBadRequestError(err.Error(), err)
}
return e.JSON(http.StatusOK, map[string]any{"acknowledged": true})
}

View File

@@ -24,8 +24,9 @@ type pluginCategoryRemapResponse struct {
}
type pluginCategoryRemapCandidate struct {
Trail *core.Record
CategoryID string
Trail *core.Record
CategoryID string
SubcategoryID string
}
type pluginCategoryTrailReference struct {
@@ -72,6 +73,7 @@ func PluginSystemCategoryRemapApply(e *core.RequestEvent) error {
return err
}
trail.Set("category", candidate.CategoryID)
trail.Set("subcategory", candidate.SubcategoryID)
if err := txApp.Save(trail); err != nil {
return err
}
@@ -84,7 +86,7 @@ func PluginSystemCategoryRemapApply(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, pluginCategoryRemapResponse{Count: len(candidates), Remapped: remapped})
}
func pluginCategoryRemapInput(e *core.RequestEvent) (*core.Record, map[string]string, error) {
func pluginCategoryRemapInput(e *core.RequestEvent) (*core.Record, map[string]importer.CategoryMappingValue, error) {
if e.Auth == nil {
return nil, nil, apis.NewUnauthorizedError("authentication required", nil)
}
@@ -109,7 +111,7 @@ func pluginCategoryRemapInput(e *core.RequestEvent) (*core.Record, map[string]st
return instance, categoryMapping(pluginHostConfig(config)), nil
}
func pluginCategoryRemapCandidates(app core.App, userID string, pluginID string, mapping map[string]string) ([]pluginCategoryRemapCandidate, error) {
func pluginCategoryRemapCandidates(app core.App, userID string, pluginID string, mapping map[string]importer.CategoryMappingValue) ([]pluginCategoryRemapCandidate, error) {
if userID == "" || pluginID == "" || len(mapping) == 0 {
return nil, nil
}
@@ -122,7 +124,7 @@ func pluginCategoryRemapCandidates(app core.App, userID string, pluginID string,
return pluginCategoryRemapCandidatesFromRefs(app, refs, mapping), nil
}
func pluginCategoryRemapCandidatesFromRefs(app core.App, refs []pluginCategoryTrailReference, mapping map[string]string) []pluginCategoryRemapCandidate {
func pluginCategoryRemapCandidatesFromRefs(app core.App, refs []pluginCategoryTrailReference, mapping map[string]importer.CategoryMappingValue) []pluginCategoryRemapCandidate {
if len(refs) == 0 || len(mapping) == 0 {
return nil
}
@@ -130,19 +132,23 @@ func pluginCategoryRemapCandidatesFromRefs(app core.App, refs []pluginCategoryTr
candidates := make([]pluginCategoryRemapCandidate, 0, len(refs))
for _, ref := range refs {
providerCategory := strings.TrimSpace(ref.Ref.GetString("provider_category"))
categoryID, matched := importer.CategoryFromProviderMapping(app, providerCategory, mapping)
if !matched || categoryID == "" || ref.Trail.GetString("category") == categoryID {
target, matched := importer.CategoryTargetFromProviderMapping(app, providerCategory, mapping)
if !matched || target.CategoryID == "" {
continue
}
if ref.Trail.GetString("category") == target.CategoryID && ref.Trail.GetString("subcategory") == target.SubcategoryID {
continue
}
candidates = append(candidates, pluginCategoryRemapCandidate{
Trail: ref.Trail,
CategoryID: categoryID,
Trail: ref.Trail,
CategoryID: target.CategoryID,
SubcategoryID: target.SubcategoryID,
})
}
return candidates
}
func pluginCategoryBackfilledSinceMappingCountFromRefs(app core.App, instance *core.Record, refs []pluginCategoryTrailReference, mapping map[string]string) int {
func pluginCategoryBackfilledSinceMappingCountFromRefs(app core.App, instance *core.Record, refs []pluginCategoryTrailReference, mapping map[string]importer.CategoryMappingValue) int {
mappingUpdatedAt := categoryMappingUpdatedAt(app, instance)
if mappingUpdatedAt.IsZero() || len(refs) == 0 || len(mapping) == 0 {
return 0
@@ -155,8 +161,8 @@ func pluginCategoryBackfilledSinceMappingCountFromRefs(app core.App, instance *c
continue
}
providerCategory := strings.TrimSpace(ref.Ref.GetString("provider_category"))
categoryID, matched := importer.CategoryFromProviderMapping(app, providerCategory, mapping)
if matched && categoryID != "" && ref.Trail.GetString("category") != categoryID {
target, matched := importer.CategoryTargetFromProviderMapping(app, providerCategory, mapping)
if matched && target.CategoryID != "" && (ref.Trail.GetString("category") != target.CategoryID || ref.Trail.GetString("subcategory") != target.SubcategoryID) {
count++
}
}

View File

@@ -578,24 +578,31 @@ func boolOption(config map[string]any, key string, fallback bool) bool {
return value
}
func categoryMapping(config map[string]any) map[string]string {
func categoryMapping(config map[string]any) map[string]importer.CategoryMappingValue {
raw, ok := config["categoryMapping"].(map[string]any)
if !ok {
return nil
}
result := make(map[string]string, len(raw))
result := make(map[string]importer.CategoryMappingValue, len(raw))
for key, value := range raw {
category, ok := value.(string)
if ok {
result[key] = category
switch typed := value.(type) {
case string:
result[key] = importer.CategoryMappingValue{Category: strings.TrimSpace(typed)}
case map[string]any:
category, _ := typed["category"].(string)
subcategory, _ := typed["subcategory"].(string)
result[key] = importer.CategoryMappingValue{
Category: strings.TrimSpace(category),
Subcategory: strings.TrimSpace(subcategory),
}
}
}
return result
}
func hasUsableCategoryMapping(mapping map[string]string) bool {
for _, category := range mapping {
if strings.TrimSpace(category) != "" {
func hasUsableCategoryMapping(mapping map[string]importer.CategoryMappingValue) bool {
for _, target := range mapping {
if strings.TrimSpace(target.Category) != "" || strings.TrimSpace(target.Subcategory) != "" {
return true
}
}

View File

@@ -1,6 +1,10 @@
package routes
import "testing"
import (
"testing"
"pocketbase/plugins/importer"
)
func TestCategoryMappingPreservesExplicitEmptyMap(t *testing.T) {
mapping := categoryMapping(map[string]any{
@@ -29,7 +33,22 @@ func TestCategoryMappingPreservesBlankProviderMapping(t *testing.T) {
if mapping == nil {
t.Fatal("expected category mapping")
}
if value, ok := mapping["Ride"]; !ok || value != "" {
if value, ok := mapping["Ride"]; !ok || value != (importer.CategoryMappingValue{}) {
t.Fatalf("expected blank provider mapping to be preserved, got %#v", mapping)
}
}
func TestCategoryMappingParsesStructuredTarget(t *testing.T) {
mapping := categoryMapping(map[string]any{
"categoryMapping": map[string]any{
"TrailRun": map[string]any{
"category": "Running",
"subcategory": "Trail",
},
},
})
want := importer.CategoryMappingValue{Category: "Running", Subcategory: "Trail"}
if value, ok := mapping["TrailRun"]; !ok || value != want {
t.Fatalf("structured provider mapping = %#v, want %#v", mapping, want)
}
}

View File

@@ -237,15 +237,39 @@ func performFullSync(app core.App, ctx context.Context, reqURL *url.URL, localTr
// --- Sub-Sync Helpers ---
func syncTrailMetadata(app core.App, record *core.Record, data map[string]any) {
// Resolve Category if present in expand
var federatedCategoryName, federatedSubcategoryName string
if expand, ok := data["expand"].(map[string]any); ok {
if cat, ok := expand["category"].(map[string]any); ok {
if name, ok := cat["name"].(string); ok {
if c, _ := app.FindFirstRecordByData("categories", "name", name); c != nil {
record.Set("category", c.Id)
}
federatedCategoryName = name
}
}
if subcat, ok := expand["subcategory"].(map[string]any); ok {
if name, ok := subcat["name"].(string); ok {
federatedSubcategoryName = name
}
}
}
if federatedCategoryName != "" {
record.Set("federated_category_name", federatedCategoryName)
}
if federatedSubcategoryName != "" {
record.Set("federated_subcategory_name", federatedSubcategoryName)
}
category, subcategory, err := util.ResolveCategoryAndSubcategoryByNormalizedNames(app, federatedCategoryName, federatedSubcategoryName)
if err == nil && category != nil {
record.Set("category", category.Id)
if subcategory != nil {
record.Set("subcategory", subcategory.Id)
} else {
record.Set("subcategory", "")
}
} else if err == nil && federatedCategoryName != "" {
record.Set("category", "")
record.Set("subcategory", "")
}
// Resolve Tags
@@ -260,8 +284,11 @@ func syncTrailMetadata(app core.App, record *core.Record, data map[string]any) {
delete(data, "gpx")
delete(data, "author")
delete(data, "category")
delete(data, "subcategory")
delete(data, "tags")
delete(data, "iri")
delete(data, "federated_category_name")
delete(data, "federated_subcategory_name")
record.Load(data)
}

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
}