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:
52
db/routes/category_preferences.go
Normal file
52
db/routes/category_preferences.go
Normal 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})
|
||||
}
|
||||
@@ -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++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user