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:
@@ -682,17 +682,21 @@ func processCreateOrUpdateSummitLogActivity(activity pub.Activity, app core.App,
|
||||
}
|
||||
}
|
||||
|
||||
if len(photoURLs) > 0 {
|
||||
photos := make([]*filesystem.File, len(photoURLs))
|
||||
for i, purl := range photoURLs {
|
||||
if len(photoURLs) == 0 {
|
||||
record.Set("photos", []*filesystem.File{})
|
||||
} else {
|
||||
photos := []*filesystem.File{}
|
||||
for _, purl := range photoURLs {
|
||||
photo, err := filesystem.NewFileFromURL(context.Background(), purl)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
photos[i] = photo
|
||||
photos = append(photos, photo)
|
||||
}
|
||||
|
||||
record.Set("photos", photos)
|
||||
if len(photos) > 0 {
|
||||
record.Set("photos", photos)
|
||||
}
|
||||
}
|
||||
|
||||
if gpxURL != "" {
|
||||
|
||||
125
db/hooks/categories.go
Normal file
125
db/hooks/categories.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"pocketbase/util"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
func ValidateCategoryHandler() func(e *core.RecordRequestEvent) error {
|
||||
return func(e *core.RecordRequestEvent) error {
|
||||
if err := util.ValidateCategoryRecord(e.App, e.Record); err != nil {
|
||||
return apis.NewBadRequestError(err.Error(), err)
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func ValidateSubcategoryHandler() func(e *core.RecordRequestEvent) error {
|
||||
return func(e *core.RecordRequestEvent) error {
|
||||
if err := util.ValidateSubcategoryRecord(e.App, e.Record); err != nil {
|
||||
return apis.NewBadRequestError(err.Error(), err)
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func BackfillRemoteTrailCategoryHandler() func(e *core.RecordEvent) error {
|
||||
return func(e *core.RecordEvent) error {
|
||||
if e.Record.Original().Id != "" && e.Record.GetString("name") == e.Record.Original().GetString("name") {
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
if err := util.BackfillRemoteTrailCategory(e.App, e.Record); err != nil {
|
||||
e.App.Logger().Warn("failed to backfill remote trail categories after category save", "category", e.Record.Id, "error", err)
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func BackfillRemoteTrailSubcategoryHandler() func(e *core.RecordEvent) error {
|
||||
return func(e *core.RecordEvent) error {
|
||||
original := e.Record.Original()
|
||||
if original.Id != "" &&
|
||||
e.Record.GetString("name") == original.GetString("name") &&
|
||||
e.Record.GetString("category") == original.GetString("category") {
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
if err := util.BackfillRemoteTrailSubcategory(e.App, e.Record); err != nil {
|
||||
e.App.Logger().Warn("failed to backfill remote trail subcategories after subcategory save", "subcategory", e.Record.Id, "error", err)
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func ValidateUserCategoryPreferenceHandler() func(e *core.RecordRequestEvent) error {
|
||||
return func(e *core.RecordRequestEvent) error {
|
||||
requestInfo, err := e.RequestInfo()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := util.ValidateUserCategoryPreferenceRequest(requestBodyHasField(requestInfo.Body, "priority")); err != nil {
|
||||
return apis.NewBadRequestError(err.Error(), err)
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func ValidateUserSubcategoryPreferenceHandler() func(e *core.RecordRequestEvent) error {
|
||||
return func(e *core.RecordRequestEvent) error {
|
||||
requestInfo, err := e.RequestInfo()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := util.ValidateUserSubcategoryPreferenceRequest(requestBodyHasField(requestInfo.Body, "priority")); err != nil {
|
||||
return apis.NewBadRequestError(err.Error(), err)
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func ValidateTrailSubcategoryHandler() func(e *core.RecordRequestEvent) error {
|
||||
return func(e *core.RecordRequestEvent) error {
|
||||
requestInfo, err := e.RequestInfo()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
subcategoryExplicit := requestBodyHasField(requestInfo.Body, "subcategory")
|
||||
if err := util.ValidateTrailSubcategoryRecord(e.App, e.Record, subcategoryExplicit); err != nil {
|
||||
return apis.NewBadRequestError(err.Error(), err)
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func requestBodyHasField(body map[string]any, field string) bool {
|
||||
_, ok := body[field]
|
||||
if ok {
|
||||
return true
|
||||
}
|
||||
|
||||
_, ok = body[field+"+"]
|
||||
if ok {
|
||||
return true
|
||||
}
|
||||
|
||||
_, ok = body["+"+field]
|
||||
if ok {
|
||||
return true
|
||||
}
|
||||
|
||||
_, ok = body[field+"-"]
|
||||
return ok
|
||||
}
|
||||
@@ -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
|
||||
|
||||
65
db/main.go
65
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"},
|
||||
},
|
||||
|
||||
803
db/migrations/1781000000_categories_redesign.go
Normal file
803
db/migrations/1781000000_categories_redesign.go
Normal file
@@ -0,0 +1,803 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"pocketbase/util"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m.Register(func(app core.App) error {
|
||||
collection, err := app.FindCollectionByNameOrId("categories")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := resolveCategoryNameCollisions(app); err != nil {
|
||||
return fmt.Errorf("failed to resolve category name collisions: %w", err)
|
||||
}
|
||||
|
||||
if err := deleteCategoryImageFiles(app); err != nil {
|
||||
return fmt.Errorf("failed to delete category image files: %w", err)
|
||||
}
|
||||
|
||||
collection.Fields.RemoveById("64dsnxtb")
|
||||
|
||||
if err := collection.Fields.AddMarshaledJSONAt(len(collection.Fields), []byte(`{
|
||||
"hidden": false,
|
||||
"id": "texti4ksx4gm",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "short_name",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := collection.Fields.AddMarshaledJSONAt(len(collection.Fields), []byte(`{
|
||||
"hidden": false,
|
||||
"id": "text0r6k2h4gi",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "icon",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := collection.Fields.AddMarshaledJSONAt(len(collection.Fields), []byte(`{
|
||||
"hidden": false,
|
||||
"id": "jsonvkf7o88i",
|
||||
"maxSize": 0,
|
||||
"name": "translations",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "json"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := app.Save(collection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ensureRunningCategory(app); err != nil {
|
||||
return fmt.Errorf("failed to ensure running category: %w", err)
|
||||
}
|
||||
|
||||
if err := createSubcategoriesCollection(app); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
trailsCollection, err := app.FindCollectionByNameOrId("trails")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := trailsCollection.Fields.AddMarshaledJSONAt(len(trailsCollection.Fields), []byte(`{
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "pbc_1781100000",
|
||||
"hidden": false,
|
||||
"id": "relphase2subct",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "subcategory",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := trailsCollection.Fields.AddMarshaledJSONAt(len(trailsCollection.Fields), []byte(`{
|
||||
"hidden": false,
|
||||
"id": "textremotecat1",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "federated_category_name",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := trailsCollection.Fields.AddMarshaledJSONAt(len(trailsCollection.Fields), []byte(`{
|
||||
"hidden": false,
|
||||
"id": "textremotesub1",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "federated_subcategory_name",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := app.Save(trailsCollection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := util.PrepopulateDefaultCategoryTranslations(app); err != nil {
|
||||
return fmt.Errorf("failed to prepopulate default category translations: %w", err)
|
||||
}
|
||||
|
||||
if err := util.PrepopulateDefaultCategoryIcons(app); err != nil {
|
||||
return fmt.Errorf("failed to prepopulate default category icons: %w", err)
|
||||
}
|
||||
|
||||
if err := util.SeedDefaultSubcategories(app); err != nil {
|
||||
return fmt.Errorf("failed to seed default subcategories: %w", err)
|
||||
}
|
||||
|
||||
if err := createUserCategoryPreferencesCollection(app); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := createUserSubcategoryPreferencesCollection(app); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := migrateFavouriteSportToPriority(app); err != nil {
|
||||
return fmt.Errorf("failed to migrate favourite sport to category priority: %w", err)
|
||||
}
|
||||
|
||||
if err := removeSettingsCategoryField(app); err != nil {
|
||||
return fmt.Errorf("failed to remove settings.category field: %w", err)
|
||||
}
|
||||
|
||||
if err := util.ValidateCategoryCollectionState(app); err != nil {
|
||||
return fmt.Errorf("categories redesign migration failed validation: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, func(app core.App) error {
|
||||
if collection, err := app.FindCollectionByNameOrId("pbc_1781250000"); err == nil {
|
||||
if err := app.Delete(collection); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if collection, err := app.FindCollectionByNameOrId("pbc_1781200000"); err == nil {
|
||||
if err := app.Delete(collection); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if trailsCollection, err := app.FindCollectionByNameOrId("trails"); err == nil {
|
||||
trailsCollection.Fields.RemoveById("relphase2subct")
|
||||
trailsCollection.Fields.RemoveById("textremotecat1")
|
||||
trailsCollection.Fields.RemoveById("textremotesub1")
|
||||
if err := app.Save(trailsCollection); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if collection, err := app.FindCollectionByNameOrId("pbc_1781100000"); err == nil {
|
||||
if err := app.Delete(collection); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if settingsCollection, err := app.FindCollectionByNameOrId("settings"); err == nil {
|
||||
if err := settingsCollection.Fields.AddMarshaledJSONAt(len(settingsCollection.Fields), []byte(`{
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "kjxvi8asj2igqwf",
|
||||
"hidden": false,
|
||||
"id": "owlyzl1x",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "category",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := app.Save(settingsCollection); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
collection, err := app.FindCollectionByNameOrId("categories")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
collection.Fields.RemoveById("texti4ksx4gm")
|
||||
collection.Fields.RemoveById("text0r6k2h4gi")
|
||||
collection.Fields.RemoveById("jsonvkf7o88i")
|
||||
|
||||
if err := collection.Fields.AddMarshaledJSONAt(2, []byte(`{
|
||||
"hidden": false,
|
||||
"id": "64dsnxtb",
|
||||
"maxSelect": 1,
|
||||
"maxSize": 5242880,
|
||||
"mimeTypes": null,
|
||||
"name": "img",
|
||||
"presentable": false,
|
||||
"protected": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"thumbs": null,
|
||||
"type": "file"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return app.Save(collection)
|
||||
})
|
||||
}
|
||||
|
||||
type categoryCollisionCandidate struct {
|
||||
id string
|
||||
name string
|
||||
created string
|
||||
}
|
||||
|
||||
func resolveCategoryNameCollisions(app core.App) error {
|
||||
allCategories, err := app.FindAllRecords("categories")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
candidates := make([]categoryCollisionCandidate, 0, len(allCategories))
|
||||
for _, category := range allCategories {
|
||||
candidates = append(candidates, categoryCollisionCandidate{
|
||||
id: category.Id,
|
||||
name: category.GetString("name"),
|
||||
created: category.GetString("created"),
|
||||
})
|
||||
}
|
||||
resolvedNames := resolveCategoryNameCollisionCandidates(candidates)
|
||||
|
||||
for _, category := range allCategories {
|
||||
resolvedName, ok := resolvedNames[category.Id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
originalName := category.GetString("name")
|
||||
category.Set("name", resolvedName)
|
||||
if err := app.Save(category); err != nil {
|
||||
return fmt.Errorf("failed to resolve category name collision for %q: %w", originalName, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func collisionResolvedCategoryName(name string, id string, seen map[string]struct{}) string {
|
||||
baseName := strings.TrimSpace(name)
|
||||
if baseName == "" {
|
||||
baseName = "Category"
|
||||
}
|
||||
|
||||
for i := 0; ; i++ {
|
||||
suffix := id
|
||||
if i > 0 {
|
||||
suffix = fmt.Sprintf("%s-%d", id, i+1)
|
||||
}
|
||||
|
||||
candidate := fmt.Sprintf("%s (%s)", baseName, suffix)
|
||||
if _, ok := seen[util.NormalizeCategoryName(candidate)]; !ok {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resolveCategoryNameCollisionCandidates(candidates []categoryCollisionCandidate) map[string]string {
|
||||
sorted := append([]categoryCollisionCandidate(nil), candidates...)
|
||||
sort.SliceStable(sorted, func(i, j int) bool {
|
||||
left := sorted[i]
|
||||
right := sorted[j]
|
||||
|
||||
leftName := util.NormalizeCategoryName(left.name)
|
||||
rightName := util.NormalizeCategoryName(right.name)
|
||||
if leftName != rightName {
|
||||
return leftName < rightName
|
||||
}
|
||||
|
||||
if left.created != right.created {
|
||||
return left.created < right.created
|
||||
}
|
||||
|
||||
return left.id < right.id
|
||||
})
|
||||
|
||||
seen := map[string]struct{}{}
|
||||
resolved := map[string]string{}
|
||||
for _, category := range sorted {
|
||||
normalizedName := util.NormalizeCategoryName(category.name)
|
||||
if _, ok := seen[normalizedName]; !ok {
|
||||
seen[normalizedName] = struct{}{}
|
||||
continue
|
||||
}
|
||||
|
||||
resolvedName := collisionResolvedCategoryName(category.name, category.id, seen)
|
||||
resolved[category.id] = resolvedName
|
||||
seen[util.NormalizeCategoryName(resolvedName)] = struct{}{}
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
func ensureRunningCategory(app core.App) error {
|
||||
allCategories, err := app.FindAllRecords("categories")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(allCategories) == 0 {
|
||||
return util.SeedDefaultCategories(app)
|
||||
}
|
||||
|
||||
for _, category := range allCategories {
|
||||
if util.NormalizeCategoryName(category.GetString("name")) == util.NormalizeCategoryName("Running") {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
collection, err := app.FindCollectionByNameOrId("categories")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
record := core.NewRecord(collection)
|
||||
record.Set("name", "Running")
|
||||
record.Set("settings", map[string]any{
|
||||
"wp_merge_enabled": true,
|
||||
"wp_merge_radius": 50,
|
||||
})
|
||||
|
||||
return app.Save(record)
|
||||
}
|
||||
|
||||
func deleteCategoryImageFiles(app core.App) error {
|
||||
allCategories, err := app.FindAllRecords("categories")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fsys, err := app.NewFilesystem()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fsys.Close()
|
||||
|
||||
var failures []error
|
||||
for _, category := range allCategories {
|
||||
for _, filename := range categoryImageFilenames(category) {
|
||||
if filename == "" || strings.ContainsAny(filename, `/\`) {
|
||||
continue
|
||||
}
|
||||
|
||||
path := category.BaseFilesPath() + "/" + filename
|
||||
if err := fsys.Delete(path); err != nil && !errors.Is(err, filesystem.ErrNotFound) {
|
||||
failures = append(failures, fmt.Errorf("failed to delete category image %q: %w", path, err))
|
||||
}
|
||||
|
||||
if errs := fsys.DeletePrefix(category.BaseFilesPath() + "/thumbs_" + filename + "/"); len(errs) > 0 {
|
||||
failures = append(failures, fmt.Errorf("failed to delete category image thumbs for %q: %w", path, errors.Join(errs...)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(failures) > 0 {
|
||||
return errors.Join(failures...)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func categoryImageFilenames(record *core.Record) []string {
|
||||
filenames := record.GetStringSlice("img")
|
||||
if len(filenames) > 0 {
|
||||
return filenames
|
||||
}
|
||||
|
||||
filename := record.GetString("img")
|
||||
if filename == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return []string{filename}
|
||||
}
|
||||
|
||||
func createSubcategoriesCollection(app core.App) error {
|
||||
jsonData := `{
|
||||
"createRule": null,
|
||||
"deleteRule": null,
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 15,
|
||||
"min": 15,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "kjxvi8asj2igqwf",
|
||||
"hidden": false,
|
||||
"id": "relphase2cat01",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "category",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "textphase2name",
|
||||
"max": 0,
|
||||
"min": 1,
|
||||
"name": "name",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "textphase2shrt",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "short_name",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "textphase2icon",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "icon",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "textphase2badge",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "badge_icon",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "jsonphase2trns",
|
||||
"maxSize": 0,
|
||||
"name": "translations",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "json"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate2990389176",
|
||||
"name": "created",
|
||||
"onCreate": true,
|
||||
"onUpdate": false,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate3332085495",
|
||||
"name": "updated",
|
||||
"onCreate": true,
|
||||
"onUpdate": true,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
}
|
||||
],
|
||||
"id": "pbc_1781100000",
|
||||
"indexes": [],
|
||||
"listRule": "",
|
||||
"name": "subcategories",
|
||||
"system": false,
|
||||
"type": "base",
|
||||
"updateRule": null,
|
||||
"viewRule": ""
|
||||
}`
|
||||
|
||||
return saveCollectionFromJSON(app, jsonData)
|
||||
}
|
||||
|
||||
func createUserCategoryPreferencesCollection(app core.App) error {
|
||||
jsonData := `{
|
||||
"createRule": "@request.auth.id != \"\" && user = @request.auth.id",
|
||||
"deleteRule": "@request.auth.id != \"\" && user = @request.auth.id",
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 15,
|
||||
"min": 15,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"hidden": false,
|
||||
"id": "relphase3user",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "user",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "kjxvi8asj2igqwf",
|
||||
"hidden": false,
|
||||
"id": "relphase3cat",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "category",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "boolphase3visible",
|
||||
"name": "visible",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "numphase3prio",
|
||||
"max": null,
|
||||
"min": 1,
|
||||
"name": "priority",
|
||||
"onlyInt": true,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate2990389176",
|
||||
"name": "created",
|
||||
"onCreate": true,
|
||||
"onUpdate": false,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate3332085495",
|
||||
"name": "updated",
|
||||
"onCreate": true,
|
||||
"onUpdate": true,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
}
|
||||
],
|
||||
"id": "pbc_1781200000",
|
||||
"indexes": [
|
||||
"CREATE UNIQUE INDEX ` + "`" + `idx_user_category_preferences_user_category` + "`" + ` ON ` + "`" + `user_category_preferences` + "`" + ` (` + "`" + `user` + "`" + `, ` + "`" + `category` + "`" + `)"
|
||||
],
|
||||
"listRule": "@request.auth.id != \"\" && user = @request.auth.id",
|
||||
"name": "user_category_preferences",
|
||||
"system": false,
|
||||
"type": "base",
|
||||
"updateRule": "@request.auth.id != \"\" && user = @request.auth.id",
|
||||
"viewRule": "@request.auth.id != \"\" && user = @request.auth.id"
|
||||
}`
|
||||
|
||||
return saveCollectionFromJSON(app, jsonData)
|
||||
}
|
||||
|
||||
func createUserSubcategoryPreferencesCollection(app core.App) error {
|
||||
jsonData := `{
|
||||
"createRule": "@request.auth.id != \"\" && user = @request.auth.id",
|
||||
"deleteRule": "@request.auth.id != \"\" && user = @request.auth.id",
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"hidden": false,
|
||||
"id": "text178125id",
|
||||
"max": 15,
|
||||
"min": 15,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"hidden": false,
|
||||
"id": "rel178125user",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "user",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "pbc_1781100000",
|
||||
"hidden": false,
|
||||
"id": "rel178125subcat",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "subcategory",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "bool178125visible",
|
||||
"name": "visible",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "num178125prio",
|
||||
"max": null,
|
||||
"min": 1,
|
||||
"name": "priority",
|
||||
"onlyInt": true,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate178125created",
|
||||
"name": "created",
|
||||
"onCreate": true,
|
||||
"onUpdate": false,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate178125updated",
|
||||
"name": "updated",
|
||||
"onCreate": true,
|
||||
"onUpdate": true,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
}
|
||||
],
|
||||
"id": "pbc_1781250000",
|
||||
"indexes": [
|
||||
"CREATE UNIQUE INDEX ` + "`" + `idx_user_subcategory_preferences_user_subcategory` + "`" + ` ON ` + "`" + `user_subcategory_preferences` + "`" + ` (` + "`" + `user` + "`" + `, ` + "`" + `subcategory` + "`" + `)"
|
||||
],
|
||||
"listRule": "@request.auth.id != \"\" && user = @request.auth.id",
|
||||
"name": "user_subcategory_preferences",
|
||||
"system": false,
|
||||
"type": "base",
|
||||
"updateRule": "@request.auth.id != \"\" && user = @request.auth.id",
|
||||
"viewRule": "@request.auth.id != \"\" && user = @request.auth.id"
|
||||
}`
|
||||
|
||||
return saveCollectionFromJSON(app, jsonData)
|
||||
}
|
||||
|
||||
// migrateFavouriteSportToPriority carries the former per-user "favourite sport"
|
||||
// (settings.category) over to the new category priority model: the favourite becomes
|
||||
// the user's priority-1 category. Users without a favourite get a common default
|
||||
// instead of falling back to category sort order. Users who already organized
|
||||
// categories by priority are left untouched.
|
||||
func migrateFavouriteSportToPriority(app core.App) error {
|
||||
settingsRecords, err := app.FindAllRecords("settings")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, settings := range settingsRecords {
|
||||
if err := util.EnsureUserCategoryPriority(app, settings.GetString("user"), settings.GetString("category")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeSettingsCategoryField(app core.App) error {
|
||||
settings, err := app.FindCollectionByNameOrId("settings")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
settings.Fields.RemoveById("owlyzl1x")
|
||||
return app.Save(settings)
|
||||
}
|
||||
|
||||
func saveCollectionFromJSON(app core.App, jsonData string) error {
|
||||
collection := &core.Collection{}
|
||||
if err := json.Unmarshal([]byte(jsonData), collection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return app.Save(collection)
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 573 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 54 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 664 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 846 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 453 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 432 KiB |
@@ -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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -173,7 +173,15 @@ func TrailFromActivity(activity pub.Activity, app core.App, actor *core.Record)
|
||||
}
|
||||
} else {
|
||||
// this trail exists already
|
||||
// ensure that it is fully synced to catch waypoint/summit log updates
|
||||
// keep searchable category metadata fresh from the update activity while
|
||||
// still requiring a full sync to catch waypoint/summit log updates.
|
||||
categoryMetadata, err := trailCategoryMetadataFromActivityObject(t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := applyTrailActivityCategoryMetadata(app, record, categoryMetadata); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
record.Set("needs_full_sync", true)
|
||||
err = app.Save(record)
|
||||
@@ -185,22 +193,22 @@ func TrailFromActivity(activity pub.Activity, app core.App, actor *core.Record)
|
||||
}
|
||||
|
||||
var distance, duration, elevation_gain, elevation_loss float64
|
||||
var diffculty, category string
|
||||
var diffculty string
|
||||
trailTags := []string{}
|
||||
tags, err := pub.ToItemCollection(t.Tag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
categoryMetadata := trailCategoryMetadataFromTags(tags)
|
||||
|
||||
for _, tag := range tags.Collection() {
|
||||
tagObj, err := pub.ToObject(tag)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
content := tagObj.Content.First().Value.String()
|
||||
switch tagObj.Name.First().Value.String() {
|
||||
case "category":
|
||||
category = content
|
||||
case "difficulty":
|
||||
diffculty = content
|
||||
case "elevation_gain":
|
||||
@@ -254,9 +262,8 @@ func TrailFromActivity(activity pub.Activity, app core.App, actor *core.Record)
|
||||
record.Set("author", actor.Id)
|
||||
record.Set("needs_full_sync", true)
|
||||
|
||||
categoryRecord, err := app.FindFirstRecordByData("categories", "name", category)
|
||||
if err == nil {
|
||||
record.Set("category", categoryRecord.Id)
|
||||
if err := applyTrailActivityCategoryMetadata(app, record, categoryMetadata); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if t.Attachment != nil {
|
||||
@@ -282,12 +289,12 @@ func TrailFromActivity(activity pub.Activity, app core.App, actor *core.Record)
|
||||
|
||||
if len(photoURLs) > 0 {
|
||||
photos := []*filesystem.File{}
|
||||
for i, purl := range photoURLs {
|
||||
for _, purl := range photoURLs {
|
||||
photo, err := filesystem.NewFileFromURL(context.Background(), purl)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
photos[i] = photo
|
||||
photos = append(photos, photo)
|
||||
}
|
||||
|
||||
record.Set("photos", photos)
|
||||
@@ -306,6 +313,80 @@ func TrailFromActivity(activity pub.Activity, app core.App, actor *core.Record)
|
||||
return record, app.Save(record)
|
||||
}
|
||||
|
||||
type trailActivityCategoryMetadata struct {
|
||||
category string
|
||||
subcategory string
|
||||
categorySet bool
|
||||
subcategorySet bool
|
||||
}
|
||||
|
||||
func trailCategoryMetadataFromActivityObject(object *pub.Object) (trailActivityCategoryMetadata, error) {
|
||||
if len(object.Tag) == 0 {
|
||||
return trailActivityCategoryMetadata{}, nil
|
||||
}
|
||||
|
||||
tags, err := pub.ToItemCollection(object.Tag)
|
||||
if err != nil {
|
||||
return trailActivityCategoryMetadata{}, err
|
||||
}
|
||||
|
||||
return trailCategoryMetadataFromTags(tags), nil
|
||||
}
|
||||
|
||||
func trailCategoryMetadataFromTags(tags *pub.ItemCollection) trailActivityCategoryMetadata {
|
||||
metadata := trailActivityCategoryMetadata{}
|
||||
for _, tag := range tags.Collection() {
|
||||
tagObj, err := pub.ToObject(tag)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch tagObj.Name.First().Value.String() {
|
||||
case "category":
|
||||
metadata.category = tagObj.Content.First().Value.String()
|
||||
metadata.categorySet = true
|
||||
case "subcategory":
|
||||
metadata.subcategory = tagObj.Content.First().Value.String()
|
||||
metadata.subcategorySet = true
|
||||
}
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
|
||||
func applyTrailActivityCategoryMetadata(app core.App, record *core.Record, metadata trailActivityCategoryMetadata) error {
|
||||
if metadata.categorySet {
|
||||
record.Set("federated_category_name", metadata.category)
|
||||
}
|
||||
if metadata.subcategorySet {
|
||||
record.Set("federated_subcategory_name", metadata.subcategory)
|
||||
} else if metadata.categorySet {
|
||||
record.Set("federated_subcategory_name", "")
|
||||
}
|
||||
|
||||
if !metadata.categorySet {
|
||||
return nil
|
||||
}
|
||||
|
||||
categoryRecord, subcategoryRecord, err := ResolveCategoryAndSubcategoryByNormalizedNames(app, metadata.category, metadata.subcategory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if categoryRecord != nil {
|
||||
record.Set("category", categoryRecord.Id)
|
||||
if subcategoryRecord != nil {
|
||||
record.Set("subcategory", subcategoryRecord.Id)
|
||||
} else {
|
||||
record.Set("subcategory", "")
|
||||
}
|
||||
} else {
|
||||
record.Set("category", "")
|
||||
record.Set("subcategory", "")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ObjectFromTrail(app core.App, trail *core.Record, mentions *pub.ItemCollection) (*pub.Object, error) {
|
||||
origin := os.Getenv("ORIGIN")
|
||||
if origin == "" {
|
||||
@@ -320,9 +401,9 @@ func ObjectFromTrail(app core.App, trail *core.Record, mentions *pub.ItemCollect
|
||||
if len(errs) > 0 {
|
||||
return nil, fmt.Errorf("failed to expand tags: %v", errs)
|
||||
}
|
||||
errs = app.ExpandRecord(trail, []string{"category"}, nil)
|
||||
errs = app.ExpandRecord(trail, []string{"category", "subcategory"}, nil)
|
||||
if len(errs) > 0 {
|
||||
return nil, fmt.Errorf("failed to expand category: %v", errs)
|
||||
return nil, fmt.Errorf("failed to expand category/subcategory: %v", errs)
|
||||
}
|
||||
|
||||
category := ""
|
||||
@@ -330,6 +411,11 @@ func ObjectFromTrail(app core.App, trail *core.Record, mentions *pub.ItemCollect
|
||||
if categoryRecord != nil {
|
||||
category = categoryRecord.GetString("name")
|
||||
}
|
||||
subcategory := ""
|
||||
subcategoryRecord := trail.ExpandedOne("subcategory")
|
||||
if subcategoryRecord != nil {
|
||||
subcategory = subcategoryRecord.GetString("name")
|
||||
}
|
||||
|
||||
tagRecords := trail.ExpandedAll("tags")
|
||||
|
||||
@@ -372,6 +458,14 @@ func ObjectFromTrail(app core.App, trail *core.Record, mentions *pub.ItemCollect
|
||||
}
|
||||
}
|
||||
|
||||
if subcategory != "" {
|
||||
tags.Append(pub.Object{
|
||||
Type: pub.NoteType,
|
||||
Name: pub.NaturalLanguageValuesNew(pub.LangRefValueNew(pub.NilLangRef, "subcategory")),
|
||||
Content: pub.NaturalLanguageValuesNew(pub.LangRefValueNew(pub.NilLangRef, subcategory)),
|
||||
})
|
||||
}
|
||||
|
||||
for _, v := range tagRecords {
|
||||
hashtag := pub.ObjectNew(pub.NoteType)
|
||||
hashtag.Name = pub.NaturalLanguageValuesNew(pub.LangRefValueNew(pub.NilLangRef, "tag"))
|
||||
|
||||
216
db/util/category.go
Normal file
216
db/util/category.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
type CategoryTranslation struct {
|
||||
Name string `json:"name"`
|
||||
ShortName string `json:"short_name"`
|
||||
}
|
||||
|
||||
func NormalizeCategoryName(name string) string {
|
||||
decomposed := norm.NFD.String(name)
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(len(decomposed))
|
||||
for _, r := range decomposed {
|
||||
if unicode.Is(unicode.Mn, r) {
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
|
||||
folded := cases.Fold().String(b.String())
|
||||
|
||||
b.Reset()
|
||||
b.Grow(len(folded))
|
||||
lastWasSeparator := false
|
||||
for _, r := range folded {
|
||||
if unicode.IsSpace(r) || r == '-' || r == '_' {
|
||||
if !lastWasSeparator {
|
||||
b.WriteByte(' ')
|
||||
lastWasSeparator = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
b.WriteRune(r)
|
||||
lastWasSeparator = false
|
||||
}
|
||||
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
func ParseCategoryTranslations(raw any) (map[string]CategoryTranslation, error) {
|
||||
if raw == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
switch value := raw.(type) {
|
||||
case map[string]CategoryTranslation:
|
||||
return value, nil
|
||||
case map[string]any:
|
||||
return normalizeCategoryTranslations(value)
|
||||
case types.JSONRaw:
|
||||
if len(value) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(value, &decoded); err != nil {
|
||||
return nil, fmt.Errorf("translations must be valid JSON: %w", err)
|
||||
}
|
||||
|
||||
return normalizeCategoryTranslations(decoded)
|
||||
case []byte:
|
||||
if len(value) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(value, &decoded); err != nil {
|
||||
return nil, fmt.Errorf("translations must be valid JSON: %w", err)
|
||||
}
|
||||
|
||||
return normalizeCategoryTranslations(decoded)
|
||||
case string:
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal([]byte(value), &decoded); err != nil {
|
||||
return nil, fmt.Errorf("translations must be valid JSON: %w", err)
|
||||
}
|
||||
|
||||
return normalizeCategoryTranslations(decoded)
|
||||
default:
|
||||
return nil, fmt.Errorf("translations must be a JSON object")
|
||||
}
|
||||
}
|
||||
|
||||
func ValidateCategoryRecord(app core.App, record *core.Record) error {
|
||||
name := record.GetString("name")
|
||||
normalizedName := NormalizeCategoryName(name)
|
||||
|
||||
allCategories, err := app.FindAllRecords("categories")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, existing := range allCategories {
|
||||
if existing.Id == record.Id {
|
||||
continue
|
||||
}
|
||||
|
||||
if NormalizeCategoryName(existing.GetString("name")) == normalizedName {
|
||||
return fmt.Errorf("category name %q collides with existing category %q after normalization", name, existing.GetString("name"))
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := ParseCategoryTranslations(record.Get("translations")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func FindCategoryByNormalizedName(app core.App, name string) (*core.Record, error) {
|
||||
normalizedName := NormalizeCategoryName(name)
|
||||
if normalizedName == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
allCategories, err := app.FindAllRecords("categories")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, category := range allCategories {
|
||||
if NormalizeCategoryName(category.GetString("name")) == normalizedName {
|
||||
return category, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func ValidateCategoryCollectionState(app core.App) error {
|
||||
allCategories, err := app.FindAllRecords("categories")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
seen := map[string]string{}
|
||||
for _, category := range allCategories {
|
||||
name := category.GetString("name")
|
||||
normalizedName := NormalizeCategoryName(name)
|
||||
if other, ok := seen[normalizedName]; ok {
|
||||
return fmt.Errorf("category normalization collision: %q conflicts with %q", name, other)
|
||||
}
|
||||
seen[normalizedName] = name
|
||||
|
||||
if _, err := ParseCategoryTranslations(category.Get("translations")); err != nil {
|
||||
return fmt.Errorf("invalid translations for category %q: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeCategoryTranslations(raw map[string]any) (map[string]CategoryTranslation, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
translations := make(map[string]CategoryTranslation, len(raw))
|
||||
for locale, entry := range raw {
|
||||
tag, err := language.Parse(locale)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("translations locale %q is invalid", locale)
|
||||
}
|
||||
base, _ := tag.Base()
|
||||
if locale != base.String() {
|
||||
return nil, fmt.Errorf("translations locale %q must use the base locale %q", locale, base.String())
|
||||
}
|
||||
if _, ok := supportedCategoryLocales[base.String()]; !ok {
|
||||
return nil, fmt.Errorf("translations locale %q is not supported", locale)
|
||||
}
|
||||
|
||||
entryMap, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("translations[%s] must be an object", locale)
|
||||
}
|
||||
|
||||
translation := CategoryTranslation{}
|
||||
if name, ok := entryMap["name"]; ok {
|
||||
nameString, ok := name.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("translations[%s].name must be a string", locale)
|
||||
}
|
||||
translation.Name = nameString
|
||||
}
|
||||
|
||||
if shortName, ok := entryMap["short_name"]; ok {
|
||||
shortNameString, ok := shortName.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("translations[%s].short_name must be a string", locale)
|
||||
}
|
||||
translation.ShortName = shortNameString
|
||||
}
|
||||
|
||||
translations[locale] = translation
|
||||
}
|
||||
|
||||
return translations, nil
|
||||
}
|
||||
289
db/util/category_defaults.go
Normal file
289
db/util/category_defaults.go
Normal file
@@ -0,0 +1,289 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
var supportedCategoryLocales = map[string]struct{}{
|
||||
"cs": {},
|
||||
"de": {},
|
||||
"en": {},
|
||||
"es": {},
|
||||
"eu": {},
|
||||
"fr": {},
|
||||
"hu": {},
|
||||
"it": {},
|
||||
"nl": {},
|
||||
"no": {},
|
||||
"pl": {},
|
||||
"pt": {},
|
||||
"ru": {},
|
||||
"zh": {},
|
||||
}
|
||||
|
||||
var defaultCategoryNames = []string{"Hiking", "Walking", "Running", "Climbing", "Skiing", "Canoeing", "Biking", "Other"}
|
||||
|
||||
func DefaultCategoryNames() []string {
|
||||
return append([]string(nil), defaultCategoryNames...)
|
||||
}
|
||||
|
||||
func SeedDefaultCategories(app core.App) error {
|
||||
collection, err := app.FindCollectionByNameOrId("categories")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
allCategories, err := app.FindAllRecords("categories")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existing := make(map[string]struct{}, len(allCategories))
|
||||
for _, category := range allCategories {
|
||||
existing[NormalizeCategoryName(category.GetString("name"))] = struct{}{}
|
||||
}
|
||||
|
||||
for _, name := range defaultCategoryNames {
|
||||
if _, ok := existing[NormalizeCategoryName(name)]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
record := core.NewRecord(collection)
|
||||
record.Set("name", name)
|
||||
if collection.Fields.GetByName("settings") != nil {
|
||||
record.Set("settings", defaultCategorySettings())
|
||||
}
|
||||
if err := app.Save(record); err != nil {
|
||||
return fmt.Errorf("failed to seed default category %q: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func defaultCategorySettings() map[string]any {
|
||||
return map[string]any{
|
||||
"wp_merge_enabled": true,
|
||||
"wp_merge_radius": 50,
|
||||
}
|
||||
}
|
||||
|
||||
var defaultCategoryTranslations = map[string]map[string]string{
|
||||
"Biking": {
|
||||
"cs": "Cyklistika",
|
||||
"de": "Radfahren",
|
||||
"en": "Biking",
|
||||
"es": "Ciclismo",
|
||||
"eu": "Bizikleta",
|
||||
"fr": "Vélo",
|
||||
"hu": "Biking",
|
||||
"it": "Ciclismo",
|
||||
"nl": "Fietsen",
|
||||
"no": "Sykling",
|
||||
"pl": "Rower",
|
||||
"pt": "Ciclismo",
|
||||
"ru": "Велоспорт",
|
||||
"zh": "骑行",
|
||||
},
|
||||
"Canoeing": {
|
||||
"cs": "Kanoistika",
|
||||
"de": "Kanufahren",
|
||||
"en": "Canoeing",
|
||||
"es": "Remo",
|
||||
"eu": "Kanoa",
|
||||
"fr": "Canoë",
|
||||
"hu": "Canoeing",
|
||||
"it": "Canoa",
|
||||
"nl": "Kanoën",
|
||||
"no": "Padling",
|
||||
"pl": "Kajak",
|
||||
"pt": "Canoagem",
|
||||
"ru": "Каякинг",
|
||||
"zh": "划艇",
|
||||
},
|
||||
"Climbing": {
|
||||
"cs": "Horolezectví",
|
||||
"de": "Klettern",
|
||||
"en": "Climbing",
|
||||
"es": "Escalada",
|
||||
"eu": "Eskalada",
|
||||
"fr": "Escalade",
|
||||
"hu": "Climbing",
|
||||
"it": "Arrampicata",
|
||||
"nl": "Klimmen",
|
||||
"no": "Klatring",
|
||||
"pl": "Wspinaczka",
|
||||
"pt": "Escalada",
|
||||
"ru": "Скалолазание",
|
||||
"zh": "攀岩",
|
||||
},
|
||||
"Hiking": {
|
||||
"cs": "Turistika",
|
||||
"de": "Wandern",
|
||||
"en": "Hiking",
|
||||
"es": "Senderismo",
|
||||
"eu": "Mendi-ibilaldia",
|
||||
"fr": "Randonnée",
|
||||
"hu": "Hiking",
|
||||
"it": "Escursionismo",
|
||||
"nl": "Hiken",
|
||||
"no": "Vandring",
|
||||
"pl": "Wędrówka",
|
||||
"pt": "Montanhismo",
|
||||
"ru": "Пеший туризм",
|
||||
"zh": "徒步",
|
||||
},
|
||||
"Other": {
|
||||
"cs": "Ostatní",
|
||||
"de": "Sonstiges",
|
||||
"en": "Other",
|
||||
"es": "Otros",
|
||||
"eu": "Bestelakoak",
|
||||
"fr": "Autre",
|
||||
"hu": "Egyéb",
|
||||
"it": "Altro",
|
||||
"nl": "Overig",
|
||||
"no": "Annet",
|
||||
"pl": "Inne",
|
||||
"pt": "Outros",
|
||||
"ru": "Другое",
|
||||
"zh": "其他",
|
||||
},
|
||||
"Running": {
|
||||
"cs": "Běh",
|
||||
"de": "Laufen",
|
||||
"en": "Running",
|
||||
"es": "Carrera",
|
||||
"eu": "Korrika",
|
||||
"fr": "Course à pied",
|
||||
"hu": "Futás",
|
||||
"it": "Corsa",
|
||||
"nl": "Hardlopen",
|
||||
"no": "Løping",
|
||||
"pl": "Bieganie",
|
||||
"pt": "Corrida",
|
||||
"ru": "Бег",
|
||||
"zh": "跑步",
|
||||
},
|
||||
"Skiing": {
|
||||
"de": "Skifahren",
|
||||
"no": "Skisport",
|
||||
},
|
||||
"Walking": {
|
||||
"cs": "Chůze",
|
||||
"de": "Spazieren",
|
||||
"en": "Walking",
|
||||
"es": "Paseo",
|
||||
"eu": "Oinez",
|
||||
"fr": "Marche",
|
||||
"hu": "Walking",
|
||||
"it": "Camminare",
|
||||
"nl": "Wandelen",
|
||||
"no": "Gåtur",
|
||||
"pl": "Spacer",
|
||||
"pt": "Caminhada",
|
||||
"ru": "Прогулка",
|
||||
"zh": "步行",
|
||||
},
|
||||
}
|
||||
|
||||
var defaultCategoryIcons = map[string]string{
|
||||
"Biking": "person-biking",
|
||||
"Canoeing": "sailboat",
|
||||
"Climbing": "mountain",
|
||||
"Hiking": "person-hiking",
|
||||
"Other": "shapes",
|
||||
"Running": "person-running",
|
||||
"Skiing": "person-skiing-nordic",
|
||||
"Walking": "person-walking",
|
||||
}
|
||||
|
||||
func PrepopulateDefaultCategoryTranslations(app core.App) error {
|
||||
allCategories, err := app.FindAllRecords("categories")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, category := range allCategories {
|
||||
staticTranslations, ok := defaultCategoryTranslations[category.GetString("name")]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
currentTranslations, err := ParseCategoryTranslations(category.Get("translations"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid existing translations for category %q: %w", category.GetString("name"), err)
|
||||
}
|
||||
mergedTranslations, changed := mergeDefaultCategoryTranslations(staticTranslations, currentTranslations)
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
|
||||
category.Set("translations", mergedTranslations)
|
||||
if err := app.Save(category); err != nil {
|
||||
return fmt.Errorf("failed to prepopulate translations for category %q: %w", category.GetString("name"), err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func PrepopulateDefaultCategoryIcons(app core.App) error {
|
||||
collection, err := app.FindCollectionByNameOrId("categories")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if collection.Fields.GetByName("icon") == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
allCategories, err := app.FindAllRecords("categories")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, category := range allCategories {
|
||||
categoryName := category.GetString("name")
|
||||
defaultIcon, ok := defaultCategoryIcons[categoryName]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if category.GetString("icon") != "" {
|
||||
continue
|
||||
}
|
||||
|
||||
category.Set("icon", defaultIcon)
|
||||
if err := app.Save(category); err != nil {
|
||||
return fmt.Errorf("failed to prepopulate icon for category %q: %w", category.GetString("name"), err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeDefaultCategoryTranslations(staticTranslations map[string]string, currentTranslations map[string]CategoryTranslation) (map[string]CategoryTranslation, bool) {
|
||||
if currentTranslations == nil {
|
||||
currentTranslations = map[string]CategoryTranslation{}
|
||||
}
|
||||
|
||||
changed := false
|
||||
for locale, name := range staticTranslations {
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
translation := currentTranslations[locale]
|
||||
if translation.Name != "" {
|
||||
continue
|
||||
}
|
||||
|
||||
translation.Name = name
|
||||
currentTranslations[locale] = translation
|
||||
changed = true
|
||||
}
|
||||
|
||||
return currentTranslations, changed
|
||||
}
|
||||
241
db/util/category_preference.go
Normal file
241
db/util/category_preference.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
const DefaultPriorityCategoryName = "Hiking"
|
||||
|
||||
func ValidateUserCategoryPreferenceRequest(priorityExplicit bool) error {
|
||||
if priorityExplicit {
|
||||
return fmt.Errorf("category preference priority can only be changed through the reorder endpoint")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateUserSubcategoryPreferenceRequest(priorityExplicit bool) error {
|
||||
if priorityExplicit {
|
||||
return fmt.Errorf("subcategory preference priority can only be changed through the reorder endpoint")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func EnsureUserCategoryPriority(app core.App, userID, categoryID string) error {
|
||||
if userID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
prioritized, err := app.FindRecordsByFilter(
|
||||
"user_category_preferences",
|
||||
"user = {:user} && priority > 0",
|
||||
"",
|
||||
1,
|
||||
0,
|
||||
map[string]any{"user": userID},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(prioritized) > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if categoryID == "" {
|
||||
category, err := FindCategoryByNormalizedName(app, DefaultPriorityCategoryName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if category == nil {
|
||||
return nil
|
||||
}
|
||||
categoryID = category.Id
|
||||
}
|
||||
|
||||
collection, err := app.FindCollectionByNameOrId("user_category_preferences")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existing, err := app.FindRecordsByFilter(
|
||||
"user_category_preferences",
|
||||
"user = {:user} && category = {:category}",
|
||||
"",
|
||||
1,
|
||||
0,
|
||||
map[string]any{"user": userID, "category": categoryID},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var record *core.Record
|
||||
if len(existing) > 0 {
|
||||
record = existing[0]
|
||||
} else {
|
||||
record = core.NewRecord(collection)
|
||||
record.Set("user", userID)
|
||||
record.Set("category", categoryID)
|
||||
record.Set("visible", true)
|
||||
}
|
||||
|
||||
record.Set("priority", 1)
|
||||
return app.SaveNoValidate(record)
|
||||
}
|
||||
|
||||
func ReorderUserCategoryPreferences(app core.App, userID string, categoryIDs []string) error {
|
||||
if userID == "" {
|
||||
return fmt.Errorf("authentication required")
|
||||
}
|
||||
|
||||
categories, err := app.FindAllRecords("categories")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(categoryIDs) != len(categories) {
|
||||
return fmt.Errorf("reorder request must include all categories")
|
||||
}
|
||||
|
||||
validCategories := make(map[string]struct{}, len(categories))
|
||||
for _, category := range categories {
|
||||
validCategories[category.Id] = struct{}{}
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(categoryIDs))
|
||||
for _, categoryID := range categoryIDs {
|
||||
if _, ok := validCategories[categoryID]; !ok {
|
||||
return fmt.Errorf("unknown category %q", categoryID)
|
||||
}
|
||||
if _, ok := seen[categoryID]; ok {
|
||||
return fmt.Errorf("duplicate category %q", categoryID)
|
||||
}
|
||||
seen[categoryID] = struct{}{}
|
||||
}
|
||||
|
||||
return app.RunInTransaction(func(txApp core.App) error {
|
||||
collection, err := txApp.FindCollectionByNameOrId("user_category_preferences")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existing, err := txApp.FindRecordsByFilter(
|
||||
"user_category_preferences",
|
||||
"user = {:user}",
|
||||
"",
|
||||
0,
|
||||
0,
|
||||
map[string]any{"user": userID},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
byCategory := make(map[string]*core.Record, len(existing))
|
||||
for _, record := range existing {
|
||||
byCategory[record.GetString("category")] = record
|
||||
}
|
||||
|
||||
for index, categoryID := range categoryIDs {
|
||||
record := byCategory[categoryID]
|
||||
if record == nil {
|
||||
record = core.NewRecord(collection)
|
||||
record.Set("user", userID)
|
||||
record.Set("category", categoryID)
|
||||
record.Set("visible", true)
|
||||
}
|
||||
|
||||
record.Set("priority", index+1)
|
||||
if err := txApp.SaveNoValidate(record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func ReorderUserSubcategoryPreferences(app core.App, userID, categoryID string, subcategoryIDs []string) error {
|
||||
if userID == "" {
|
||||
return fmt.Errorf("authentication required")
|
||||
}
|
||||
if categoryID == "" {
|
||||
return fmt.Errorf("category is required")
|
||||
}
|
||||
|
||||
subcategories, err := app.FindRecordsByFilter(
|
||||
"subcategories",
|
||||
"category = {:category}",
|
||||
"",
|
||||
0,
|
||||
0,
|
||||
map[string]any{"category": categoryID},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(subcategoryIDs) != len(subcategories) {
|
||||
return fmt.Errorf("reorder request must include all subcategories for the category")
|
||||
}
|
||||
|
||||
validSubcategories := make(map[string]struct{}, len(subcategories))
|
||||
for _, subcategory := range subcategories {
|
||||
validSubcategories[subcategory.Id] = struct{}{}
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(subcategoryIDs))
|
||||
for _, subcategoryID := range subcategoryIDs {
|
||||
if _, ok := validSubcategories[subcategoryID]; !ok {
|
||||
return fmt.Errorf("unknown subcategory %q", subcategoryID)
|
||||
}
|
||||
if _, ok := seen[subcategoryID]; ok {
|
||||
return fmt.Errorf("duplicate subcategory %q", subcategoryID)
|
||||
}
|
||||
seen[subcategoryID] = struct{}{}
|
||||
}
|
||||
|
||||
return app.RunInTransaction(func(txApp core.App) error {
|
||||
collection, err := txApp.FindCollectionByNameOrId("user_subcategory_preferences")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existing, err := txApp.FindRecordsByFilter(
|
||||
"user_subcategory_preferences",
|
||||
"user = {:user}",
|
||||
"",
|
||||
0,
|
||||
0,
|
||||
map[string]any{"user": userID},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bySubcategory := make(map[string]*core.Record, len(existing))
|
||||
for _, record := range existing {
|
||||
bySubcategory[record.GetString("subcategory")] = record
|
||||
}
|
||||
|
||||
for index, subcategoryID := range subcategoryIDs {
|
||||
record := bySubcategory[subcategoryID]
|
||||
if record == nil {
|
||||
record = core.NewRecord(collection)
|
||||
record.Set("user", userID)
|
||||
record.Set("subcategory", subcategoryID)
|
||||
record.Set("visible", true)
|
||||
}
|
||||
|
||||
record.Set("priority", index+1)
|
||||
if err := txApp.SaveNoValidate(record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
1301
db/util/category_test.go
Normal file
1301
db/util/category_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -33,10 +33,24 @@ func documentFromTrailRecord(r *core.Record, author *core.Record, includeShares
|
||||
tags[i] = v.GetString("name")
|
||||
}
|
||||
|
||||
categoryID := r.GetString("category")
|
||||
var categoryIDValue any
|
||||
if categoryID != "" {
|
||||
categoryIDValue = categoryID
|
||||
}
|
||||
|
||||
subcategoryID := r.GetString("subcategory")
|
||||
var subcategoryIDValue any
|
||||
if subcategoryID != "" {
|
||||
subcategoryIDValue = subcategoryID
|
||||
}
|
||||
|
||||
category := ""
|
||||
categoryIcon := ""
|
||||
trailCategory := r.ExpandedOne("category")
|
||||
if trailCategory != nil {
|
||||
category = trailCategory.GetString("name")
|
||||
categoryIcon = trailCategory.GetString("icon")
|
||||
}
|
||||
|
||||
bounds := getStoredBounds(r)
|
||||
@@ -52,34 +66,40 @@ func documentFromTrailRecord(r *core.Record, author *core.Record, includeShares
|
||||
}
|
||||
|
||||
document := map[string]any{
|
||||
"id": r.Id,
|
||||
"author": author.Id,
|
||||
"author_name": author.GetString("preferred_username"),
|
||||
"author_avatar": author.GetString("icon"),
|
||||
"name": r.GetString("name"),
|
||||
"description": r.GetString("description"),
|
||||
"location": r.GetString("location"),
|
||||
"distance": r.GetFloat("distance"),
|
||||
"elevation_gain": r.GetFloat("elevation_gain"),
|
||||
"elevation_loss": r.GetFloat("elevation_loss"),
|
||||
"duration": r.GetFloat("duration"),
|
||||
"difficulty": difficultyToNumber(r.GetString("difficulty")),
|
||||
"category": category,
|
||||
"completed": r.GetBool("completed"),
|
||||
"date": r.GetDateTime("date").Time().Unix(),
|
||||
"created": r.GetDateTime("created").Time().Unix(),
|
||||
"public": r.GetBool("public"),
|
||||
"thumbnail": thumbnail,
|
||||
"gpx": r.GetString("gpx"),
|
||||
"tags": tags,
|
||||
"polyline": r.GetString("polyline"),
|
||||
"domain": domain,
|
||||
"iri": r.GetString("iri"),
|
||||
"min_lat": bounds[0],
|
||||
"max_lat": bounds[1],
|
||||
"min_lon": bounds[2],
|
||||
"max_lon": bounds[3],
|
||||
"bounding_box_diagonal": diagonal,
|
||||
"id": r.Id,
|
||||
"author": author.Id,
|
||||
"author_name": author.GetString("preferred_username"),
|
||||
"author_avatar": author.GetString("icon"),
|
||||
"name": r.GetString("name"),
|
||||
"description": r.GetString("description"),
|
||||
"location": r.GetString("location"),
|
||||
"distance": r.GetFloat("distance"),
|
||||
"elevation_gain": r.GetFloat("elevation_gain"),
|
||||
"elevation_loss": r.GetFloat("elevation_loss"),
|
||||
"duration": r.GetFloat("duration"),
|
||||
"difficulty": difficultyToNumber(r.GetString("difficulty")),
|
||||
"category": category,
|
||||
"category_id": categoryIDValue,
|
||||
"category_icon": categoryIcon,
|
||||
"subcategory_id": subcategoryIDValue,
|
||||
"is_federated": !author.GetBool("is_local"),
|
||||
"federated_category_name": r.GetString("federated_category_name"),
|
||||
"federated_subcategory_name": r.GetString("federated_subcategory_name"),
|
||||
"completed": r.GetBool("completed"),
|
||||
"date": r.GetDateTime("date").Time().Unix(),
|
||||
"created": r.GetDateTime("created").Time().Unix(),
|
||||
"public": r.GetBool("public"),
|
||||
"thumbnail": thumbnail,
|
||||
"gpx": r.GetString("gpx"),
|
||||
"tags": tags,
|
||||
"polyline": r.GetString("polyline"),
|
||||
"domain": domain,
|
||||
"iri": r.GetString("iri"),
|
||||
"min_lat": bounds[0],
|
||||
"max_lat": bounds[1],
|
||||
"min_lon": bounds[2],
|
||||
"max_lon": bounds[3],
|
||||
"bounding_box_diagonal": diagonal,
|
||||
"_geo": map[string]float64{
|
||||
"lat": r.GetFloat("lat"),
|
||||
"lng": r.GetFloat("lon"),
|
||||
|
||||
230
db/util/subcategory.go
Normal file
230
db/util/subcategory.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
func ValidateSubcategoryRecord(app core.App, record *core.Record) error {
|
||||
parentCategory := record.GetString("category")
|
||||
if parentCategory == "" {
|
||||
return fmt.Errorf("subcategory category is required")
|
||||
}
|
||||
|
||||
name := record.GetString("name")
|
||||
normalizedName := NormalizeCategoryName(name)
|
||||
|
||||
allSubcategories, err := app.FindAllRecords("subcategories")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, existing := range allSubcategories {
|
||||
if existing.Id == record.Id || existing.GetString("category") != parentCategory {
|
||||
continue
|
||||
}
|
||||
|
||||
if NormalizeCategoryName(existing.GetString("name")) == normalizedName {
|
||||
return fmt.Errorf("subcategory name %q collides with existing subcategory %q in the same category after normalization", name, existing.GetString("name"))
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := ParseCategoryTranslations(record.Get("translations")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateTrailSubcategoryRecord(app core.App, record *core.Record, subcategoryExplicit bool) error {
|
||||
subcategoryID := record.GetString("subcategory")
|
||||
if subcategoryID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
categoryID := record.GetString("category")
|
||||
if categoryID == "" {
|
||||
if !subcategoryExplicit {
|
||||
record.Set("subcategory", "")
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("trail subcategory requires a category")
|
||||
}
|
||||
|
||||
subcategory, err := app.FindRecordById("subcategories", subcategoryID)
|
||||
if err != nil {
|
||||
if !subcategoryExplicit {
|
||||
record.Set("subcategory", "")
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("trail subcategory %q does not exist: %w", subcategoryID, err)
|
||||
}
|
||||
|
||||
parentCategory := subcategory.GetString("category")
|
||||
if parentCategory != categoryID {
|
||||
if !subcategoryExplicit {
|
||||
record.Set("subcategory", "")
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("trail subcategory %q belongs to category %q, not %q", subcategoryID, parentCategory, categoryID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func FindSubcategoryByNormalizedName(app core.App, categoryID string, name string) (*core.Record, error) {
|
||||
normalizedName := NormalizeCategoryName(name)
|
||||
if categoryID == "" || normalizedName == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
subcategories, err := app.FindRecordsByFilter(
|
||||
"subcategories",
|
||||
"category = {:category}",
|
||||
"",
|
||||
0,
|
||||
0,
|
||||
map[string]any{"category": categoryID},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, subcategory := range subcategories {
|
||||
if NormalizeCategoryName(subcategory.GetString("name")) == normalizedName {
|
||||
return subcategory, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func ResolveCategoryAndSubcategoryByNormalizedNames(app core.App, categoryName string, subcategoryName string) (*core.Record, *core.Record, error) {
|
||||
category, err := FindCategoryByNormalizedName(app, categoryName)
|
||||
if err != nil || category == nil {
|
||||
return category, nil, err
|
||||
}
|
||||
|
||||
subcategory, err := FindSubcategoryByNormalizedName(app, category.Id, subcategoryName)
|
||||
if err != nil {
|
||||
return category, nil, err
|
||||
}
|
||||
|
||||
return category, subcategory, nil
|
||||
}
|
||||
|
||||
func BackfillRemoteTrailCategory(app core.App, category *core.Record) error {
|
||||
if category == nil || category.Id == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
subcategoriesByName, err := normalizedSubcategoriesByName(app, category.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
trails, err := app.FindRecordsByFilter(
|
||||
"trails",
|
||||
"federated_category_name != '' && category = ''",
|
||||
"",
|
||||
0,
|
||||
0,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
normalizedCategoryName := NormalizeCategoryName(category.GetString("name"))
|
||||
for _, trail := range trails {
|
||||
if NormalizeCategoryName(trail.GetString("federated_category_name")) != normalizedCategoryName {
|
||||
continue
|
||||
}
|
||||
|
||||
trail.Set("category", category.Id)
|
||||
if subcategory, ok := subcategoriesByName[NormalizeCategoryName(trail.GetString("federated_subcategory_name"))]; ok {
|
||||
trail.Set("subcategory", subcategory.Id)
|
||||
}
|
||||
|
||||
if err := app.Save(trail); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func BackfillRemoteTrailSubcategory(app core.App, subcategory *core.Record) error {
|
||||
if subcategory == nil || subcategory.Id == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
category, err := app.FindRecordById("categories", subcategory.GetString("category"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
trails, err := app.FindRecordsByFilter(
|
||||
"trails",
|
||||
"federated_subcategory_name != '' && subcategory = '' && (category = {:category} || category = '')",
|
||||
"",
|
||||
0,
|
||||
0,
|
||||
map[string]any{"category": category.Id},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
normalizedCategoryName := NormalizeCategoryName(category.GetString("name"))
|
||||
normalizedSubcategoryName := NormalizeCategoryName(subcategory.GetString("name"))
|
||||
for _, trail := range trails {
|
||||
categoryID := trail.GetString("category")
|
||||
if categoryID == "" {
|
||||
if NormalizeCategoryName(trail.GetString("federated_category_name")) != normalizedCategoryName {
|
||||
continue
|
||||
}
|
||||
trail.Set("category", category.Id)
|
||||
}
|
||||
|
||||
if NormalizeCategoryName(trail.GetString("federated_subcategory_name")) != normalizedSubcategoryName {
|
||||
continue
|
||||
}
|
||||
|
||||
trail.Set("subcategory", subcategory.Id)
|
||||
if err := app.Save(trail); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizedSubcategoriesByName(app core.App, categoryID string) (map[string]*core.Record, error) {
|
||||
subcategories, err := app.FindRecordsByFilter(
|
||||
"subcategories",
|
||||
"category = {:category}",
|
||||
"",
|
||||
0,
|
||||
0,
|
||||
map[string]any{"category": categoryID},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
byName := make(map[string]*core.Record, len(subcategories))
|
||||
for _, subcategory := range subcategories {
|
||||
normalizedName := NormalizeCategoryName(subcategory.GetString("name"))
|
||||
if normalizedName == "" {
|
||||
continue
|
||||
}
|
||||
byName[normalizedName] = subcategory
|
||||
}
|
||||
|
||||
return byName, nil
|
||||
}
|
||||
284
db/util/subcategory_defaults.go
Normal file
284
db/util/subcategory_defaults.go
Normal file
@@ -0,0 +1,284 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
type defaultSubcategorySeed struct {
|
||||
parentCategory string
|
||||
name string
|
||||
shortName string
|
||||
badgeIcon string
|
||||
translations map[string]CategoryTranslation
|
||||
aliases []string
|
||||
}
|
||||
|
||||
var defaultSubcategories = []defaultSubcategorySeed{
|
||||
{parentCategory: "Biking", name: "MTB", shortName: "MTB", badgeIcon: "mountain"},
|
||||
{parentCategory: "Biking", name: "Gravel", shortName: "GRVL"},
|
||||
{
|
||||
parentCategory: "Biking",
|
||||
name: "Touring",
|
||||
shortName: "TOUR",
|
||||
aliases: []string{"Touring Bike", "City Bike"},
|
||||
translations: subcategoryTranslations("Touring", "Tourenrad", "TOUR"),
|
||||
},
|
||||
{
|
||||
parentCategory: "Biking",
|
||||
name: "Road",
|
||||
shortName: "ROAD",
|
||||
badgeIcon: "grip-lines-vertical",
|
||||
translations: subcategoryTranslations("Road", "Rennrad", "ROAD"),
|
||||
},
|
||||
{parentCategory: "Biking", name: "E-Bike", shortName: "EBIKE", badgeIcon: "bolt"},
|
||||
{
|
||||
parentCategory: "Hiking",
|
||||
name: "Winter",
|
||||
shortName: "WINT",
|
||||
badgeIcon: "snowflake",
|
||||
aliases: []string{"Winter Hiking"},
|
||||
translations: map[string]CategoryTranslation{
|
||||
"de": {Name: "Winterwandern", ShortName: "WINT"},
|
||||
"en": {Name: "Winter", ShortName: "WINT"},
|
||||
},
|
||||
},
|
||||
{
|
||||
parentCategory: "Hiking",
|
||||
name: "Alpine",
|
||||
shortName: "ALP",
|
||||
badgeIcon: "mountain",
|
||||
aliases: []string{"Alpine Hiking"},
|
||||
translations: subcategoryTranslations("Alpine", "Bergwandern", "ALP"),
|
||||
},
|
||||
{
|
||||
parentCategory: "Hiking",
|
||||
name: "Long-distance",
|
||||
shortName: "LONG",
|
||||
aliases: []string{"Long-distance Hiking"},
|
||||
translations: subcategoryTranslations("Long-distance", "Fernwandern", "LONG"),
|
||||
},
|
||||
{
|
||||
parentCategory: "Hiking",
|
||||
name: "Snowshoeing",
|
||||
shortName: "SNOW",
|
||||
badgeIcon: "snowflake",
|
||||
translations: subcategoryTranslations("Snowshoeing", "Schneeschuhwandern", "SNOW"),
|
||||
},
|
||||
{
|
||||
parentCategory: "Hiking",
|
||||
name: "Family",
|
||||
shortName: "FAM",
|
||||
badgeIcon: "child",
|
||||
aliases: []string{"Family Hiking"},
|
||||
translations: subcategoryTranslations("Family", "Familienwandern", "FAM"),
|
||||
},
|
||||
{
|
||||
parentCategory: "Hiking",
|
||||
name: "Pilgrimage",
|
||||
shortName: "PILG",
|
||||
badgeIcon: "cross",
|
||||
translations: subcategoryTranslations("Pilgrimage", "Pilgern", "PILG"),
|
||||
},
|
||||
{
|
||||
parentCategory: "Running",
|
||||
name: "Trail",
|
||||
shortName: "TRAIL",
|
||||
aliases: []string{"Trail Running"},
|
||||
translations: subcategoryTranslations("Trail", "Trailrunning", "TRAIL"),
|
||||
},
|
||||
{
|
||||
parentCategory: "Running",
|
||||
name: "Road",
|
||||
shortName: "ROAD",
|
||||
badgeIcon: "grip-lines-vertical",
|
||||
aliases: []string{"Road Running"},
|
||||
translations: subcategoryTranslations("Road", "Straßenlauf", "ROAD"),
|
||||
},
|
||||
{
|
||||
parentCategory: "Skiing",
|
||||
name: "Cross-country",
|
||||
shortName: "NORD",
|
||||
aliases: []string{"Cross-country Skiing"},
|
||||
translations: subcategoryTranslations("Cross-country", "Langlauf", "NORD"),
|
||||
},
|
||||
{
|
||||
parentCategory: "Skiing",
|
||||
name: "Skating",
|
||||
shortName: "SKATE",
|
||||
translations: subcategoryTranslations("Skating", "Skating", "SKATE"),
|
||||
},
|
||||
{
|
||||
parentCategory: "Skiing",
|
||||
name: "Backcountry",
|
||||
shortName: "BACK",
|
||||
aliases: []string{"Backcountry Skiing"},
|
||||
translations: subcategoryTranslations("Backcountry", "Skitour", "BACK"),
|
||||
},
|
||||
}
|
||||
|
||||
func subcategoryTranslations(en string, de string, shortName string) map[string]CategoryTranslation {
|
||||
return map[string]CategoryTranslation{
|
||||
"de": {Name: de, ShortName: shortName},
|
||||
"en": {Name: en, ShortName: shortName},
|
||||
}
|
||||
}
|
||||
|
||||
func SeedDefaultSubcategories(app core.App) error {
|
||||
subcategoriesCollection, err := app.FindCollectionByNameOrId("subcategories")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, seed := range defaultSubcategories {
|
||||
categories, err := app.FindRecordsByFilter(
|
||||
"categories",
|
||||
"name = {:name}",
|
||||
"",
|
||||
1,
|
||||
0,
|
||||
map[string]any{"name": seed.parentCategory},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(categories) == 0 {
|
||||
continue
|
||||
}
|
||||
category := categories[0]
|
||||
|
||||
existing, err := app.FindRecordsByFilter(
|
||||
"subcategories",
|
||||
"category = {:category}",
|
||||
"",
|
||||
0,
|
||||
0,
|
||||
map[string]any{"category": category.Id},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existingRecord := findDefaultSubcategory(existing, seed.name, seed.aliases); existingRecord != nil {
|
||||
changed, err := applyDefaultSubcategorySeed(existingRecord, seed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed {
|
||||
if err := app.Save(existingRecord); err != nil {
|
||||
return fmt.Errorf("failed to update seeded subcategory %q: %w", seed.name, err)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
record := core.NewRecord(subcategoriesCollection)
|
||||
record.Set("category", category.Id)
|
||||
record.Set("name", seed.name)
|
||||
record.Set("short_name", seed.shortName)
|
||||
if seed.badgeIcon != "" {
|
||||
record.Set("badge_icon", seed.badgeIcon)
|
||||
}
|
||||
if len(seed.translations) > 0 {
|
||||
record.Set("translations", seed.translations)
|
||||
}
|
||||
if err := app.Save(record); err != nil {
|
||||
return fmt.Errorf("failed to seed subcategory %q: %w", seed.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyDefaultSubcategorySeed(record *core.Record, seed defaultSubcategorySeed) (bool, error) {
|
||||
changed := false
|
||||
|
||||
if isDefaultSubcategoryAlias(record.GetString("name"), seed.aliases) {
|
||||
record.Set("name", seed.name)
|
||||
changed = true
|
||||
}
|
||||
|
||||
if record.GetString("short_name") == "" && seed.shortName != "" {
|
||||
record.Set("short_name", seed.shortName)
|
||||
changed = true
|
||||
}
|
||||
|
||||
if record.GetString("badge_icon") == "" && seed.badgeIcon != "" {
|
||||
record.Set("badge_icon", seed.badgeIcon)
|
||||
changed = true
|
||||
}
|
||||
|
||||
if len(seed.translations) > 0 {
|
||||
currentTranslations, err := ParseCategoryTranslations(record.Get("translations"))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("invalid existing translations for subcategory %q: %w", record.GetString("name"), err)
|
||||
}
|
||||
|
||||
mergedTranslations, translationsChanged := mergeDefaultSubcategoryTranslations(seed.translations, currentTranslations)
|
||||
if translationsChanged {
|
||||
record.Set("translations", mergedTranslations)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func isDefaultSubcategoryAlias(name string, aliases []string) bool {
|
||||
normalizedName := NormalizeCategoryName(name)
|
||||
for _, alias := range aliases {
|
||||
if normalizedName == NormalizeCategoryName(alias) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func mergeDefaultSubcategoryTranslations(staticTranslations map[string]CategoryTranslation, currentTranslations map[string]CategoryTranslation) (map[string]CategoryTranslation, bool) {
|
||||
if currentTranslations == nil {
|
||||
currentTranslations = map[string]CategoryTranslation{}
|
||||
}
|
||||
|
||||
changed := false
|
||||
for locale, staticTranslation := range staticTranslations {
|
||||
translation := currentTranslations[locale]
|
||||
localeChanged := false
|
||||
if translation.Name == "" && staticTranslation.Name != "" {
|
||||
translation.Name = staticTranslation.Name
|
||||
localeChanged = true
|
||||
}
|
||||
if translation.ShortName == "" && staticTranslation.ShortName != "" {
|
||||
translation.ShortName = staticTranslation.ShortName
|
||||
localeChanged = true
|
||||
}
|
||||
if localeChanged {
|
||||
changed = true
|
||||
currentTranslations[locale] = translation
|
||||
}
|
||||
}
|
||||
|
||||
return currentTranslations, changed
|
||||
}
|
||||
|
||||
func findDefaultSubcategory(records []*core.Record, name string, aliases []string) *core.Record {
|
||||
normalizedName := NormalizeCategoryName(name)
|
||||
for _, record := range records {
|
||||
if NormalizeCategoryName(record.GetString("name")) == normalizedName {
|
||||
return record
|
||||
}
|
||||
}
|
||||
|
||||
normalizedAliases := map[string]struct{}{}
|
||||
for _, alias := range aliases {
|
||||
normalizedAliases[NormalizeCategoryName(alias)] = struct{}{}
|
||||
}
|
||||
|
||||
for _, record := range records {
|
||||
if _, ok := normalizedAliases[NormalizeCategoryName(record.GetString("name"))]; ok {
|
||||
return record
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user