diff --git a/db/federation/create.go b/db/federation/create.go index 0cf686e9..5bb97d7f 100644 --- a/db/federation/create.go +++ b/db/federation/create.go @@ -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 != "" { diff --git a/db/hooks/categories.go b/db/hooks/categories.go new file mode 100644 index 00000000..f3d061d2 --- /dev/null +++ b/db/hooks/categories.go @@ -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 +} diff --git a/db/hooks/users.go b/db/hooks/users.go index 638167b1..43e0585c 100644 --- a/db/hooks/users.go +++ b/db/hooks/users.go @@ -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 diff --git a/db/main.go b/db/main.go index 08cc9e92..fad75242 100644 --- a/db/main.go +++ b/db/main.go @@ -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"}, }, diff --git a/db/migrations/1781000000_categories_redesign.go b/db/migrations/1781000000_categories_redesign.go new file mode 100644 index 00000000..c2b947b7 --- /dev/null +++ b/db/migrations/1781000000_categories_redesign.go @@ -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) +} diff --git a/db/migrations/initial_data/biking.jpg b/db/migrations/initial_data/biking.jpg deleted file mode 100644 index 18a00b40..00000000 Binary files a/db/migrations/initial_data/biking.jpg and /dev/null differ diff --git a/db/migrations/initial_data/canoeing.jpg b/db/migrations/initial_data/canoeing.jpg deleted file mode 100644 index 3809cdfc..00000000 Binary files a/db/migrations/initial_data/canoeing.jpg and /dev/null differ diff --git a/db/migrations/initial_data/climbing.jpg b/db/migrations/initial_data/climbing.jpg deleted file mode 100644 index 2ebef9b9..00000000 Binary files a/db/migrations/initial_data/climbing.jpg and /dev/null differ diff --git a/db/migrations/initial_data/hiking.jpg b/db/migrations/initial_data/hiking.jpg deleted file mode 100644 index 9700419b..00000000 Binary files a/db/migrations/initial_data/hiking.jpg and /dev/null differ diff --git a/db/migrations/initial_data/skiing.jpg b/db/migrations/initial_data/skiing.jpg deleted file mode 100644 index dede5023..00000000 Binary files a/db/migrations/initial_data/skiing.jpg and /dev/null differ diff --git a/db/migrations/initial_data/walking.jpg b/db/migrations/initial_data/walking.jpg deleted file mode 100644 index 167a70d5..00000000 Binary files a/db/migrations/initial_data/walking.jpg and /dev/null differ diff --git a/db/plugins/importer/importer.go b/db/plugins/importer/importer.go index a427c9b4..7d1dd8c1 100644 --- a/db/plugins/importer/importer.go +++ b/db/plugins/importer/importer.go @@ -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 { diff --git a/db/plugins/importer/importer_test.go b/db/plugins/importer/importer_test.go index ce08dc67..8d85a887 100644 --- a/db/plugins/importer/importer_test.go +++ b/db/plugins/importer/importer_test.go @@ -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 +} diff --git a/db/routes/category_preferences.go b/db/routes/category_preferences.go new file mode 100644 index 00000000..8e2a7060 --- /dev/null +++ b/db/routes/category_preferences.go @@ -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}) +} diff --git a/db/routes/plugin_system_category_remap.go b/db/routes/plugin_system_category_remap.go index 70794183..88447e20 100644 --- a/db/routes/plugin_system_category_remap.go +++ b/db/routes/plugin_system_category_remap.go @@ -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++ } } diff --git a/db/routes/plugin_system_sync.go b/db/routes/plugin_system_sync.go index 7c5cb471..93443807 100644 --- a/db/routes/plugin_system_sync.go +++ b/db/routes/plugin_system_sync.go @@ -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 } } diff --git a/db/routes/plugin_system_sync_test.go b/db/routes/plugin_system_sync_test.go index 62f87aaf..1d21b9b6 100644 --- a/db/routes/plugin_system_sync_test.go +++ b/db/routes/plugin_system_sync_test.go @@ -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) + } +} diff --git a/db/routes/remote_trail.go b/db/routes/remote_trail.go index 3f9eeb84..a32fd8bf 100644 --- a/db/routes/remote_trail.go +++ b/db/routes/remote_trail.go @@ -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) } diff --git a/db/util/activitypub.go b/db/util/activitypub.go index 74fde892..452614ec 100644 --- a/db/util/activitypub.go +++ b/db/util/activitypub.go @@ -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")) diff --git a/db/util/category.go b/db/util/category.go new file mode 100644 index 00000000..7270d0ac --- /dev/null +++ b/db/util/category.go @@ -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 +} diff --git a/db/util/category_defaults.go b/db/util/category_defaults.go new file mode 100644 index 00000000..b81b9750 --- /dev/null +++ b/db/util/category_defaults.go @@ -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 +} diff --git a/db/util/category_preference.go b/db/util/category_preference.go new file mode 100644 index 00000000..fdea759f --- /dev/null +++ b/db/util/category_preference.go @@ -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 + }) +} diff --git a/db/util/category_test.go b/db/util/category_test.go new file mode 100644 index 00000000..a2245f8d --- /dev/null +++ b/db/util/category_test.go @@ -0,0 +1,1301 @@ +package util + +import ( + "reflect" + "strings" + "testing" + + "github.com/pocketbase/pocketbase/core" + pbtests "github.com/pocketbase/pocketbase/tests" + "github.com/pocketbase/pocketbase/tools/types" +) + +func TestNormalizeCategoryName(t *testing.T) { + t.Run("WhitespaceAndSeparators", func(t *testing.T) { + for _, value := range []string{"E-Bike", "E Bike", "e-bike"} { + if got := NormalizeCategoryName(value); got != "e bike" { + t.Fatalf("NormalizeCategoryName(%q) = %q, want %q", value, got, "e bike") + } + } + + if got := NormalizeCategoryName(" Mountain Biking "); got != "mountain biking" { + t.Fatalf("NormalizeCategoryName(%q) = %q, want %q", " Mountain Biking ", got, "mountain biking") + } + }) + + t.Run("AccentFolding", func(t *testing.T) { + for _, value := range []string{"Canoë", "Canoe"} { + if got := NormalizeCategoryName(value); got != "canoe" { + t.Fatalf("NormalizeCategoryName(%q) = %q, want %q", value, got, "canoe") + } + } + }) + + t.Run("SeparatorsNotRemoved", func(t *testing.T) { + if got := NormalizeCategoryName("EBike"); got != "ebike" { + t.Fatalf("NormalizeCategoryName(%q) = %q, want %q", "EBike", got, "ebike") + } + + if NormalizeCategoryName("EBike") == NormalizeCategoryName("E Bike") { + t.Fatalf("expected %q and %q to normalize differently", "EBike", "E Bike") + } + }) +} + +func TestParseCategoryTranslations(t *testing.T) { + tests := []struct { + name string + input types.JSONRaw + wantErr bool + want map[string]CategoryTranslation + }{ + { + name: "valid base locale", + input: types.JSONRaw(`{"de": {"name": "Wandern", "short_name": "WAND"}}`), + want: map[string]CategoryTranslation{"de": {Name: "Wandern", ShortName: "WAND"}}, + }, + { + name: "null is nil without error", + input: types.JSONRaw(`null`), + want: nil, + }, + { + name: "region locale rejected", + input: types.JSONRaw(`{"pt-BR": {"name": "Caminhada"}}`), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseCategoryTranslations(tt.input) + if tt.wantErr { + if err == nil { + t.Fatal("ParseCategoryTranslations() error = nil, want error") + } + return + } + if err != nil { + t.Fatalf("ParseCategoryTranslations() error = %v", err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("ParseCategoryTranslations() = %#v, want %#v", got, tt.want) + } + }) + } +} + +func TestDefaultCategoryTranslationsAreSupportedAndNonEmpty(t *testing.T) { + for _, category := range DefaultCategoryNames() { + translations, ok := defaultCategoryTranslations[category] + if !ok { + t.Fatalf("defaultCategoryTranslations missing category %q", category) + } + + for locale, name := range translations { + if _, ok := supportedCategoryLocales[locale]; !ok { + t.Fatalf("defaultCategoryTranslations[%q] contains unsupported locale %q", category, locale) + } + + if name == "" { + t.Fatalf("defaultCategoryTranslations[%q][%q] is empty", category, locale) + } + } + } +} + +func TestDefaultCategoryIconsAreNonEmpty(t *testing.T) { + for _, category := range DefaultCategoryNames() { + icon, ok := defaultCategoryIcons[category] + if !ok { + t.Fatalf("defaultCategoryIcons missing category %q", category) + } + + if strings.TrimSpace(icon) == "" { + t.Fatalf("defaultCategoryIcons[%q] is empty", category) + } + } +} + +func TestSeedDefaultCategoriesAddsMissingDefaults(t *testing.T) { + app := setupCategoryValidationTestApp(t) + defer app.Cleanup() + + running := createTestCategory(t, app, "Running") + + if err := SeedDefaultCategories(app); err != nil { + t.Fatalf("SeedDefaultCategories() error = %v", err) + } + + categories, err := app.FindAllRecords("categories") + if err != nil { + t.Fatal(err) + } + + byName := map[string]*core.Record{} + for _, category := range categories { + byName[category.GetString("name")] = category + } + + for _, name := range DefaultCategoryNames() { + if byName[name] == nil { + t.Fatalf("default category %q was not seeded", name) + } + } + if byName["Running"].Id != running.Id { + t.Fatalf("existing Running category was replaced: got %q, want %q", byName["Running"].Id, running.Id) + } + if len(categories) != len(DefaultCategoryNames()) { + t.Fatalf("category count = %d, want %d", len(categories), len(DefaultCategoryNames())) + } +} + +func TestMergeDefaultCategoryTranslations(t *testing.T) { + merged, changed := mergeDefaultCategoryTranslations( + map[string]string{ + "de": "Wandern", + "en": "Hiking", + "fr": "", + }, + map[string]CategoryTranslation{ + "de": { + Name: "Custom Wandern", + ShortName: "WAND", + }, + }, + ) + + if !changed { + t.Fatal("mergeDefaultCategoryTranslations changed = false, want true") + } + + want := map[string]CategoryTranslation{ + "de": { + Name: "Custom Wandern", + ShortName: "WAND", + }, + "en": { + Name: "Hiking", + }, + } + if !reflect.DeepEqual(merged, want) { + t.Fatalf("mergeDefaultCategoryTranslations() = %#v, want %#v", merged, want) + } +} + +func TestMergeDefaultSubcategoryTranslations(t *testing.T) { + merged, changed := mergeDefaultSubcategoryTranslations( + map[string]CategoryTranslation{ + "de": { + Name: "Rennrad", + ShortName: "ROAD", + }, + "en": { + Name: "Road", + ShortName: "ROAD", + }, + }, + map[string]CategoryTranslation{ + "de": { + Name: "Custom Road", + ShortName: "ROADX", + }, + "en": { + Name: "Road", + }, + }, + ) + + if !changed { + t.Fatal("mergeDefaultSubcategoryTranslations changed = false, want true") + } + + want := map[string]CategoryTranslation{ + "de": { + Name: "Custom Road", + ShortName: "ROADX", + }, + "en": { + Name: "Road", + ShortName: "ROAD", + }, + } + if !reflect.DeepEqual(merged, want) { + t.Fatalf("mergeDefaultSubcategoryTranslations() = %#v, want %#v", merged, want) + } +} + +func TestPrepopulateDefaultCategoryIcons(t *testing.T) { + app := setupCategoryValidationTestApp(t) + defer app.Cleanup() + + hiking := createTestCategory(t, app, "Hiking") + walking := createTestCategory(t, app, "Walking") + canoeing := createTestCategory(t, app, "Canoeing") + walking.Set("icon", "custom-icon") + if err := app.Save(walking); err != nil { + t.Fatal(err) + } + canoeing.Set("icon", "ship") + if err := app.Save(canoeing); err != nil { + t.Fatal(err) + } + + if err := PrepopulateDefaultCategoryIcons(app); err != nil { + t.Fatalf("PrepopulateDefaultCategoryIcons() error = %v", err) + } + + hiking, err := app.FindRecordById("categories", hiking.Id) + if err != nil { + t.Fatal(err) + } + walking, err = app.FindRecordById("categories", walking.Id) + if err != nil { + t.Fatal(err) + } + canoeing, err = app.FindRecordById("categories", canoeing.Id) + if err != nil { + t.Fatal(err) + } + + if got := hiking.GetString("icon"); got != defaultCategoryIcons["Hiking"] { + t.Fatalf("hiking icon = %q, want %q", got, defaultCategoryIcons["Hiking"]) + } + if got := walking.GetString("icon"); got != "custom-icon" { + t.Fatalf("walking icon = %q, want custom icon", got) + } + if got := canoeing.GetString("icon"); got != "ship" { + t.Fatalf("canoeing icon = %q, want existing icon", got) + } +} + +func TestDefaultSubcategories(t *testing.T) { + seen := map[string]struct{}{} + wantDefaults := map[string]struct{}{ + "Hiking/Winter": {}, + "Hiking/Alpine": {}, + "Hiking/Long-distance": {}, + "Hiking/Snowshoeing": {}, + "Hiking/Family": {}, + "Hiking/Pilgrimage": {}, + "Biking/Touring": {}, + "Running/Trail": {}, + "Running/Road": {}, + "Skiing/Cross-country": {}, + "Skiing/Skating": {}, + "Skiing/Backcountry": {}, + } + wantBadgeIcons := map[string]string{ + "Biking/MTB": "mountain", + "Biking/Road": "grip-lines-vertical", + "Biking/E-Bike": "bolt", + "Hiking/Winter": "snowflake", + "Hiking/Alpine": "mountain", + "Hiking/Snowshoeing": "snowflake", + "Hiking/Family": "child", + "Hiking/Pilgrimage": "cross", + "Running/Road": "grip-lines-vertical", + } + for _, subcategory := range defaultSubcategories { + if subcategory.parentCategory == "" { + t.Fatalf("default subcategory %q parent is empty", subcategory.name) + } + if subcategory.name == "" { + t.Fatal("default subcategory name is empty") + } + if subcategory.shortName == "" { + t.Fatalf("default subcategory %q shortName is empty", subcategory.name) + } + + normalizedName := NormalizeCategoryName(subcategory.name) + key := subcategory.parentCategory + "/" + normalizedName + if _, ok := seen[key]; ok { + t.Fatalf("default subcategory %q duplicates normalized key %q", subcategory.name, key) + } + seen[key] = struct{}{} + + if _, ok := wantDefaults[subcategory.parentCategory+"/"+subcategory.name]; ok { + delete(wantDefaults, subcategory.parentCategory+"/"+subcategory.name) + } + if wantBadgeIcon, ok := wantBadgeIcons[subcategory.parentCategory+"/"+subcategory.name]; ok { + if subcategory.badgeIcon != wantBadgeIcon { + t.Fatalf("%s/%s badgeIcon = %q, want %q", subcategory.parentCategory, subcategory.name, subcategory.badgeIcon, wantBadgeIcon) + } + } + if subcategory.parentCategory == "Hiking" && subcategory.name == "Winter" { + if subcategory.translations["de"].Name != "Winterwandern" { + t.Fatalf("Winter de translation = %q, want Winterwandern", subcategory.translations["de"].Name) + } + } + if subcategory.parentCategory == "Hiking" && subcategory.name == "Pilgrimage" { + if subcategory.translations["de"].Name != "Pilgern" { + t.Fatalf("Pilgrimage de translation = %q, want Pilgern", subcategory.translations["de"].Name) + } + } + if subcategory.parentCategory == "Biking" && subcategory.name == "Touring" { + if subcategory.translations["de"].Name != "Tourenrad" { + t.Fatalf("Touring de translation = %q, want Tourenrad", subcategory.translations["de"].Name) + } + } + } + + if len(wantDefaults) > 0 { + t.Fatalf("default subcategories missing entries: %#v", wantDefaults) + } +} + +func TestValidateSubcategoryRecord(t *testing.T) { + app := setupCategoryValidationTestApp(t) + defer app.Cleanup() + + biking := createTestCategory(t, app, "Biking") + hiking := createTestCategory(t, app, "Hiking") + createTestSubcategory(t, app, biking.Id, "MTB", "MTB") + + subcategoriesCollection := mustFindTestCollection(t, app, "subcategories") + + tests := []struct { + name string + category string + record string + wantError bool + }{ + { + name: "requires parent category", + category: "", + record: "Road", + wantError: true, + }, + { + name: "rejects normalized collision in same parent", + category: biking.Id, + record: "mtb", + wantError: true, + }, + { + name: "allows same normalized name in different parent", + category: hiking.Id, + record: "mtb", + }, + { + name: "allows unique name in same parent", + category: biking.Id, + record: "Road", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + record := core.NewRecord(subcategoriesCollection) + record.Set("category", tt.category) + record.Set("name", tt.record) + + err := ValidateSubcategoryRecord(app, record) + if (err != nil) != tt.wantError { + t.Fatalf("ValidateSubcategoryRecord() error = %v, wantError %v", err, tt.wantError) + } + }) + } +} + +func TestValidateTrailSubcategoryRecord(t *testing.T) { + app := setupCategoryValidationTestApp(t) + defer app.Cleanup() + + biking := createTestCategory(t, app, "Biking") + hiking := createTestCategory(t, app, "Hiking") + mtb := createTestSubcategory(t, app, biking.Id, "MTB", "MTB") + + trailsCollection := mustFindTestCollection(t, app, "trails") + missingSubcategoryID := "missing1234567" + + tests := []struct { + name string + category string + subcategory string + subcategoryExplicit bool + wantSubcategory string + wantError bool + }{ + { + name: "allows empty subcategory", + }, + { + name: "requires category when subcategory is explicit", + subcategory: mtb.Id, + subcategoryExplicit: true, + wantSubcategory: mtb.Id, + wantError: true, + }, + { + name: "clears subcategory without category when subcategory is implicit", + subcategory: mtb.Id, + wantSubcategory: "", + }, + { + name: "allows matching parent category", + category: biking.Id, + subcategory: mtb.Id, + subcategoryExplicit: true, + wantSubcategory: mtb.Id, + }, + { + name: "rejects mismatched explicit subcategory", + category: hiking.Id, + subcategory: mtb.Id, + subcategoryExplicit: true, + wantSubcategory: mtb.Id, + wantError: true, + }, + { + name: "clears mismatched implicit subcategory", + category: hiking.Id, + subcategory: mtb.Id, + wantSubcategory: "", + }, + { + name: "rejects missing explicit subcategory", + category: biking.Id, + subcategory: missingSubcategoryID, + subcategoryExplicit: true, + wantSubcategory: missingSubcategoryID, + wantError: true, + }, + { + name: "clears missing implicit subcategory", + category: biking.Id, + subcategory: missingSubcategoryID, + wantSubcategory: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + record := core.NewRecord(trailsCollection) + record.Set("category", tt.category) + record.Set("subcategory", tt.subcategory) + + err := ValidateTrailSubcategoryRecord(app, record, tt.subcategoryExplicit) + if (err != nil) != tt.wantError { + t.Fatalf("ValidateTrailSubcategoryRecord() error = %v, wantError %v", err, tt.wantError) + } + + if got := record.GetString("subcategory"); got != tt.wantSubcategory { + t.Fatalf("record subcategory = %q, want %q", got, tt.wantSubcategory) + } + }) + } +} + +func TestSeedDefaultSubcategoriesSkipsNormalizedExisting(t *testing.T) { + app := setupCategoryValidationTestApp(t) + defer app.Cleanup() + + biking := createTestCategory(t, app, "Biking") + mtb := createTestSubcategory(t, app, biking.Id, "mtb", "CUSTOM") + + if err := SeedDefaultSubcategories(app); err != nil { + t.Fatalf("SeedDefaultSubcategories() error = %v", err) + } + + records, err := app.FindRecordsByFilter( + "subcategories", + "category = {:category}", + "", + 0, + 0, + map[string]any{"category": biking.Id}, + ) + if err != nil { + t.Fatal(err) + } + + var normalizedMTBCount int + for _, record := range records { + if NormalizeCategoryName(record.GetString("name")) == NormalizeCategoryName("MTB") { + normalizedMTBCount++ + } + } + + if normalizedMTBCount != 1 { + t.Fatalf("normalized MTB subcategory count = %d, want 1", normalizedMTBCount) + } + + updatedMTB, err := app.FindRecordById("subcategories", mtb.Id) + if err != nil { + t.Fatal(err) + } + if got := updatedMTB.GetString("name"); got != "mtb" { + t.Fatalf("existing MTB subcategory name = %q, want mtb", got) + } + if got := updatedMTB.GetString("short_name"); got != "CUSTOM" { + t.Fatalf("existing MTB short_name = %q, want CUSTOM", got) + } + + defaultBikingSubcategories := 0 + for _, subcategory := range defaultSubcategories { + if subcategory.parentCategory == "Biking" { + defaultBikingSubcategories++ + } + } + + if len(records) != defaultBikingSubcategories { + t.Fatalf("seeded Biking subcategory count = %d, want %d", len(records), defaultBikingSubcategories) + } +} + +func TestResolveCategoryAndSubcategoryByNormalizedNames(t *testing.T) { + app := setupCategoryValidationTestApp(t) + defer app.Cleanup() + + biking := createTestCategory(t, app, "Biking") + mtb := createTestSubcategory(t, app, biking.Id, "E-Bike", "EBIKE") + hiking := createTestCategory(t, app, "Hiking") + createTestSubcategory(t, app, hiking.Id, "E-Bike", "EBIKE") + + category, subcategory, err := ResolveCategoryAndSubcategoryByNormalizedNames(app, " biking ", "e bike") + if err != nil { + t.Fatalf("ResolveCategoryAndSubcategoryByNormalizedNames() error = %v", err) + } + if category == nil || category.Id != biking.Id { + t.Fatalf("category = %#v, want Biking", category) + } + if subcategory == nil || subcategory.Id != mtb.Id { + t.Fatalf("subcategory = %#v, want Biking/E-Bike", subcategory) + } + + category, subcategory, err = ResolveCategoryAndSubcategoryByNormalizedNames(app, "Biking", "Bikepacking") + if err != nil { + t.Fatalf("ResolveCategoryAndSubcategoryByNormalizedNames() unknown subcategory error = %v", err) + } + if category == nil || category.Id != biking.Id { + t.Fatalf("unknown subcategory category = %#v, want Biking", category) + } + if subcategory != nil { + t.Fatalf("unknown subcategory = %#v, want nil", subcategory) + } + + category, subcategory, err = ResolveCategoryAndSubcategoryByNormalizedNames(app, "Skydiving", "Wingsuit") + if err != nil { + t.Fatalf("ResolveCategoryAndSubcategoryByNormalizedNames() unknown category error = %v", err) + } + if category != nil || subcategory != nil { + t.Fatalf("unknown category resolved category=%#v subcategory=%#v, want nil/nil", category, subcategory) + } +} + +func TestBackfillRemoteTrailCategoryBackfillsTargetCategoryOnly(t *testing.T) { + app := setupCategoryValidationTestApp(t) + defer app.Cleanup() + + biking := createTestCategory(t, app, "Biking") + gravel := createTestSubcategory(t, app, biking.Id, "Gravel", "GRVL") + matchingTrail := createTestTrail(t, app, "", "", "biking", "gravel") + otherTrail := createTestTrail(t, app, "", "", "Hiking", "gravel") + + if err := BackfillRemoteTrailCategory(app, biking); err != nil { + t.Fatalf("BackfillRemoteTrailCategory() error = %v", err) + } + + updatedMatchingTrail, err := app.FindRecordById("trails", matchingTrail.Id) + if err != nil { + t.Fatal(err) + } + if got := updatedMatchingTrail.GetString("category"); got != biking.Id { + t.Fatalf("matching trail category = %q, want %q", got, biking.Id) + } + if got := updatedMatchingTrail.GetString("subcategory"); got != gravel.Id { + t.Fatalf("matching trail subcategory = %q, want %q", got, gravel.Id) + } + + updatedOtherTrail, err := app.FindRecordById("trails", otherTrail.Id) + if err != nil { + t.Fatal(err) + } + if got := updatedOtherTrail.GetString("category"); got != "" { + t.Fatalf("other trail category = %q, want empty", got) + } + if got := updatedOtherTrail.GetString("subcategory"); got != "" { + t.Fatalf("other trail subcategory = %q, want empty", got) + } +} + +func TestBackfillRemoteTrailSubcategoryBackfillsWithinParentScope(t *testing.T) { + app := setupCategoryValidationTestApp(t) + defer app.Cleanup() + + biking := createTestCategory(t, app, "Biking") + hiking := createTestCategory(t, app, "Hiking") + bikingGravel := createTestSubcategory(t, app, biking.Id, "Gravel", "GRVL") + createTestSubcategory(t, app, hiking.Id, "Gravel", "GRVL") + + existingParentTrail := createTestTrail(t, app, biking.Id, "", "Biking", "gravel") + emptyParentTrail := createTestTrail(t, app, "", "", "biking", "gravel") + wrongParentTrail := createTestTrail(t, app, hiking.Id, "", "Hiking", "gravel") + + if err := BackfillRemoteTrailSubcategory(app, bikingGravel); err != nil { + t.Fatalf("BackfillRemoteTrailSubcategory() error = %v", err) + } + + updatedExistingParentTrail, err := app.FindRecordById("trails", existingParentTrail.Id) + if err != nil { + t.Fatal(err) + } + if got := updatedExistingParentTrail.GetString("category"); got != biking.Id { + t.Fatalf("existing parent trail category = %q, want %q", got, biking.Id) + } + if got := updatedExistingParentTrail.GetString("subcategory"); got != bikingGravel.Id { + t.Fatalf("existing parent trail subcategory = %q, want %q", got, bikingGravel.Id) + } + + updatedEmptyParentTrail, err := app.FindRecordById("trails", emptyParentTrail.Id) + if err != nil { + t.Fatal(err) + } + if got := updatedEmptyParentTrail.GetString("category"); got != biking.Id { + t.Fatalf("empty parent trail category = %q, want %q", got, biking.Id) + } + if got := updatedEmptyParentTrail.GetString("subcategory"); got != bikingGravel.Id { + t.Fatalf("empty parent trail subcategory = %q, want %q", got, bikingGravel.Id) + } + + updatedWrongParentTrail, err := app.FindRecordById("trails", wrongParentTrail.Id) + if err != nil { + t.Fatal(err) + } + if got := updatedWrongParentTrail.GetString("category"); got != hiking.Id { + t.Fatalf("wrong parent trail category = %q, want %q", got, hiking.Id) + } + if got := updatedWrongParentTrail.GetString("subcategory"); got != "" { + t.Fatalf("wrong parent trail subcategory = %q, want empty", got) + } +} + +func TestSeedDefaultSubcategoriesRenamesLegacyDefault(t *testing.T) { + app := setupCategoryValidationTestApp(t) + defer app.Cleanup() + + hiking := createTestCategory(t, app, "Hiking") + winter := createTestSubcategory(t, app, hiking.Id, "Winter Hiking", "WINT") + + if err := SeedDefaultSubcategories(app); err != nil { + t.Fatalf("SeedDefaultSubcategories() error = %v", err) + } + if err := SeedDefaultSubcategories(app); err != nil { + t.Fatalf("second SeedDefaultSubcategories() error = %v", err) + } + + records, err := app.FindRecordsByFilter( + "subcategories", + "category = {:category} && short_name = 'WINT'", + "", + 0, + 0, + map[string]any{"category": hiking.Id}, + ) + if err != nil { + t.Fatal(err) + } + + if len(records) != 1 { + t.Fatalf("Winter subcategory count = %d, want 1", len(records)) + } + if got := records[0].GetString("name"); got != "Winter" { + t.Fatalf("Winter subcategory name = %q, want Winter", got) + } + if records[0].Id != winter.Id { + t.Fatalf("Winter subcategory id = %q, want %q", records[0].Id, winter.Id) + } + if got := records[0].GetString("badge_icon"); got != "snowflake" { + t.Fatalf("Winter badge_icon = %q, want snowflake", got) + } +} + +func TestSeedDefaultSubcategoriesDoesNotOverwriteBadgeIcon(t *testing.T) { + app := setupCategoryValidationTestApp(t) + defer app.Cleanup() + + hiking := createTestCategory(t, app, "Hiking") + winter := createTestSubcategory(t, app, hiking.Id, "Winter", "WINT") + winter.Set("badge_icon", "custom") + if err := app.Save(winter); err != nil { + t.Fatal(err) + } + + if err := SeedDefaultSubcategories(app); err != nil { + t.Fatalf("SeedDefaultSubcategories() error = %v", err) + } + + updatedWinter, err := app.FindRecordById("subcategories", winter.Id) + if err != nil { + t.Fatal(err) + } + if got := updatedWinter.GetString("badge_icon"); got != "custom" { + t.Fatalf("Winter badge_icon = %q, want custom", got) + } +} + +func TestSeedDefaultSubcategoriesDoesNotRenameAliasWhenCanonicalExists(t *testing.T) { + app := setupCategoryValidationTestApp(t) + defer app.Cleanup() + + hiking := createTestCategory(t, app, "Hiking") + winter := createTestSubcategory(t, app, hiking.Id, "Winter", "WINT") + legacy := createTestSubcategory(t, app, hiking.Id, "Winter Hiking", "WHIKE") + + if err := SeedDefaultSubcategories(app); err != nil { + t.Fatalf("SeedDefaultSubcategories() error = %v", err) + } + + updatedWinter, err := app.FindRecordById("subcategories", winter.Id) + if err != nil { + t.Fatal(err) + } + if got := updatedWinter.GetString("name"); got != "Winter" { + t.Fatalf("canonical subcategory name = %q, want Winter", got) + } + + updatedLegacy, err := app.FindRecordById("subcategories", legacy.Id) + if err != nil { + t.Fatal(err) + } + if got := updatedLegacy.GetString("name"); got != "Winter Hiking" { + t.Fatalf("legacy subcategory name = %q, want Winter Hiking", got) + } + + records, err := app.FindRecordsByFilter( + "subcategories", + "category = {:category} && name = 'Winter'", + "", + 0, + 0, + map[string]any{"category": hiking.Id}, + ) + if err != nil { + t.Fatal(err) + } + if len(records) != 1 { + t.Fatalf("canonical Winter subcategory count = %d, want 1", len(records)) + } +} + +func TestValidateUserCategoryPreferenceRequest(t *testing.T) { + tests := []struct { + name string + priorityExplicit bool + wantError bool + }{ + { + name: "allows requests without priority", + }, + { + name: "rejects explicit priority", + priorityExplicit: true, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateUserCategoryPreferenceRequest(tt.priorityExplicit) + if (err != nil) != tt.wantError { + t.Fatalf("ValidateUserCategoryPreferenceRequest() error = %v, wantError %v", err, tt.wantError) + } + }) + } +} + +func TestValidateUserSubcategoryPreferenceRequest(t *testing.T) { + tests := []struct { + name string + priorityExplicit bool + wantError bool + }{ + { + name: "allows requests without priority", + }, + { + name: "rejects explicit priority", + priorityExplicit: true, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateUserSubcategoryPreferenceRequest(tt.priorityExplicit) + if (err != nil) != tt.wantError { + t.Fatalf("ValidateUserSubcategoryPreferenceRequest() error = %v, wantError %v", err, tt.wantError) + } + }) + } +} + +func TestReorderUserCategoryPreferencesValidation(t *testing.T) { + tests := []struct { + name string + userID string + categoryIDs func(categories []*core.Record) []string + wantError bool + }{ + { + name: "requires authenticated user", + userID: "", + categoryIDs: func(categories []*core.Record) []string { + return []string{categories[0].Id, categories[1].Id} + }, + wantError: true, + }, + { + name: "rejects incomplete list", + userID: "user12345678901", + categoryIDs: func(categories []*core.Record) []string { + return []string{categories[0].Id} + }, + wantError: true, + }, + { + name: "rejects duplicate category", + userID: "user12345678901", + categoryIDs: func(categories []*core.Record) []string { + return []string{categories[0].Id, categories[0].Id} + }, + wantError: true, + }, + { + name: "rejects unknown category", + userID: "user12345678901", + categoryIDs: func(categories []*core.Record) []string { + return []string{categories[0].Id, "unknown12345678"} + }, + wantError: true, + }, + { + name: "allows complete category list", + userID: "user12345678901", + categoryIDs: func(categories []*core.Record) []string { + return []string{categories[1].Id, categories[0].Id} + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app := setupCategoryValidationTestApp(t) + defer app.Cleanup() + + categories := []*core.Record{ + createTestCategory(t, app, "Biking"), + createTestCategory(t, app, "Hiking"), + } + + err := ReorderUserCategoryPreferences(app, tt.userID, tt.categoryIDs(categories)) + if (err != nil) != tt.wantError { + t.Fatalf("ReorderUserCategoryPreferences() error = %v, wantError %v", err, tt.wantError) + } + }) + } +} + +func TestReorderUserSubcategoryPreferencesValidation(t *testing.T) { + tests := []struct { + name string + userID string + categoryID func(categories []*core.Record) string + subcategoryIDs func(subcategories []*core.Record) []string + wantError bool + }{ + { + name: "requires authenticated user", + categoryID: func(categories []*core.Record) string { + return categories[0].Id + }, + subcategoryIDs: func(subcategories []*core.Record) []string { + return []string{subcategories[0].Id, subcategories[1].Id} + }, + wantError: true, + }, + { + name: "requires category", + userID: "user12345678901", + categoryID: func(categories []*core.Record) string { + return "" + }, + subcategoryIDs: func(subcategories []*core.Record) []string { + return []string{subcategories[0].Id, subcategories[1].Id} + }, + wantError: true, + }, + { + name: "rejects incomplete list", + userID: "user12345678901", + categoryID: func(categories []*core.Record) string { + return categories[0].Id + }, + subcategoryIDs: func(subcategories []*core.Record) []string { + return []string{subcategories[0].Id} + }, + wantError: true, + }, + { + name: "rejects duplicate subcategory", + userID: "user12345678901", + categoryID: func(categories []*core.Record) string { + return categories[0].Id + }, + subcategoryIDs: func(subcategories []*core.Record) []string { + return []string{subcategories[0].Id, subcategories[0].Id} + }, + wantError: true, + }, + { + name: "rejects subcategory from another category", + userID: "user12345678901", + categoryID: func(categories []*core.Record) string { + return categories[0].Id + }, + subcategoryIDs: func(subcategories []*core.Record) []string { + return []string{subcategories[0].Id, subcategories[2].Id} + }, + wantError: true, + }, + { + name: "allows complete subcategory list", + userID: "user12345678901", + categoryID: func(categories []*core.Record) string { + return categories[0].Id + }, + subcategoryIDs: func(subcategories []*core.Record) []string { + return []string{subcategories[1].Id, subcategories[0].Id} + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app := setupCategoryValidationTestApp(t) + defer app.Cleanup() + + categories := []*core.Record{ + createTestCategory(t, app, "Hiking"), + createTestCategory(t, app, "Biking"), + } + subcategories := []*core.Record{ + createTestSubcategory(t, app, categories[0].Id, "Mountain Hiking", "", ""), + createTestSubcategory(t, app, categories[0].Id, "Family Hiking", "", ""), + createTestSubcategory(t, app, categories[1].Id, "E-Bike", "", ""), + } + + err := ReorderUserSubcategoryPreferences(app, tt.userID, tt.categoryID(categories), tt.subcategoryIDs(subcategories)) + if (err != nil) != tt.wantError { + t.Fatalf("ReorderUserSubcategoryPreferences() error = %v, wantError %v", err, tt.wantError) + } + }) + } +} + +func TestEnsureUserCategoryPriorityDefaultsToHiking(t *testing.T) { + app := setupCategoryValidationTestApp(t) + defer app.Cleanup() + + createTestCategory(t, app, "Canoeing") + hiking := createTestCategory(t, app, "Hiking") + userID := "user12345678901" + + if err := EnsureUserCategoryPriority(app, userID, ""); err != nil { + t.Fatalf("EnsureUserCategoryPriority() error = %v", err) + } + + records, err := app.FindRecordsByFilter( + "user_category_preferences", + "user = {:user}", + "", + 0, + 0, + map[string]any{"user": userID}, + ) + if err != nil { + t.Fatal(err) + } + if len(records) != 1 { + t.Fatalf("preference count = %d, want 1", len(records)) + } + + record := records[0] + if got := record.GetString("category"); got != hiking.Id { + t.Fatalf("category = %q, want Hiking id %q", got, hiking.Id) + } + if got := record.GetInt("priority"); got != 1 { + t.Fatalf("priority = %d, want 1", got) + } + if !record.GetBool("visible") { + t.Fatal("visible = false, want true") + } +} + +func TestReorderUserCategoryPreferencesUpsertsAndRenumbers(t *testing.T) { + app := setupCategoryValidationTestApp(t) + defer app.Cleanup() + + biking := createTestCategory(t, app, "Biking") + hiking := createTestCategory(t, app, "Hiking") + walking := createTestCategory(t, app, "Walking") + userID := "user12345678901" + + existing := core.NewRecord(mustFindTestCollection(t, app, "user_category_preferences")) + existing.Set("user", userID) + existing.Set("category", biking.Id) + existing.Set("visible", false) + existing.Set("priority", 99) + if err := app.SaveNoValidate(existing); err != nil { + t.Fatal(err) + } + + if err := ReorderUserCategoryPreferences(app, userID, []string{walking.Id, biking.Id, hiking.Id}); err != nil { + t.Fatalf("ReorderUserCategoryPreferences() error = %v", err) + } + + records, err := app.FindRecordsByFilter( + "user_category_preferences", + "user = {:user}", + "priority", + 0, + 0, + map[string]any{"user": userID}, + ) + if err != nil { + t.Fatal(err) + } + + if len(records) != 3 { + t.Fatalf("preference count = %d, want 3", len(records)) + } + + wantPriorityByCategory := map[string]int{ + walking.Id: 1, + biking.Id: 2, + hiking.Id: 3, + } + for _, record := range records { + categoryID := record.GetString("category") + if got := record.GetInt("priority"); got != wantPriorityByCategory[categoryID] { + t.Fatalf("priority for category %q = %d, want %d", categoryID, got, wantPriorityByCategory[categoryID]) + } + if categoryID == biking.Id { + if record.Id != existing.Id { + t.Fatalf("biking preference id = %q, want existing id %q", record.Id, existing.Id) + } + if record.GetBool("visible") { + t.Fatal("existing preference visible = true, want false") + } + } else { + if !record.GetBool("visible") { + t.Fatalf("new preference for category %q visible = false, want true", categoryID) + } + } + } +} + +func TestReorderUserSubcategoryPreferencesUpsertsAndRenumbers(t *testing.T) { + app := setupCategoryValidationTestApp(t) + defer app.Cleanup() + + hiking := createTestCategory(t, app, "Hiking") + biking := createTestCategory(t, app, "Biking") + mountain := createTestSubcategory(t, app, hiking.Id, "Mountain Hiking", "", "") + family := createTestSubcategory(t, app, hiking.Id, "Family Hiking", "", "") + ebike := createTestSubcategory(t, app, biking.Id, "E-Bike", "", "") + userID := "user12345678901" + + existing := core.NewRecord(mustFindTestCollection(t, app, "user_subcategory_preferences")) + existing.Set("user", userID) + existing.Set("subcategory", family.Id) + existing.Set("visible", false) + existing.Set("priority", 99) + if err := app.SaveNoValidate(existing); err != nil { + t.Fatal(err) + } + + otherCategoryPreference := core.NewRecord(mustFindTestCollection(t, app, "user_subcategory_preferences")) + otherCategoryPreference.Set("user", userID) + otherCategoryPreference.Set("subcategory", ebike.Id) + otherCategoryPreference.Set("visible", true) + otherCategoryPreference.Set("priority", 12) + if err := app.SaveNoValidate(otherCategoryPreference); err != nil { + t.Fatal(err) + } + + if err := ReorderUserSubcategoryPreferences(app, userID, hiking.Id, []string{family.Id, mountain.Id}); err != nil { + t.Fatalf("ReorderUserSubcategoryPreferences() error = %v", err) + } + + records, err := app.FindRecordsByFilter( + "user_subcategory_preferences", + "user = {:user}", + "priority", + 0, + 0, + map[string]any{"user": userID}, + ) + if err != nil { + t.Fatal(err) + } + + if len(records) != 3 { + t.Fatalf("preference count = %d, want 3", len(records)) + } + + wantPriorityBySubcategory := map[string]int{ + family.Id: 1, + mountain.Id: 2, + ebike.Id: 12, + } + for _, record := range records { + subcategoryID := record.GetString("subcategory") + if got := record.GetInt("priority"); got != wantPriorityBySubcategory[subcategoryID] { + t.Fatalf("priority for subcategory %q = %d, want %d", subcategoryID, got, wantPriorityBySubcategory[subcategoryID]) + } + if subcategoryID == family.Id { + if record.Id != existing.Id { + t.Fatalf("family preference id = %q, want existing id %q", record.Id, existing.Id) + } + if record.GetBool("visible") { + t.Fatal("existing preference visible = true, want false") + } + } + if subcategoryID == mountain.Id && !record.GetBool("visible") { + t.Fatal("new preference visible = false, want true") + } + if subcategoryID == ebike.Id && record.Id != otherCategoryPreference.Id { + t.Fatalf("other category preference id = %q, want existing id %q", record.Id, otherCategoryPreference.Id) + } + } +} + +func setupCategoryValidationTestApp(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}, + &core.TextField{Name: "short_name"}, + &core.TextField{Name: "icon"}, + &core.JSONField{Name: "translations"}, + ) + 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}, + &core.TextField{Name: "short_name"}, + &core.TextField{Name: "icon"}, + &core.TextField{Name: "badge_icon"}, + &core.JSONField{Name: "translations"}, + ) + if err := app.Save(subcategories); err != nil { + app.Cleanup() + t.Fatal(err) + } + + trails := core.NewBaseCollection("trails") + trails.Fields.Add( + &core.RelationField{Name: "category", CollectionId: categories.Id, MaxSelect: 1}, + &core.RelationField{Name: "subcategory", CollectionId: subcategories.Id, MaxSelect: 1}, + &core.TextField{Name: "federated_category_name"}, + &core.TextField{Name: "federated_subcategory_name"}, + ) + if err := app.Save(trails); err != nil { + app.Cleanup() + t.Fatal(err) + } + + preferences := core.NewBaseCollection("user_category_preferences") + preferences.Fields.Add( + &core.RelationField{Name: "user", CollectionId: "_pb_users_auth_", MaxSelect: 1, Required: true}, + &core.RelationField{Name: "category", CollectionId: categories.Id, MaxSelect: 1, Required: true}, + &core.BoolField{Name: "visible"}, + &core.NumberField{Name: "priority", OnlyInt: true}, + ) + if err := app.Save(preferences); err != nil { + app.Cleanup() + t.Fatal(err) + } + + subcategoryPreferences := core.NewBaseCollection("user_subcategory_preferences") + subcategoryPreferences.Fields.Add( + &core.RelationField{Name: "user", CollectionId: "_pb_users_auth_", MaxSelect: 1, Required: true}, + &core.RelationField{Name: "subcategory", CollectionId: subcategories.Id, MaxSelect: 1, Required: true}, + &core.BoolField{Name: "visible"}, + &core.NumberField{Name: "priority", OnlyInt: true}, + ) + if err := app.Save(subcategoryPreferences); err != nil { + app.Cleanup() + t.Fatal(err) + } + + return app +} + +func mustFindTestCollection(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 +} + +func createTestCategory(t *testing.T, app core.App, name string) *core.Record { + t.Helper() + + record := core.NewRecord(mustFindTestCollection(t, app, "categories")) + record.Set("name", name) + if err := app.Save(record); err != nil { + t.Fatal(err) + } + + return record +} + +func createTestSubcategory(t *testing.T, app core.App, categoryID string, name string, shortName string, badgeIcon ...string) *core.Record { + t.Helper() + + record := core.NewRecord(mustFindTestCollection(t, app, "subcategories")) + record.Set("category", categoryID) + record.Set("name", name) + record.Set("short_name", shortName) + if len(badgeIcon) > 0 { + record.Set("badge_icon", badgeIcon[0]) + } + if err := app.Save(record); err != nil { + t.Fatal(err) + } + + return record +} + +func createTestTrail(t *testing.T, app core.App, categoryID string, subcategoryID string, federatedCategoryName string, federatedSubcategoryName string) *core.Record { + t.Helper() + + record := core.NewRecord(mustFindTestCollection(t, app, "trails")) + record.Set("category", categoryID) + record.Set("subcategory", subcategoryID) + record.Set("federated_category_name", federatedCategoryName) + record.Set("federated_subcategory_name", federatedSubcategoryName) + if err := app.Save(record); err != nil { + t.Fatal(err) + } + + return record +} diff --git a/db/util/meilisearch.go b/db/util/meilisearch.go index 395c5cd8..bae8546b 100644 --- a/db/util/meilisearch.go +++ b/db/util/meilisearch.go @@ -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"), diff --git a/db/util/subcategory.go b/db/util/subcategory.go new file mode 100644 index 00000000..7440077e --- /dev/null +++ b/db/util/subcategory.go @@ -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 +} diff --git a/db/util/subcategory_defaults.go b/db/util/subcategory_defaults.go new file mode 100644 index 00000000..5395ce01 --- /dev/null +++ b/db/util/subcategory_defaults.go @@ -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 +} diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index aeed090d..1081c919 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -51,6 +51,10 @@ export default defineConfig({ label: 'Create/Edit a trail', link: '/use/create-a-trail/' }, + { + label: 'Categories', + link: '/use/categories/' + }, { label: 'Summit logs', link: '/use/summit-logs/' diff --git a/docs/src/assets/guides/pocketbase_categories.png b/docs/src/assets/guides/pocketbase_categories.png index 98730280..5ddeeb08 100644 Binary files a/docs/src/assets/guides/pocketbase_categories.png and b/docs/src/assets/guides/pocketbase_categories.png differ diff --git a/docs/src/assets/guides/pocketbase_subcategories.png b/docs/src/assets/guides/pocketbase_subcategories.png new file mode 100644 index 00000000..f3b2da06 Binary files /dev/null and b/docs/src/assets/guides/pocketbase_subcategories.png differ diff --git a/docs/src/assets/guides/wanderer_settings_categories.png b/docs/src/assets/guides/wanderer_settings_categories.png new file mode 100644 index 00000000..08dd44bc Binary files /dev/null and b/docs/src/assets/guides/wanderer_settings_categories.png differ diff --git a/docs/src/assets/guides/wanderer_trails_adjust.png b/docs/src/assets/guides/wanderer_trails_adjust.png new file mode 100644 index 00000000..cec4568b Binary files /dev/null and b/docs/src/assets/guides/wanderer_trails_adjust.png differ diff --git a/docs/src/assets/guides/wanderer_trails_category_filter.png b/docs/src/assets/guides/wanderer_trails_category_filter.png new file mode 100644 index 00000000..03d8cae5 Binary files /dev/null and b/docs/src/assets/guides/wanderer_trails_category_filter.png differ diff --git a/docs/src/content/docs/develop/plugin-system.md b/docs/src/content/docs/develop/plugin-system.md index c299ad1e..ebd53f6a 100644 --- a/docs/src/content/docs/develop/plugin-system.md +++ b/docs/src/content/docs/develop/plugin-system.md @@ -536,12 +536,14 @@ Supported host fields: | `privacy` | string | Trail import | `original` keeps provider visibility; `settings` uses the local user trail privacy setting. | | `merge.enabled` | boolean | Trail import | Runs auto-merge after creating imported trails. | | `createSummitLogForCompleted` | boolean | Trail import | Creates summit logs for completed imported trails. Defaults to `true`. | -| `categoryMapping` | object | Trail import | Maps plugin-provided `metadata.providerCategory` values to local category IDs or category names. | +| `categoryMapping` | object | Trail import | Maps plugin-provided `metadata.providerCategory` values to local category or subcategory targets. | | `connectors` | object | Host request/media policy | Concrete settings for configured connectors. | The settings UI lets users edit `categoryMapping` per plugin instance for trail -import plugins. Unknown or empty provider categories still fall back to the -host's activity-type mapping. +import plugins. A mapping value can be a string for broad category-only +compatibility, or an object with `category` and optional `subcategory`. Category +and subcategory values may be local record IDs or canonical names. Unknown or +empty provider categories still fall back to the host's activity-type mapping. Example: @@ -549,7 +551,14 @@ Example: { "hostConfig": { "categoryMapping": { - "Ride": "Biking", + "Ride": { + "category": "Biking", + "subcategory": "Road" + }, + "GravelRide": { + "category": "Biking", + "subcategory": "Gravel" + }, "Hike": "Hiking" } }, diff --git a/docs/src/content/docs/run/backend-configuration/custom-categories.md b/docs/src/content/docs/run/backend-configuration/custom-categories.md index 6de63a74..313f8267 100644 --- a/docs/src/content/docs/run/backend-configuration/custom-categories.md +++ b/docs/src/content/docs/run/backend-configuration/custom-categories.md @@ -1,11 +1,12 @@ --- title: Custom categories -description: How to create custom trail categories +description: How to configure trail categories and subcategories --- wanderer uses categories to classify what kind of activity a trail belongs to. -Out of the box you get: Biking, Canoeing, Climbing, Hiking, Skiing and Walking. -However, you can adapt these categories to your needs or add completely new ones. +Out of the box you get: Biking, Canoeing, Climbing, Hiking, Running, Skiing and Walking. +Some broad categories also have subcategories, for example Biking can be refined into MTB, Gravel, Road or E-Bike. +You can adapt this taxonomy to your needs in the PocketBase admin panel. ## Modifying categories @@ -15,7 +16,69 @@ In the PocketBase admin panel, click on the `categories` table in the list on th All existing categories will be listed here. To edit one simply click on the row, edit the data you want to change, and click "Save". To delete a category check the box at the beginning of the row and click "Delete selected". -To create a new category click the "New record" button in the top right corner, give your new category a name and a background image, and click "Save". +To create a new category click the "New record" button in the top right corner, give your new category a name, optionally fill in display metadata such as `short_name`, `icon`, or localized `translations`, and click "Save". + +The category `name` is the canonical, language-independent identity. +Use stable names such as `Hiking` or `Biking`; display labels in different languages should be stored in `translations`. +Incoming federated trails and integration imports match categories by a normalized version of `name`, so changing a category name can affect future matching. + +### Category fields + +| Field | Description | +| ----- | ----------- | +| `name` | Canonical category name. This is used for matching across imports and federation. | +| `short_name` | Optional compact label for space-constrained UI. | +| `icon` | Optional Font Awesome Free icon name without the `fa-` prefix, for example `person-hiking`. | +| `translations` | Optional localized display labels. | +| `settings` | Optional JSON settings for category-specific backend behavior. | + +`translations` uses supported base locale codes such as `de`, `en`, `fr`, or `pt` as keys. +Do not use region-specific keys such as `de-CH` or `pt-BR`; the frontend resolves user locales to their base locale before looking up category translations. + +Example: + +```json +{ + "de": { + "name": "Radfahren", + "short_name": "RAD" + }, + "en": { + "name": "Biking", + "short_name": "BIKE" + } +} +``` + +## Modifying subcategories + +Subcategories live in the `subcategories` table and act as optional refinements below a single parent category. Their names only need to be unique within that parent, so `Road` can exist under both Biking and Running at the same time. + + + +To add one, create a new record in `subcategories`, choose its parent `category`, set a canonical `name`, and optionally add display metadata. + +### Subcategory fields + +| Field | Description | +| ----- | ----------- | +| `category` | Required parent category. | +| `name` | Canonical subcategory name, unique within the parent category after normalization. | +| `short_name` | Compact label shown in icon-based filters, for example `MTB`, `GRVL`, or `ROAD`. | +| `icon` | Optional Font Awesome Free icon name. If empty, the parent category icon is used. | +| `badge_icon` | Optional Font Awesome Free overlay icon, for example `snowflake`, `mountain`, `bolt`, or `cross`. | +| `translations` | Optional localized display labels, using the same structure as category translations. | + +Most subcategories should reuse the parent category's icon and rely on `short_name` — plus a `badge_icon` where it helps — to set themselves apart, rather than each carrying a distinct full icon. You can browse available icon names at [fontawesome.com](https://fontawesome.com/search?ic=free-collection). + +:::note +Unknown remote categories and subcategories are not automatically created during federation. +Raw remote values are stored on the trail and can be matched later when an admin creates a compatible local category or subcategory. +::: + +## Migrating old custom categories + +If your instance already had custom categories such as `MTB` or `Gravel` that now overlap with a default subcategory, you can reassign the affected trails in bulk from the web UI. See [Categories](/use/categories/#editing-several-trails-at-once) for the step-by-step migration path. ## Category settings diff --git a/docs/src/content/docs/use/categories.md b/docs/src/content/docs/use/categories.md new file mode 100644 index 00000000..fb33f2f6 --- /dev/null +++ b/docs/src/content/docs/use/categories.md @@ -0,0 +1,58 @@ +--- +title: Categories +description: How to use trail categories, subcategories, and category visibility settings +--- + +Every trail has a category that describes its broad activity type — Hiking, Biking, Running, Skiing, and so on. Many categories can be narrowed down further with a subcategory, such as Biking / Gravel or Hiking / Snowshoeing, whenever you want to be more specific. + +## Choosing a category + +When you create or edit a trail, pick the activity type with the **Category** selector in the trail form. It lists the broad categories together with their subcategories, so you can stay general or get specific: + +- Choose **Hiking** for an ordinary hiking trail. +- Choose **Hiking / Snowshoeing** to mark it as a snowshoe route. +- Choose **Biking / Gravel** for a gravel ride. + +A broad category on its own is always enough; a subcategory is optional. On trail cards and in lists, the category icon carries a small badge for subcategories that need one — for example a snowflake for winter variants — so you can tell refinements apart at a glance. + +## Filtering trails + +The filter panel shows each category as an icon. Click an icon to add that category to the filter. + + + +Categories that have subcategories reveal a subcategory overlay when you hover or focus the icon; on touch devices, long-press it instead. From there you can filter by: + +- the whole category, +- only trails that have no subcategory, or +- one or more specific subcategories. + +When a subcategory filter is active, a small indicator appears on the category icon — that's how you tell "all Biking trails" apart from "only the Biking subcategories I picked". + +## Editing several trails at once + +To reclassify many trails in one go, select them in the trail list, open the action menu, and choose **Adjust**. The modal lets you set a new category, subcategory, or difficulty for the whole selection. + + + +This is handy after upgrading an instance, when an older standalone category overlaps with a new subcategory: every trail previously filed under `Gravel`, for instance, can be moved to Biking / Gravel in a single step. + +## Category preferences + +Open **Settings → Categories** to control how categories behave for your account. + + + +Each category has one visibility toggle: + +- **Show** controls whether the category is part of your exploration and planning. While it is on, the category appears in search and discovery and is offered in the category picker when you create or edit a trail. Turn it off to hide all trails in that category from those places, including federated trails from other instances. + +You can **reorder** categories by dragging them. This order carries over to pickers and filters, and it also decides which category is preselected when you create a new trail. New accounts start with Hiking as the first category unless an older favourite-sport setting is migrated. + +Categories with subcategories can be expanded. Inside the expanded section, each subcategory has its own visibility toggle and can be reordered by dragging. Hidden subcategories appear muted and drop out of your pickers and filters; hiding a parent category also hides its subcategories. When a category is collapsed, the compact badges below the category name show which subcategories belong to it. + +These settings are personal. They never delete categories, change other users' settings, or remove category assignments that already exist on trails. + +:::note +Categories and subcategories themselves are defined by the instance administrator in PocketBase. As a regular user you choose from the available taxonomy and set your own visibility preferences, but you cannot create global categories from the web UI. +::: diff --git a/docs/src/content/docs/use/create-a-trail.md b/docs/src/content/docs/use/create-a-trail.md index 47ffbc51..8a58ba5a 100644 --- a/docs/src/content/docs/use/create-a-trail.md +++ b/docs/src/content/docs/use/create-a-trail.md @@ -65,7 +65,7 @@ While drawing or editing a route, the anchor list shows the route's start, inter - **Distance / Duration / Elevation** – These are automatically calculated but can be manually adjusted if needed. - **Tags** – Add descriptive tags to help categorize and search for your trail (e.g. forest, sunset, dog-friendly). Start typing to add a tag and press Enter to confirm. - **Difficulty** – Select the trail's difficulty (e.g. Easy, Moderate, Hard) -- **Category** – Choose the activity type (e.g. Hiking, Cycling) +- **[Category](/use/categories/)** – Choose the activity type. You can select a broad category such as Hiking, or a more specific subcategory such as Biking / Gravel. #### Visibility @@ -83,7 +83,7 @@ Waypoints are points of interest along the trail. - When you are not editing the route, click on the map to open a popup with a **Create waypoint** button at that location. - While drawing or editing the route, right-click on the map to open the same popup without placing a route anchor. This works only while waypoint markers are visible (see the waypoint toggle in the route editing toolbar). - Each waypoint can have a name, description, icon, and photos. -- Use Font Awesome icons for map markers. You can browse them at [fontawesome.com](https://fontawesome.com/search?q=share&o=r&m=free). +- Use Font Awesome icons for map markers. You can browse them at [fontawesome.com](https://fontawesome.com/search?ic=free-collection). Alternatively, click **From Photos** to upload photos with GPS metadata. Waypoints will be created automatically based on the photo locations. diff --git a/plugins/README.md b/plugins/README.md index 724262e3..1510f3b4 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -204,10 +204,24 @@ Manifest `configSchema` defines plugin-owned settings that are passed to plugin | `merge.available` | Controls whether the UI offers auto-merge for this plugin. Defaults to `true`. | | `merge.enabled` | Runs auto-merge after trail import. | | `createSummitLogForCompleted` | Creates summit logs for completed imports. | -| `categoryMapping` | Maps `metadata.providerCategory` to local category IDs or names. | +| `categoryMapping` | Maps `metadata.providerCategory` to local category or subcategory targets. | | `connectors` | Provides host-owned base URL, TLS, private-network, and storage redirect settings for configured connectors. | The settings UI lets users edit `categoryMapping` per plugin instance for trail import plugins. +Mapping values can be strings for broad category-only compatibility, or objects +with `category` and optional `subcategory`, using local record IDs or canonical +names: + +```json +{ + "categoryMapping": { + "Ride": { "category": "Biking", "subcategory": "Road" }, + "GravelRide": { "category": "Biking", "subcategory": "Gravel" }, + "Hike": "Hiking" + } +} +``` + Plugins may describe provider-owned category values for the settings UI with `metadata.providerCategories`. This is display-only metadata; `categoryMapping` keys still use the raw provider category values emitted as diff --git a/plugins/komoot/plugin.json b/plugins/komoot/plugin.json index 7bff07e1..85f2057c 100644 --- a/plugins/komoot/plugin.json +++ b/plugins/komoot/plugin.json @@ -101,31 +101,31 @@ "hostConfig": { "categoryMapping": { "hike": "Hiking", - "mountaineering": "Hiking", - "racebike": "Biking", - "e_racebike": "Biking", - "touringbicycle": "Biking", - "e_touringbicycle": "Biking", - "mtb": "Biking", - "e_mtb": "Biking", - "mtb_easy": "Biking", - "e_mtb_easy": "Biking", - "mtb_advanced": "Biking", - "e_mtb_advanced": "Biking", - "downhillbike": "Biking", + "mountaineering": { "category": "Hiking", "subcategory": "Alpine" }, + "racebike": { "category": "Biking", "subcategory": "Road" }, + "e_racebike": { "category": "Biking", "subcategory": "E-Bike" }, + "touringbicycle": { "category": "Biking", "subcategory": "Touring" }, + "e_touringbicycle": { "category": "Biking", "subcategory": "Touring" }, + "mtb": { "category": "Biking", "subcategory": "MTB" }, + "e_mtb": { "category": "Biking", "subcategory": "MTB" }, + "mtb_easy": { "category": "Biking", "subcategory": "MTB" }, + "e_mtb_easy": { "category": "Biking", "subcategory": "MTB" }, + "mtb_advanced": { "category": "Biking", "subcategory": "MTB" }, + "e_mtb_advanced": { "category": "Biking", "subcategory": "MTB" }, + "downhillbike": { "category": "Biking", "subcategory": "MTB" }, "unicycle": "Biking", - "citybike": "Biking", - "jogging": "Walking", + "citybike": { "category": "Biking", "subcategory": "Touring" }, + "jogging": { "category": "Running", "subcategory": "Road" }, "nordicwalking": "Walking", "skaten": "Walking", "other": "Walking", "climbing": "Climbing", - "nordic": "Skiing", + "nordic": { "category": "Skiing", "subcategory": "Cross-country" }, "skialpin": "Skiing", - "skitour": "Skiing", + "skitour": { "category": "Skiing", "subcategory": "Backcountry" }, "sled": "Skiing", "snowboard": "Skiing", - "snowshoe": "Skiing" + "snowshoe": { "category": "Hiking", "subcategory": "Snowshoeing" } } }, "metadata": { diff --git a/plugins/schema/plugin.schema.json b/plugins/schema/plugin.schema.json index 02d671d6..016c039a 100644 --- a/plugins/schema/plugin.schema.json +++ b/plugins/schema/plugin.schema.json @@ -412,7 +412,17 @@ "type": "boolean" }, "categoryMapping": { - "$ref": "#/definitions/stringMap" + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "type": "string" + }, + { + "$ref": "#/definitions/categoryMappingTarget" + } + ] + } }, "connectors": { "type": "object", @@ -476,6 +486,18 @@ "$ref": "#/definitions/stringMap" } } + }, + "categoryMappingTarget": { + "type": "object", + "additionalProperties": false, + "properties": { + "category": { + "type": "string" + }, + "subcategory": { + "type": "string" + } + } } } } diff --git a/plugins/strava/activity_type.go b/plugins/strava/activity_type.go new file mode 100644 index 00000000..e3d1cbe6 --- /dev/null +++ b/plugins/strava/activity_type.go @@ -0,0 +1,22 @@ +package main + +func activityTypeFromProvider(value string) string { + switch value { + case "AlpineSki", "BackcountrySki", "IceSkate", "NordicSki", "RollerSki", "Snowboard": + return "skiing" + case "Canoeing", "Kayaking", "Kitesurf", "Rowing", "Sail", "StandUpPaddling", "Surfing", "Windsurf": + return "canoeing" + case "Hike", "Snowshoe": + return "hiking" + case "Run", "TrailRun", "VirtualRun": + return "running" + case "Walk", "Golf", "Skateboard", "Wheelchair": + return "walking" + case "Ride", "EBikeRide", "Handcycle", "InlineSkate", "Velomobile", "VirtualRide": + return "biking" + case "RockClimbing": + return "climbing" + default: + return value + } +} diff --git a/plugins/strava/mapper.go b/plugins/strava/mapper.go index 3ef2445f..deff7b31 100644 --- a/plugins/strava/mapper.go +++ b/plugins/strava/mapper.go @@ -208,21 +208,5 @@ func providerActivityType(activity *detailedActivity) string { } func activityType(activity *detailedActivity) string { - value := providerActivityType(activity) - switch value { - case "AlpineSki", "BackcountrySki", "IceSkate", "NordicSki", "RollerSki", "Snowboard": - return "skiing" - case "Canoeing", "Kayaking", "Kitesurf", "Rowing", "Sail", "StandUpPaddling", "Surfing", "Windsurf": - return "canoeing" - case "Hike", "Snowshoe": - return "hiking" - case "Run", "VirtualRun", "Walk", "Golf", "Skateboard", "Wheelchair": - return "walking" - case "Ride", "EBikeRide", "Handcycle", "InlineSkate", "Velomobile", "VirtualRide": - return "biking" - case "RockClimbing": - return "climbing" - default: - return value - } + return activityTypeFromProvider(providerActivityType(activity)) } diff --git a/plugins/strava/mapper_test.go b/plugins/strava/mapper_test.go new file mode 100644 index 00000000..3ec6e3ee --- /dev/null +++ b/plugins/strava/mapper_test.go @@ -0,0 +1,18 @@ +package main + +import "testing" + +func TestActivityTypeMapsRunsToRunning(t *testing.T) { + cases := map[string]string{ + "Run": "running", + "TrailRun": "running", + "VirtualRun": "running", + "Walk": "walking", + } + + for providerType, want := range cases { + if got := activityTypeFromProvider(providerType); got != want { + t.Fatalf("activityTypeFromProvider(%q) = %q, want %q", providerType, got, want) + } + } +} diff --git a/plugins/strava/plugin.json b/plugins/strava/plugin.json index e05c1cfa..ad855dc4 100644 --- a/plugins/strava/plugin.json +++ b/plugins/strava/plugin.json @@ -145,15 +145,15 @@ "route:1": "Biking", "route:2": "Walking", "AlpineSki": "Skiing", - "BackcountrySki": "Skiing", + "BackcountrySki": { "category": "Skiing", "subcategory": "Backcountry" }, "Badminton": "Other", "Canoeing": "Canoeing", - "Crossfit": "Workout", - "EBikeRide": "Biking", - "EMountainBikeRide": "Biking", - "Elliptical": "Workout", + "Crossfit": "Other", + "EBikeRide": { "category": "Biking", "subcategory": "E-Bike" }, + "EMountainBikeRide": { "category": "Biking", "subcategory": "E-Bike" }, + "Elliptical": "Other", "Golf": "Other", - "GravelRide": "Biking", + "GravelRide": { "category": "Biking", "subcategory": "Gravel" }, "Handcycle": "Biking", "HighIntensityIntervalTraining": "Other", "Hike": "Hiking", @@ -161,40 +161,40 @@ "InlineSkate": "Walking", "Kayaking": "Canoeing", "Kitesurf": "Canoeing", - "MountainBikeRide": "Biking", - "NordicSki": "Skiing", + "MountainBikeRide": { "category": "Biking", "subcategory": "MTB" }, + "NordicSki": { "category": "Skiing", "subcategory": "Cross-country" }, "Pickleball": "Other", "Pilates": "Other", "Racquetball": "Other", - "Ride": "Biking", + "Ride": { "category": "Biking", "subcategory": "Road" }, "RockClimbing": "Climbing", - "RollerSki": "Skiing", + "RollerSki": { "category": "Skiing", "subcategory": "Cross-country" }, "Rowing": "Canoeing", - "Run": "Walking", + "Run": { "category": "Running", "subcategory": "Road" }, "Sail": "Canoeing", "Skateboard": "Walking", "Snowboard": "Skiing", - "Snowshoe": "Hiking", + "Snowshoe": { "category": "Hiking", "subcategory": "Snowshoeing" }, "Soccer": "Other", "Squash": "Other", - "StairStepper": "Workout", + "StairStepper": "Other", "StandUpPaddling": "Canoeing", "Surfing": "Canoeing", "Swim": "Other", "TableTennis": "Other", "Tennis": "Other", - "TrailRun": "Other", + "TrailRun": { "category": "Running", "subcategory": "Trail" }, "Training": "Other", "Velomobile": "Biking", "VirtualRide": "Biking", "VirtualRow": "Other", - "VirtualRun": "Walking", + "VirtualRun": "Running", "Walk": "Walking", - "WeightTraining": "Workout", + "WeightTraining": "Other", "Wheelchair": "Walking", "Windsurf": "Canoeing", - "Workout": "Workout", - "Yoga": "Workout" + "Workout": "Other", + "Yoga": "Other" } }, "metadata": { diff --git a/web/src/css/components.css b/web/src/css/components.css index dc1ea2dc..fc24ae1b 100644 --- a/web/src/css/components.css +++ b/web/src/css/components.css @@ -1,6 +1,15 @@ @import 'tailwindcss'; @reference "./app.css"; +:root { + --tooltip-background: rgba(36, 39, 52, 0.75); + --tooltip-border-radius: 4px; + --tooltip-color: #fff; + --tooltip-font-size: 12px; + --tooltip-offset-top: 24px; + --tooltip-padding: 6px 10px; +} + .btn-primary { @apply min-h-10 text-white rounded-lg px-4 py-2 bg-primary font-semibold transition-all hover:bg-primary-hover focus:ring-4 ring-input-ring } @@ -58,13 +67,13 @@ } .tooltip:before { - background: rgba(36, 39, 52, 0.75); - border-radius: 4px; - color: #fff; + background: var(--tooltip-background); + border-radius: var(--tooltip-border-radius); + color: var(--tooltip-color); content: attr(data-title); - font-size: 12px; - padding: 6px 10px; - top: 24px; + font-size: var(--tooltip-font-size); + padding: var(--tooltip-padding); + top: var(--tooltip-offset-top); white-space: nowrap; z-index: 10 } diff --git a/web/src/lib/components/base/calendar.svelte b/web/src/lib/components/base/calendar.svelte index fd886e59..ba026068 100644 --- a/web/src/lib/components/base/calendar.svelte +++ b/web/src/lib/components/base/calendar.svelte @@ -1,8 +1,9 @@ - -
{text}
+ {#if children} + {@render children()} + {:else} +{text}
+ {/if} {/snippet} {#snippet footer()}- {$_(category)} + {displayCategoryName( + category, + $locale, + )} + {#if subcategory} + + / {displaySubcategoryLabel( + subcategory, + $locale, + )} + + {/if}
{/if} {#if location} diff --git a/web/src/lib/components/settings/plugins/plugin_instance_settings_modal.svelte b/web/src/lib/components/settings/plugins/plugin_instance_settings_modal.svelte index be1e10de..ad9f7916 100644 --- a/web/src/lib/components/settings/plugins/plugin_instance_settings_modal.svelte +++ b/web/src/lib/components/settings/plugins/plugin_instance_settings_modal.svelte @@ -6,11 +6,18 @@ import TextField from "$lib/components/base/text_field.svelte"; import Toggle from "$lib/components/base/toggle.svelte"; import PluginMergeSettings from "$lib/components/settings/plugins/plugin_merge_settings.svelte"; + import CategoryPicker from "$lib/components/trail/category_picker.svelte"; import type { Category } from "$lib/models/category"; import type { PluginInstance } from "$lib/models/plugin_instance"; import type { ConfigField, PluginProvider } from "$lib/models/plugin_provider"; + import type { Subcategory } from "$lib/models/subcategory"; import { plugin_auth_validate, plugin_oauth_start } from "$lib/stores/plugin_instance_store"; import { show_toast } from "$lib/stores/toast_store.svelte"; + import { + categoryMappingTargetFromUnknown, + categoryMappingTargetToPickerValue, + type CategoryMappingTarget, + } from "$lib/util/category_util"; import { translatePluginAPIError } from "$lib/util/plugin_error_i18n"; import { configFieldDescription, @@ -34,12 +41,13 @@ interface Props { plugin: PluginProvider; categories?: Category[]; + subcategories?: Subcategory[]; instance?: PluginInstance; onbeforecategorymappingsave?: (instance: PluginInstanceForm) => Promise- {$_( - trail.expand?.category?.name ?? trail.category ?? "-", - )} + + + {#if displayTrailCategoryBadgeIcon(trail)} + + {/if} + {displayCategoryName( + trail.expand?.category ?? { name: trail.category ?? "" }, + $locale, + ) || "-"} + {#if trail.expand?.subcategory} + + / {displaySubcategoryLabel( + trail.expand.subcategory, + $locale, + )} + + {/if}
{/if} {#if trail.location} diff --git a/web/src/lib/i18n/locales/de.json b/web/src/lib/i18n/locales/de.json index 105966f2..5f2ffdec 100644 --- a/web/src/lib/i18n/locales/de.json +++ b/web/src/lib/i18n/locales/de.json @@ -3,9 +3,10 @@ "Canoeing": "Kanufahren", "Climbing": "Klettern", "Hiking": "Wandern", + "Running": "Laufen", "Other": "Sonstiges", "Skiing": "Skifahren", - "Walking": "Laufen", + "Walking": "Spazieren", "about": "Über", "account-delete-confirm": "Du bist dabei, dein Konto zu löschen. Alle deine Routen werden ebenfalls gelöscht. Möchtest du fortfahren?", "account-privacy": "Privatsphäre des Kontos", @@ -17,6 +18,7 @@ "add-waypoint": "Wegpunkt hinzufügen", "added-trail-to": "Route hinzugefügt zu", "added-trails-to": "Routen hinzugefügt zu", + "adjust": "Anpassen", "after": "Nach", "all-activities": "Alle Aktivitäten", "allow-auto-geolocate": "Beginne das Zeichnen einer neuen Route am aktuellen Standort", @@ -31,6 +33,7 @@ "append-waypoint-description": "Kommentar anhängen", "append-waypoint-photos": "Fotos hinzufügen", "append-waypoint-title": "Titel anhängen", + "apply": "Anwenden", "apply-user-settings": "Benutzereinstellungen anwenden", "attraction": "Sehenswürdigkeit", "author": "Autor", @@ -49,6 +52,8 @@ "bicycle-rental": "Fahrradverleih", "bicycle-shop": "Fahrrad-Reparatur", "bike-type": "Fahrradtyp", + "bulk-edit-selected-trails": "{n, plural, =1 {1 ausgewählte Route} other {# ausgewählte Routen}}", + "bulk-edit-updated-trails": "{n, plural, =1 {1 Route aktualisiert} other {# Routen aktualisiert}}", "bus-stop": "Bushaltestelle", "by": "von", "calendar": { @@ -70,6 +75,24 @@ "card": "{n, plural, =1 {Karte} other {Karten}}", "categories": "Kategorien", "category": "Kategorie", + "category-preference-visible": "Anzeigen", + "category-preferences": "Kategorie-Einstellungen", + "category-preferences-description": "Definiere, welche Kategorien und Unterkategorien für deine Routen relevant sind und in welcher Reihenfolge Kategorien erscheinen sollen.", + "confirm-disable-category-with-trails-title": "Kategorie ausblenden?", + "confirm-disable-category-intro": "Diese Kategorie wird noch an einigen Stellen verwendet. Prüfe die Konflikte, bevor du sie ausblendest.", + "confirm-disable-category-with-trails": "{count, plural, =1 {1 deiner eigenen Routen verwendet „{name}“.} other {# deiner eigenen Routen verwenden „{name}“.}}", + "confirm-disable-category-active-plugin-mappings": "Aktive Plugins referenzieren diese Kategorie in ihren Zuordnungen: {plugins}.", + "confirm-disable-category-inactive-plugin-mappings": "Vorbereitete, aber inaktive Plugins referenzieren diese Kategorie ebenfalls: {plugins}.", + "confirm-disable-category-anyway": "Diese Kategorie trotzdem ausblenden?", + "confirm-disable-subcategory-with-trails-title": "Unterkategorie ausblenden?", + "confirm-disable-subcategory-intro": "Diese Unterkategorie wird noch an einigen Stellen verwendet. Prüfe die Konflikte, bevor du sie ausblendest.", + "confirm-disable-subcategory-with-trails": "{count, plural, =1 {1 deiner eigenen Routen verwendet „{name}“.} other {# deiner eigenen Routen verwenden „{name}“.}}", + "confirm-disable-subcategory-active-plugin-mappings": "Aktive Plugins referenzieren diese Unterkategorie in ihren Zuordnungen: {plugins}.", + "confirm-disable-subcategory-inactive-plugin-mappings": "Vorbereitete, aber inaktive Plugins referenzieren diese Unterkategorie ebenfalls: {plugins}.", + "confirm-disable-subcategory-anyway": "Diese Unterkategorie trotzdem ausblenden?", + "conflicts": "Konflikte", + "collapse-subcategories": "Unterkategorien einklappen", + "category-filter-hidden-active": "{categories} ist in deinen Kategorie-Einstellungen ausgeblendet.", "category-mapping": "Kategorie-Zuordnung", "category-mapping-help": "Entfernte Provider-Kategorien werden bewusst nicht zugeordnet und erhalten beim Import keine Kategorie.", "change": "Ändern", @@ -175,6 +198,7 @@ "error-printing-map": "Fehler beim Drucken der Karte", "error-reading-file": "Fehler beim Lesen der Datei", "error-saving-list": "Fehler beim Speichern der Liste", + "error-saving-settings": "Fehler beim Speichern der Einstellungen", "error-saving-trail": "Fehler beim Speichern der Route", "error-setting-up-plugin": "Fehler beim Einrichten des {provider}-Plugins", "error-starting-oauth": "Fehler beim Starten der OAuth-Verbindung", @@ -185,9 +209,14 @@ "error-uploading-trail-to-hammerhead": "Fehler beim Hochladen der Route zu Hammerhead", "est-duration": "Gesch. Dauer", "everyone-with-the-link": "Jeder mit dem Link", + "expand-subcategories": "Unterkategorien ausklappen", "expand-trail-list": "", "expiration": "", "expires": "", + "exclude-federated": "Föderierte ausschließen", + "exclude-search": "Von Suche ausschließen", + "include-federated": "Föderierte einschließen", + "include-search": "In Suche einschließen", "explore": "Erkunden", "explore-some-trails": "Erkunde einige Routen", "export": "Exportieren", @@ -223,6 +252,9 @@ "get-started": "Los geht’s", "grid": "Gitter", "grocery-store": "Lebensmittelgeschäft", + "hide-design": "Im Routeneditor ausblenden", + "show-design": "Im Routeneditor auswählbar", + "hammerhead-integration-after-date-hint": "Wenn Ihr Hammerhead Konto bereits mit anderen Trail-Datenbanken wie komoot oder Strava synchronisiert ist, kann die zusätzliche Synchronisierung Ihrer Hammerhead-Daten zu Duplikaten führen. Um dies zu vermeiden, können Sie unten ein Startdatum festlegen, sodass nur Aktivitäten synchronisiert werden, die nach diesem Datum aufgezeichnet wurden.", "heading": "Überschrift", "height": "Höhe", "help": "Hilfe", @@ -362,6 +394,7 @@ "no-preference": "Keine Präferenz", "no-results": "Keine Ergebnisse gefunden", "no-routes-added": "Keine Routen hinzugefügt", + "no-subcategory": "Keine Unterkategorie", "no-waypoints-yet": "Noch keine Wegpunkte", "norwegian": "Norwegisch", "not-a-valid-email-address": "Keine gültige Email-Adresse", @@ -407,6 +440,7 @@ "profile": "Profil", "provider-category": "Provider-Kategorie", "public": "Öffentlich", + "priority": "Priorität", "public-access": "Öffentlicher Zugriff", "public-share-everyone": "Jeder im Internet mit dem Link kann diese Route sehen", "public-share-limited": "Nur Leute mit Zugriff können diese Route sehen", @@ -503,6 +537,7 @@ "stop-drawing": "Zeichnen beenden", "stop-editing": "Bearbeiten beenden", "subway-stop": "U-Bahn Eingang", + "subcategory": "Unterkategorie", "summit": "Gipfel", "summit-book": "Gipfelbuch", "summit-log": "{n, plural, =1 {Gipfelbuch-Eintrag} other {Gipfelbuch-Einträge}}", @@ -588,6 +623,7 @@ "tram-stop": "Tram Haltestelle", "unchanged": "unverändert", "units": "Einheiten", + "unprioritized": "Nicht priorisiert", "unlink": "Trennen", "upload-file": "Datei hochladen", "upload-gpx": "GPX hochladen", @@ -602,6 +638,7 @@ "username": "Nutzername", "username-not-unique": "Dieser Nutzername ist bereits vergeben. Bitte versuche es mit einem anderen.", "view": "Ansehen", + "view-affected-trails": "Betroffene Routen ansehen", "viewpoint": "Aussichtspunkt", "visibilty": "", "visibilty-status": "Sichtbarkeit", diff --git a/web/src/lib/i18n/locales/en.json b/web/src/lib/i18n/locales/en.json index f49ea388..7c6dd870 100644 --- a/web/src/lib/i18n/locales/en.json +++ b/web/src/lib/i18n/locales/en.json @@ -3,6 +3,7 @@ "Canoeing": "Canoeing", "Climbing": "Climbing", "Hiking": "Hiking", + "Running": "Running", "Other": "Other", "Skiing": "", "Walking": "Walking", @@ -17,6 +18,7 @@ "add-waypoint": "Add Waypoint", "added-trail-to": "Added trail to", "added-trails-to": "Added trails to", + "adjust": "Adjust", "after": "After", "all-activities": "All activities", "allow-auto-geolocate": "Begin drawing a new trail from your current location", @@ -31,6 +33,7 @@ "append-waypoint-description": "Append description", "append-waypoint-photos": "Add photos", "append-waypoint-title": "Append title", + "apply": "Apply", "apply-user-settings": "Apply user settings", "attraction": "Attraction", "author": "Author", @@ -49,6 +52,8 @@ "bicycle-rental": "Bicycle Rental", "bicycle-shop": "Bicycle Shop", "bike-type": "Bike Type", + "bulk-edit-selected-trails": "{n, plural, =1 {1 selected trail} other {# selected trails}}", + "bulk-edit-updated-trails": "{n, plural, =1 {1 trail updated} other {# trails updated}}", "bus-stop": "Bus stop", "by": "by", "calendar": { @@ -70,6 +75,24 @@ "card": "{n, plural, =1 {Card} other {Cards}}", "categories": "Categories", "category": "Category", + "category-preference-visible": "Show", + "category-preferences": "Category preferences", + "category-preferences-description": "Define which categories and subcategories are relevant to your trails and the order in which categories appear.", + "confirm-disable-category-with-trails-title": "Hide category?", + "confirm-disable-category-intro": "This category is still used in a few places. Review the conflicts before hiding it.", + "confirm-disable-category-with-trails": "{count, plural, =1 {1 of your own trails uses “{name}”.} other {# of your own trails use “{name}”.}}", + "confirm-disable-category-active-plugin-mappings": "Active plugins reference this category in their mappings: {plugins}.", + "confirm-disable-category-inactive-plugin-mappings": "Prepared but inactive plugins also reference this category: {plugins}.", + "confirm-disable-category-anyway": "Hide this category anyway?", + "confirm-disable-subcategory-with-trails-title": "Hide subcategory?", + "confirm-disable-subcategory-intro": "This subcategory is still used in a few places. Review the conflicts before hiding it.", + "confirm-disable-subcategory-with-trails": "{count, plural, =1 {1 of your own trails uses “{name}”.} other {# of your own trails use “{name}”.}}", + "confirm-disable-subcategory-active-plugin-mappings": "Active plugins reference this subcategory in their mappings: {plugins}.", + "confirm-disable-subcategory-inactive-plugin-mappings": "Prepared but inactive plugins also reference this subcategory: {plugins}.", + "confirm-disable-subcategory-anyway": "Hide this subcategory anyway?", + "conflicts": "Conflicts", + "collapse-subcategories": "Collapse subcategories", + "category-filter-hidden-active": "{categories} is hidden in your category preferences.", "category-mapping": "Category mapping", "category-mapping-help": "Removed provider categories are intentionally left unmapped and will be imported without a category.", "change": "Change", @@ -175,6 +198,7 @@ "error-printing-map": "Error printing map", "error-reading-file": "Error reading file", "error-saving-list": "Error saving list", + "error-saving-settings": "Error saving settings", "error-saving-trail": "Error saving trail", "error-setting-up-plugin": "Error setting up {provider} plugin", "error-starting-oauth": "Error starting OAuth connection", @@ -185,9 +209,14 @@ "error-uploading-trail-to-hammerhead": "Error uploading trail to Hammerhead", "est-duration": "Est. duration", "everyone-with-the-link": "Everyone with the link", + "expand-subcategories": "Expand subcategories", "expand-trail-list": "Expand trail list", "expiration": "Expiration", "expires": "Expires", + "exclude-federated": "Exclude federated", + "exclude-search": "Exclude from search", + "include-federated": "Include federated", + "include-search": "Include in search", "explore": "Explore", "explore-some-trails": "Explore some trails", "export": "Export", @@ -223,6 +252,9 @@ "get-started": "Get started", "grid": "Grid", "grocery-store": "Grocery store", + "hide-design": "Hide in trail editor", + "show-design": "Available in trail editor", + "hammerhead-integration-after-date-hint": "If your hammerhead account is already synced with other trail databases, such as komoot or Strava, start syncing your Hammerhead data may result in duplicates. To avoid this, you can set an start date below, meaning only activities recorded after this date will be synced.", "heading": "Heading", "height": "Height", "help": "Help", @@ -362,6 +394,7 @@ "no-preference": "No preference", "no-results": "No results found", "no-routes-added": "No routes added", + "no-subcategory": "No subcategory", "no-waypoints-yet": "No waypoints yet", "norwegian": "Norwegian", "not-a-valid-email-address": "Not a valid email address", @@ -407,6 +440,7 @@ "profile": "Profile", "provider-category": "Provider category", "public": "Public", + "priority": "Priority", "public-access": "Public access", "public-share-everyone": "Everyone on the internet with the link can see this trail", "public-share-limited": "Only people with access can open the link", @@ -503,6 +537,7 @@ "stop-drawing": "Stop drawing", "stop-editing": "Stop editing", "subway-stop": "Subway entrance", + "subcategory": "Subcategory", "summit": "Summit", "summit-book": "Summit Book", "summit-log": "{n, plural, =1 {Summit log} other {Summit logs}}", @@ -588,6 +623,7 @@ "tram-stop": "Tram stop", "unchanged": "unchanged", "units": "Units", + "unprioritized": "Unprioritized", "unlink": "Unlink", "upload-file": "Upload file", "upload-gpx": "Upload GPX", @@ -602,6 +638,7 @@ "username": "Username", "username-not-unique": "This username is already taken. Please try another.", "view": "View", + "view-affected-trails": "View affected trails", "viewpoint": "Viewpoint", "visibilty": "Visibility", "visibilty-status": "Visibility status", diff --git a/web/src/lib/models/api/category_preference_schema.ts b/web/src/lib/models/api/category_preference_schema.ts new file mode 100644 index 00000000..3ad52055 --- /dev/null +++ b/web/src/lib/models/api/category_preference_schema.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +const UserCategoryPreferenceUpsertSchema = z.object({ + category: z.string().length(15), + visible: z.boolean(), +}); + +const UserCategoryPreferenceReorderSchema = z.object({ + categories: z.array(z.string().length(15)), +}); + +export { + UserCategoryPreferenceReorderSchema, + UserCategoryPreferenceUpsertSchema, +}; diff --git a/web/src/lib/models/api/openapi_schemas.ts b/web/src/lib/models/api/openapi_schemas.ts index aa2a218a..7f18af3d 100644 --- a/web/src/lib/models/api/openapi_schemas.ts +++ b/web/src/lib/models/api/openapi_schemas.ts @@ -158,6 +158,195 @@ * type: string * description: Tag name * + * CategoryTranslation: + * type: object + * properties: + * name: + * type: string + * short_name: + * type: string + * + * Category: + * type: object + * required: + * - id + * - name + * properties: + * id: + * type: string + * description: Category ID (15 chars) + * name: + * type: string + * short_name: + * type: string + * nullable: true + * icon: + * type: string + * nullable: true + * translations: + * type: object + * nullable: true + * additionalProperties: + * $ref: '#/components/schemas/CategoryTranslation' + * settings: + * type: object + * nullable: true + * properties: + * wp_merge_enabled: + * type: boolean + * wp_merge_radius: + * type: number + * created: + * type: string + * format: date-time + * updated: + * type: string + * format: date-time + * + * Subcategory: + * type: object + * required: + * - id + * - category + * - name + * properties: + * id: + * type: string + * description: Subcategory ID (15 chars) + * category: + * type: string + * description: Parent category ID (15 chars) + * name: + * type: string + * short_name: + * type: string + * nullable: true + * icon: + * type: string + * nullable: true + * badge_icon: + * type: string + * nullable: true + * translations: + * type: object + * nullable: true + * additionalProperties: + * $ref: '#/components/schemas/CategoryTranslation' + * created: + * type: string + * format: date-time + * updated: + * type: string + * format: date-time + * + * UserCategoryPreference: + * type: object + * required: + * - id + * - user + * - category + * - visible + * properties: + * id: + * type: string + * description: Preference ID (15 chars) + * user: + * type: string + * description: User ID (15 chars) + * category: + * type: string + * description: Category ID (15 chars) + * visible: + * type: boolean + * priority: + * type: integer + * nullable: true + * created: + * type: string + * format: date-time + * updated: + * type: string + * format: date-time + * + * UserCategoryPreferenceUpsertInput: + * type: object + * required: + * - category + * - visible + * properties: + * category: + * type: string + * description: Category ID (15 chars) + * visible: + * type: boolean + * + * UserCategoryPreferenceReorderInput: + * type: object + * required: + * - categories + * properties: + * categories: + * type: array + * items: + * type: string + * description: Category ID (15 chars) + * + * UserSubcategoryPreference: + * type: object + * required: + * - id + * - user + * - subcategory + * - visible + * properties: + * id: + * type: string + * description: Preference ID (15 chars) + * user: + * type: string + * description: User ID (15 chars) + * subcategory: + * type: string + * description: Subcategory ID (15 chars) + * visible: + * type: boolean + * priority: + * type: integer + * nullable: true + * created: + * type: string + * format: date-time + * updated: + * type: string + * format: date-time + * + * UserSubcategoryPreferenceUpsertInput: + * type: object + * required: + * - subcategory + * - visible + * properties: + * subcategory: + * type: string + * description: Subcategory ID (15 chars) + * visible: + * type: boolean + * + * UserSubcategoryPreferenceReorderInput: + * type: object + * required: + * - category + * - subcategories + * properties: + * category: + * type: string + * description: Category ID (15 chars) + * subcategories: + * type: array + * items: + * type: string + * description: Subcategory ID (15 chars) + * * Trail: * type: object * required: @@ -220,6 +409,9 @@ * category: * type: string * description: Category ID (15 chars) + * subcategory: + * type: string + * description: Subcategory ID (15 chars) * tags: * type: array * items: @@ -290,6 +482,8 @@ * default: 0 * category: * type: string + * subcategory: + * type: string * tags: * type: array * items: @@ -348,6 +542,8 @@ * default: 0 * category: * type: string + * subcategory: + * type: string * tags: * type: array * items: diff --git a/web/src/lib/models/api/subcategory_preference_schema.ts b/web/src/lib/models/api/subcategory_preference_schema.ts new file mode 100644 index 00000000..ac36e940 --- /dev/null +++ b/web/src/lib/models/api/subcategory_preference_schema.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +const UserSubcategoryPreferenceUpsertSchema = z.object({ + subcategory: z.string().length(15), + visible: z.boolean(), +}); + +const UserSubcategoryPreferenceReorderSchema = z.object({ + category: z.string().length(15), + subcategories: z.array(z.string().length(15)), +}); + +export { + UserSubcategoryPreferenceReorderSchema, + UserSubcategoryPreferenceUpsertSchema, +}; diff --git a/web/src/lib/models/api/trail_schema.ts b/web/src/lib/models/api/trail_schema.ts index 609b6380..5824daf8 100644 --- a/web/src/lib/models/api/trail_schema.ts +++ b/web/src/lib/models/api/trail_schema.ts @@ -21,6 +21,7 @@ const TrailCreateSchema = z.object({ thumbnail: z.number().int().nonnegative().optional(), like_count: z.number().int().min(0).optional().default(0), category: z.string().length(15).optional().or(z.literal('')), + subcategory: z.string().length(15).optional().or(z.literal('')), tags: z.array(z.string()).default([]), gpx: z.string().optional(), author: z.string().length(15), @@ -47,6 +48,7 @@ const TrailUpdateSchema = z.object({ thumbnail: z.number().int().nonnegative().optional(), like_count: z.number().int().min(0).optional(), category: z.string().optional(), + subcategory: z.string().optional(), tags: z.array(z.string()).optional(), gpx: z.string().optional(), }) satisfies ZodType+ {$_("category-preferences-description")} +
+{disableIntroTextForPendingDisable()}
+ +{ownTrailMessageForPendingDisable()}
+{activePluginMessageForPendingDisable()}
+{inactivePluginMessageForPendingDisable()}
+{disableAnywayTextForPendingDisable()}
+ {/if} +