feat: Refine trail category model (#1059)
* feat: advanced trail categories * rename remote_category * fix merge issues * subcategories for plugins mapping * fix refresh * remove vertical trail filter category scrolling * cleanup * add confirm modal for disabling a category * Fix federation issues * fix review findings * redesign category settings page * remove prio badge * fix subcategory badge layout * optimize subcategory settings layout * further settings page layout optimization * fix subcategory icon position * fix * update docs --------- Co-authored-by: Christian Beutel <>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user