feat: Refine trail category model (#1059)

* feat: advanced trail categories

* rename remote_category

* fix merge issues

* subcategories for plugins mapping

* fix refresh

* remove vertical trail filter category scrolling

* cleanup

* add confirm modal for disabling a category

* Fix federation issues

* fix review findings

* redesign category settings page

* remove prio badge

* fix subcategory badge layout

* optimize subcategory settings layout

* further settings page layout optimization

* fix subcategory icon position

* fix

* update docs

---------

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

View File

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

View File

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

View File

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

View File

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

View File

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