Merge branch 'main' into feat/pwa-favicon-support
2
.github/workflows/go.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
TINYGO_VERSION: '0.39.0'
|
TINYGO_VERSION: '0.39.0'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: '1.25'
|
go-version: '1.25'
|
||||||
|
|||||||
6
.github/workflows/release.yaml
vendored
@@ -18,7 +18,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set Version Output
|
- name: Set Version Output
|
||||||
id: get_version
|
id: get_version
|
||||||
@@ -37,7 +37,7 @@ jobs:
|
|||||||
needs: publish
|
needs: publish
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
@@ -77,7 +77,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
TINYGO_VERSION: '0.39.0'
|
TINYGO_VERSION: '0.39.0'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
|
|||||||
2
.github/workflows/release_dev.yaml
vendored
@@ -12,7 +12,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/release_request.yaml
vendored
@@ -15,7 +15,7 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
|
|||||||
2
.github/workflows/web.yaml
vendored
@@ -16,7 +16,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -682,17 +682,21 @@ func processCreateOrUpdateSummitLogActivity(activity pub.Activity, app core.App,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(photoURLs) > 0 {
|
if len(photoURLs) == 0 {
|
||||||
photos := make([]*filesystem.File, len(photoURLs))
|
record.Set("photos", []*filesystem.File{})
|
||||||
for i, purl := range photoURLs {
|
} else {
|
||||||
|
photos := []*filesystem.File{}
|
||||||
|
for _, purl := range photoURLs {
|
||||||
photo, err := filesystem.NewFileFromURL(context.Background(), purl)
|
photo, err := filesystem.NewFileFromURL(context.Background(), purl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
photos[i] = photo
|
photos = append(photos, photo)
|
||||||
}
|
}
|
||||||
|
|
||||||
record.Set("photos", photos)
|
if len(photos) > 0 {
|
||||||
|
record.Set("photos", photos)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if gpxURL != "" {
|
if gpxURL != "" {
|
||||||
|
|||||||
12
db/go.mod
@@ -3,7 +3,7 @@ module pocketbase
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/doyensec/safeurl v0.2.3
|
github.com/doyensec/safeurl v0.2.5
|
||||||
github.com/extism/go-sdk v1.7.1
|
github.com/extism/go-sdk v1.7.1
|
||||||
github.com/go-ap/jsonld v0.0.0-20250905102310-8480b0fe24d9
|
github.com/go-ap/jsonld v0.0.0-20250905102310-8480b0fe24d9
|
||||||
github.com/meilisearch/meilisearch-go v0.36.2
|
github.com/meilisearch/meilisearch-go v0.36.2
|
||||||
@@ -53,13 +53,13 @@ require (
|
|||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/twpayne/go-polyline v1.1.1
|
github.com/twpayne/go-polyline v1.1.1
|
||||||
golang.org/x/crypto v0.51.0 // indirect
|
golang.org/x/crypto v0.53.0 // indirect
|
||||||
golang.org/x/image v0.39.0 // indirect
|
golang.org/x/image v0.39.0 // indirect
|
||||||
golang.org/x/net v0.55.0
|
golang.org/x/net v0.56.0
|
||||||
golang.org/x/oauth2 v0.36.0 // indirect
|
golang.org/x/oauth2 v0.36.0 // indirect
|
||||||
golang.org/x/sync v0.20.0
|
golang.org/x/sync v0.21.0
|
||||||
golang.org/x/sys v0.45.0 // indirect
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
golang.org/x/text v0.37.0 // indirect
|
golang.org/x/text v0.38.0
|
||||||
modernc.org/libc v1.72.0 // indirect
|
modernc.org/libc v1.72.0 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
|||||||
36
db/go.sum
@@ -17,8 +17,8 @@ github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1
|
|||||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||||
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
|
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
|
||||||
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
||||||
github.com/doyensec/safeurl v0.2.3 h1:KJZHxTUMI17yUSy5umKmDLtzYBUxN6MkdSIyRI81DvY=
|
github.com/doyensec/safeurl v0.2.5 h1:kKu0JNQy0tJ8jkDyB5h6Aml9vWWniq+mpoa12EGLcOQ=
|
||||||
github.com/doyensec/safeurl v0.2.3/go.mod h1:3H0cgRpPYPSpgxRRn5yGD35Ns/LgGX/BVWSBbzUqXtY=
|
github.com/doyensec/safeurl v0.2.5/go.mod h1:3H0cgRpPYPSpgxRRn5yGD35Ns/LgGX/BVWSBbzUqXtY=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/dvyukov/go-fuzz v0.0.0-20200318091601-be3528f3a813/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw=
|
github.com/dvyukov/go-fuzz v0.0.0-20200318091601-be3528f3a813/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw=
|
||||||
@@ -124,39 +124,39 @@ go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR
|
|||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||||
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||||
|
|||||||
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := util.EnsureUserCategoryPriority(e.App, e.Record.Id, ""); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
_, err = util.ActorFromUser(e.App, e.Record)
|
_, err = util.ActorFromUser(e.App, e.Record)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
65
db/main.go
@@ -5,14 +5,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/meilisearch/meilisearch-go"
|
"github.com/meilisearch/meilisearch-go"
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase"
|
"github.com/pocketbase/pocketbase"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/plugins/migratecmd"
|
"github.com/pocketbase/pocketbase/plugins/migratecmd"
|
||||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
|
||||||
|
|
||||||
"pocketbase/commands"
|
"pocketbase/commands"
|
||||||
"pocketbase/hooks"
|
"pocketbase/hooks"
|
||||||
@@ -96,6 +94,22 @@ func setupEventHandlers(app *pocketbase.PocketBase, client meilisearch.ServiceMa
|
|||||||
app.OnRecordAfterUpdateSuccess("activitypub_actors").BindFunc(hooks.UpdateActorHandler(client))
|
app.OnRecordAfterUpdateSuccess("activitypub_actors").BindFunc(hooks.UpdateActorHandler(client))
|
||||||
app.OnRecordAfterDeleteSuccess("activitypub_actors").BindFunc(hooks.DeleteActorHandler(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.OnRecordAfterCreateSuccess("trails").BindFunc(hooks.CreateTrailHandler(client))
|
||||||
app.OnRecordAfterUpdateSuccess("trails").BindFunc(hooks.UpdateTrailHandler(client))
|
app.OnRecordAfterUpdateSuccess("trails").BindFunc(hooks.UpdateTrailHandler(client))
|
||||||
app.OnRecordAfterDeleteSuccess("trails").BindFunc(hooks.DeleteTrailHandler(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("/auth/token", routes.AuthToken)
|
||||||
se.Router.POST("/user/email", routes.UserEmailChange)
|
se.Router.POST("/user/email", routes.UserEmailChange)
|
||||||
se.Router.POST("/waypoint/cluster", routes.WaypointCluster)
|
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/suggest", routes.TrailMergeSuggest)
|
||||||
se.Router.POST("/trail-merge", routes.TrailMerge(client))
|
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 {
|
func initData(app core.App, client meilisearch.ServiceManager) error {
|
||||||
initCategories(app)
|
initCategories(app)
|
||||||
|
if err := util.SeedDefaultSubcategories(app); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
initPlugins(app)
|
initPlugins(app)
|
||||||
initMeilisearchConfig(client)
|
initMeilisearchConfig(client)
|
||||||
go func() {
|
go func() {
|
||||||
@@ -280,31 +299,29 @@ func initCategories(app core.App) error {
|
|||||||
if err := query.All(&records); err != nil {
|
if err := query.All(&records); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(records) != 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
collection, err := app.FindCollectionByNameOrId("categories")
|
collection, err := app.FindCollectionByNameOrId("categories")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
categories := []string{"Hiking", "Walking", "Climbing", "Skiing", "Canoeing", "Biking", "Other"}
|
if len(records) == 0 {
|
||||||
for _, element := range categories {
|
for _, element := range util.DefaultCategoryNames() {
|
||||||
record := core.NewRecord(collection)
|
record := core.NewRecord(collection)
|
||||||
record.Set("name", element)
|
record.Set("name", element)
|
||||||
record.Set("settings", map[string]any{
|
record.Set("settings", map[string]any{
|
||||||
"wp_merge_enabled": true,
|
"wp_merge_enabled": true,
|
||||||
"wp_merge_radius": 50,
|
"wp_merge_radius": 50,
|
||||||
})
|
})
|
||||||
if f, err := filesystem.NewFileFromPath("migrations/initial_data/" + strings.ToLower(element) + ".jpg"); err == nil {
|
err := app.Save(record)
|
||||||
record.Set("img", f)
|
if err != nil {
|
||||||
}
|
return err
|
||||||
if err := app.Save(record); err != nil {
|
}
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
if err := util.PrepopulateDefaultCategoryTranslations(app); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return util.PrepopulateDefaultCategoryIcons(app)
|
||||||
}
|
}
|
||||||
|
|
||||||
func initMeilisearchConfig(client meilisearch.ServiceManager) {
|
func initMeilisearchConfig(client meilisearch.ServiceManager) {
|
||||||
@@ -312,13 +329,15 @@ func initMeilisearchConfig(client meilisearch.ServiceManager) {
|
|||||||
"trails": {
|
"trails": {
|
||||||
SearchableAttributes: []string{"author_name", "name", "description", "location", "tags"},
|
SearchableAttributes: []string{"author_name", "name", "description", "location", "tags"},
|
||||||
FilterableAttributes: []string{
|
FilterableAttributes: []string{
|
||||||
"id", "_geo", "author", "category", "completed", "date", "difficulty",
|
"id", "_geo", "author", "category_id", "subcategory_id",
|
||||||
"distance", "elevation_gain", "elevation_loss", "likes", "public",
|
"is_federated", "completed", "date", "difficulty", "distance",
|
||||||
"shares", "tags", "min_lat", "max_lat", "min_lon", "max_lon", "bounding_box_diagonal",
|
"elevation_gain", "elevation_loss", "likes", "public", "shares",
|
||||||
|
"tags", "min_lat", "max_lat", "min_lon", "max_lon", "bounding_box_diagonal",
|
||||||
},
|
},
|
||||||
SortableAttributes: []string{
|
SortableAttributes: []string{
|
||||||
"author", "created", "date", "difficulty", "distance",
|
"author", "created", "date", "difficulty", "distance",
|
||||||
"duration", "elevation_gain", "elevation_loss", "like_count", "name",
|
"duration", "elevation_gain", "elevation_loss", "like_count", "name",
|
||||||
|
"min_lat", "max_lat", "min_lon", "max_lon",
|
||||||
},
|
},
|
||||||
RankingRules: []string{"words", "typo", "proximity", "attribute", "sort", "exactness"},
|
RankingRules: []string{"words", "typo", "proximity", "attribute", "sort", "exactness"},
|
||||||
},
|
},
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 573 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 664 KiB |
|
Before Width: | Height: | Size: 846 KiB |
|
Before Width: | Height: | Size: 453 KiB |
|
Before Width: | Height: | Size: 432 KiB |
@@ -29,7 +29,7 @@ type Options struct {
|
|||||||
ActorID string
|
ActorID string
|
||||||
DefaultPublic bool
|
DefaultPublic bool
|
||||||
CreateSummitLogForCompleted bool
|
CreateSummitLogForCompleted bool
|
||||||
CategoryMapping map[string]string
|
CategoryMapping map[string]CategoryMappingValue
|
||||||
Manifest pluginsystem.Manifest
|
Manifest pluginsystem.Manifest
|
||||||
Policy pluginsystem.RequestPolicyContext
|
Policy pluginsystem.RequestPolicyContext
|
||||||
Auth map[string]any
|
Auth map[string]any
|
||||||
@@ -43,6 +43,16 @@ type Result struct {
|
|||||||
Skipped bool
|
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
|
// ImportTrail is the boundary between plugin output and wanderer records. It
|
||||||
// validates the provider identity, deduplicates by trail_external_reference,
|
// validates the provider identity, deduplicates by trail_external_reference,
|
||||||
// stores the GPX/photos, maps GPX metrics onto the trail record, and creates the
|
// 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)
|
applyProviderStart(&metrics, trackIndex, item.Metadata)
|
||||||
applyProviderMetrics(&metrics, item.Metadata)
|
applyProviderMetrics(&metrics, item.Metadata)
|
||||||
public := publicFromPrivacy(item.Privacy, opts.DefaultPublic)
|
public := publicFromPrivacy(item.Privacy, opts.DefaultPublic)
|
||||||
categoryID := categoryIDForImport(app, item, opts.CategoryMapping)
|
categoryTarget := categoryTargetForImport(app, item, opts.CategoryMapping)
|
||||||
date := dateFromImport(item, metrics)
|
date := dateFromImport(item, metrics)
|
||||||
mediaBudget := &pluginMediaBudget{}
|
mediaBudget := &pluginMediaBudget{}
|
||||||
photos := photoFiles(ctx, app, item.Photos, opts, mediaBudget)
|
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,
|
"lat": metrics.StartLat,
|
||||||
"lon": metrics.StartLon,
|
"lon": metrics.StartLon,
|
||||||
"difficulty": "easy",
|
"difficulty": "easy",
|
||||||
"category": categoryID,
|
"category": categoryTarget.CategoryID,
|
||||||
|
"subcategory": categoryTarget.SubcategoryID,
|
||||||
"author": opts.ActorID,
|
"author": opts.ActorID,
|
||||||
})
|
})
|
||||||
record.Set("gpx", gpxFile)
|
record.Set("gpx", gpxFile)
|
||||||
@@ -772,11 +783,15 @@ func createSummitLog(app core.App, trailID string, actorID string, date time.Tim
|
|||||||
return app.Save(record)
|
return app.Save(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
func categoryIDForImport(app core.App, item pluginsystem.TrailImport, mapping map[string]string) string {
|
func categoryTargetForImport(app core.App, item pluginsystem.TrailImport, mapping map[string]CategoryMappingValue) CategoryMappingTarget {
|
||||||
if category, matched := CategoryFromProviderMapping(app, ProviderCategoryFromImport(item), mapping); matched {
|
if categoryTarget, matched := CategoryTargetFromProviderMapping(app, ProviderCategoryFromImport(item), mapping); matched {
|
||||||
return category
|
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 {
|
func ProviderCategoryFromImport(item pluginsystem.TrailImport) string {
|
||||||
@@ -788,58 +803,109 @@ func ProviderCategoryFromImport(item pluginsystem.TrailImport) string {
|
|||||||
return strings.TrimSpace(value)
|
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)
|
providerCategory = strings.TrimSpace(providerCategory)
|
||||||
if providerCategory == "" || len(mapping) == 0 {
|
if providerCategory == "" || len(mapping) == 0 {
|
||||||
return "", false
|
return CategoryMappingTarget{}, false
|
||||||
}
|
}
|
||||||
rawTarget, matched := mapping[providerCategory]
|
mappingTarget, matched := mapping[providerCategory]
|
||||||
if !matched {
|
if !matched {
|
||||||
return "", false
|
return CategoryMappingTarget{}, false
|
||||||
}
|
}
|
||||||
target := strings.TrimSpace(rawTarget)
|
if mappingTarget.Category == "" && mappingTarget.Subcategory == "" {
|
||||||
if target == "" {
|
return CategoryMappingTarget{}, true
|
||||||
return "", true
|
|
||||||
}
|
}
|
||||||
if category, err := app.FindRecordById("categories", target); err == nil && category != nil {
|
|
||||||
return category.Id, true
|
return resolveCategoryMappingTarget(app, mappingTarget)
|
||||||
}
|
|
||||||
category, _ := app.FindFirstRecordByData("categories", "name", target)
|
|
||||||
if category == nil {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
return category.Id, true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// categoryIDForActivityType maps common provider activity labels to wanderer's
|
// categoryIDForActivityType maps common provider activity labels to wanderer's
|
||||||
// built-in categories. Unknown labels intentionally leave the category empty.
|
// built-in categories. Unknown labels intentionally leave the category empty.
|
||||||
func categoryIDForActivityType(app core.App, activityType string) string {
|
func categoryIDForActivityType(app core.App, activityType string) string {
|
||||||
categoryMap := map[string]string{
|
return categoryTargetForActivityType(app, activityType).CategoryID
|
||||||
"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",
|
|
||||||
}
|
|
||||||
|
|
||||||
name := categoryMap[strings.ToLower(activityType)]
|
func categoryTargetForActivityType(app core.App, activityType string) CategoryMappingTarget {
|
||||||
|
name := categoryNameForActivityType(activityType)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return ""
|
return CategoryMappingTarget{}
|
||||||
}
|
}
|
||||||
|
|
||||||
category, _ := app.FindFirstRecordByData("categories", "name", name)
|
target, matched := resolveCategoryMappingTarget(app, CategoryMappingValue{Category: name})
|
||||||
if category == nil {
|
if !matched {
|
||||||
return ""
|
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 {
|
func fallbackName(name string) string {
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
pbtests "github.com/pocketbase/pocketbase/tests"
|
||||||
|
|
||||||
pluginsystem "pocketbase/pluginsystem"
|
pluginsystem "pocketbase/pluginsystem"
|
||||||
"pocketbase/util"
|
"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)
|
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) {
|
func TestDateFromImport(t *testing.T) {
|
||||||
started := time.Date(2025, 6, 1, 8, 0, 0, 0, time.UTC)
|
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)
|
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
@@ -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 {
|
type pluginCategoryRemapCandidate struct {
|
||||||
Trail *core.Record
|
Trail *core.Record
|
||||||
CategoryID string
|
CategoryID string
|
||||||
|
SubcategoryID string
|
||||||
}
|
}
|
||||||
|
|
||||||
type pluginCategoryTrailReference struct {
|
type pluginCategoryTrailReference struct {
|
||||||
@@ -72,6 +73,7 @@ func PluginSystemCategoryRemapApply(e *core.RequestEvent) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
trail.Set("category", candidate.CategoryID)
|
trail.Set("category", candidate.CategoryID)
|
||||||
|
trail.Set("subcategory", candidate.SubcategoryID)
|
||||||
if err := txApp.Save(trail); err != nil {
|
if err := txApp.Save(trail); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -84,7 +86,7 @@ func PluginSystemCategoryRemapApply(e *core.RequestEvent) error {
|
|||||||
return e.JSON(http.StatusOK, pluginCategoryRemapResponse{Count: len(candidates), Remapped: remapped})
|
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 {
|
if e.Auth == nil {
|
||||||
return nil, nil, apis.NewUnauthorizedError("authentication required", 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
|
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 {
|
if userID == "" || pluginID == "" || len(mapping) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -122,7 +124,7 @@ func pluginCategoryRemapCandidates(app core.App, userID string, pluginID string,
|
|||||||
return pluginCategoryRemapCandidatesFromRefs(app, refs, mapping), nil
|
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 {
|
if len(refs) == 0 || len(mapping) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -130,19 +132,23 @@ func pluginCategoryRemapCandidatesFromRefs(app core.App, refs []pluginCategoryTr
|
|||||||
candidates := make([]pluginCategoryRemapCandidate, 0, len(refs))
|
candidates := make([]pluginCategoryRemapCandidate, 0, len(refs))
|
||||||
for _, ref := range refs {
|
for _, ref := range refs {
|
||||||
providerCategory := strings.TrimSpace(ref.Ref.GetString("provider_category"))
|
providerCategory := strings.TrimSpace(ref.Ref.GetString("provider_category"))
|
||||||
categoryID, matched := importer.CategoryFromProviderMapping(app, providerCategory, mapping)
|
target, matched := importer.CategoryTargetFromProviderMapping(app, providerCategory, mapping)
|
||||||
if !matched || categoryID == "" || ref.Trail.GetString("category") == categoryID {
|
if !matched || target.CategoryID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ref.Trail.GetString("category") == target.CategoryID && ref.Trail.GetString("subcategory") == target.SubcategoryID {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
candidates = append(candidates, pluginCategoryRemapCandidate{
|
candidates = append(candidates, pluginCategoryRemapCandidate{
|
||||||
Trail: ref.Trail,
|
Trail: ref.Trail,
|
||||||
CategoryID: categoryID,
|
CategoryID: target.CategoryID,
|
||||||
|
SubcategoryID: target.SubcategoryID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return candidates
|
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)
|
mappingUpdatedAt := categoryMappingUpdatedAt(app, instance)
|
||||||
if mappingUpdatedAt.IsZero() || len(refs) == 0 || len(mapping) == 0 {
|
if mappingUpdatedAt.IsZero() || len(refs) == 0 || len(mapping) == 0 {
|
||||||
return 0
|
return 0
|
||||||
@@ -155,8 +161,8 @@ func pluginCategoryBackfilledSinceMappingCountFromRefs(app core.App, instance *c
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
providerCategory := strings.TrimSpace(ref.Ref.GetString("provider_category"))
|
providerCategory := strings.TrimSpace(ref.Ref.GetString("provider_category"))
|
||||||
categoryID, matched := importer.CategoryFromProviderMapping(app, providerCategory, mapping)
|
target, matched := importer.CategoryTargetFromProviderMapping(app, providerCategory, mapping)
|
||||||
if matched && categoryID != "" && ref.Trail.GetString("category") != categoryID {
|
if matched && target.CategoryID != "" && (ref.Trail.GetString("category") != target.CategoryID || ref.Trail.GetString("subcategory") != target.SubcategoryID) {
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -578,24 +578,31 @@ func boolOption(config map[string]any, key string, fallback bool) bool {
|
|||||||
return value
|
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)
|
raw, ok := config["categoryMapping"].(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
result := make(map[string]string, len(raw))
|
result := make(map[string]importer.CategoryMappingValue, len(raw))
|
||||||
for key, value := range raw {
|
for key, value := range raw {
|
||||||
category, ok := value.(string)
|
switch typed := value.(type) {
|
||||||
if ok {
|
case string:
|
||||||
result[key] = category
|
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
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasUsableCategoryMapping(mapping map[string]string) bool {
|
func hasUsableCategoryMapping(mapping map[string]importer.CategoryMappingValue) bool {
|
||||||
for _, category := range mapping {
|
for _, target := range mapping {
|
||||||
if strings.TrimSpace(category) != "" {
|
if strings.TrimSpace(target.Category) != "" || strings.TrimSpace(target.Subcategory) != "" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package routes
|
package routes
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"pocketbase/plugins/importer"
|
||||||
|
)
|
||||||
|
|
||||||
func TestCategoryMappingPreservesExplicitEmptyMap(t *testing.T) {
|
func TestCategoryMappingPreservesExplicitEmptyMap(t *testing.T) {
|
||||||
mapping := categoryMapping(map[string]any{
|
mapping := categoryMapping(map[string]any{
|
||||||
@@ -29,7 +33,22 @@ func TestCategoryMappingPreservesBlankProviderMapping(t *testing.T) {
|
|||||||
if mapping == nil {
|
if mapping == nil {
|
||||||
t.Fatal("expected category mapping")
|
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)
|
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 ---
|
// --- Sub-Sync Helpers ---
|
||||||
|
|
||||||
func syncTrailMetadata(app core.App, record *core.Record, data map[string]any) {
|
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 expand, ok := data["expand"].(map[string]any); ok {
|
||||||
if cat, ok := expand["category"].(map[string]any); ok {
|
if cat, ok := expand["category"].(map[string]any); ok {
|
||||||
if name, ok := cat["name"].(string); ok {
|
if name, ok := cat["name"].(string); ok {
|
||||||
if c, _ := app.FindFirstRecordByData("categories", "name", name); c != nil {
|
federatedCategoryName = name
|
||||||
record.Set("category", c.Id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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
|
// Resolve Tags
|
||||||
@@ -260,8 +284,11 @@ func syncTrailMetadata(app core.App, record *core.Record, data map[string]any) {
|
|||||||
delete(data, "gpx")
|
delete(data, "gpx")
|
||||||
delete(data, "author")
|
delete(data, "author")
|
||||||
delete(data, "category")
|
delete(data, "category")
|
||||||
|
delete(data, "subcategory")
|
||||||
delete(data, "tags")
|
delete(data, "tags")
|
||||||
delete(data, "iri")
|
delete(data, "iri")
|
||||||
|
delete(data, "federated_category_name")
|
||||||
|
delete(data, "federated_subcategory_name")
|
||||||
|
|
||||||
record.Load(data)
|
record.Load(data)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,7 +173,15 @@ func TrailFromActivity(activity pub.Activity, app core.App, actor *core.Record)
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// this trail exists already
|
// 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)
|
record.Set("needs_full_sync", true)
|
||||||
err = app.Save(record)
|
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 distance, duration, elevation_gain, elevation_loss float64
|
||||||
var diffculty, category string
|
var diffculty string
|
||||||
trailTags := []string{}
|
trailTags := []string{}
|
||||||
tags, err := pub.ToItemCollection(t.Tag)
|
tags, err := pub.ToItemCollection(t.Tag)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
categoryMetadata := trailCategoryMetadataFromTags(tags)
|
||||||
|
|
||||||
for _, tag := range tags.Collection() {
|
for _, tag := range tags.Collection() {
|
||||||
tagObj, err := pub.ToObject(tag)
|
tagObj, err := pub.ToObject(tag)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
content := tagObj.Content.First().Value.String()
|
content := tagObj.Content.First().Value.String()
|
||||||
switch tagObj.Name.First().Value.String() {
|
switch tagObj.Name.First().Value.String() {
|
||||||
case "category":
|
|
||||||
category = content
|
|
||||||
case "difficulty":
|
case "difficulty":
|
||||||
diffculty = content
|
diffculty = content
|
||||||
case "elevation_gain":
|
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("author", actor.Id)
|
||||||
record.Set("needs_full_sync", true)
|
record.Set("needs_full_sync", true)
|
||||||
|
|
||||||
categoryRecord, err := app.FindFirstRecordByData("categories", "name", category)
|
if err := applyTrailActivityCategoryMetadata(app, record, categoryMetadata); err != nil {
|
||||||
if err == nil {
|
return nil, err
|
||||||
record.Set("category", categoryRecord.Id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if t.Attachment != nil {
|
if t.Attachment != nil {
|
||||||
@@ -282,12 +289,12 @@ func TrailFromActivity(activity pub.Activity, app core.App, actor *core.Record)
|
|||||||
|
|
||||||
if len(photoURLs) > 0 {
|
if len(photoURLs) > 0 {
|
||||||
photos := []*filesystem.File{}
|
photos := []*filesystem.File{}
|
||||||
for i, purl := range photoURLs {
|
for _, purl := range photoURLs {
|
||||||
photo, err := filesystem.NewFileFromURL(context.Background(), purl)
|
photo, err := filesystem.NewFileFromURL(context.Background(), purl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
photos[i] = photo
|
photos = append(photos, photo)
|
||||||
}
|
}
|
||||||
|
|
||||||
record.Set("photos", photos)
|
record.Set("photos", photos)
|
||||||
@@ -306,6 +313,80 @@ func TrailFromActivity(activity pub.Activity, app core.App, actor *core.Record)
|
|||||||
return record, app.Save(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) {
|
func ObjectFromTrail(app core.App, trail *core.Record, mentions *pub.ItemCollection) (*pub.Object, error) {
|
||||||
origin := os.Getenv("ORIGIN")
|
origin := os.Getenv("ORIGIN")
|
||||||
if origin == "" {
|
if origin == "" {
|
||||||
@@ -320,9 +401,9 @@ func ObjectFromTrail(app core.App, trail *core.Record, mentions *pub.ItemCollect
|
|||||||
if len(errs) > 0 {
|
if len(errs) > 0 {
|
||||||
return nil, fmt.Errorf("failed to expand tags: %v", errs)
|
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 {
|
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 := ""
|
category := ""
|
||||||
@@ -330,6 +411,11 @@ func ObjectFromTrail(app core.App, trail *core.Record, mentions *pub.ItemCollect
|
|||||||
if categoryRecord != nil {
|
if categoryRecord != nil {
|
||||||
category = categoryRecord.GetString("name")
|
category = categoryRecord.GetString("name")
|
||||||
}
|
}
|
||||||
|
subcategory := ""
|
||||||
|
subcategoryRecord := trail.ExpandedOne("subcategory")
|
||||||
|
if subcategoryRecord != nil {
|
||||||
|
subcategory = subcategoryRecord.GetString("name")
|
||||||
|
}
|
||||||
|
|
||||||
tagRecords := trail.ExpandedAll("tags")
|
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 {
|
for _, v := range tagRecords {
|
||||||
hashtag := pub.ObjectNew(pub.NoteType)
|
hashtag := pub.ObjectNew(pub.NoteType)
|
||||||
hashtag.Name = pub.NaturalLanguageValuesNew(pub.LangRefValueNew(pub.NilLangRef, "tag"))
|
hashtag.Name = pub.NaturalLanguageValuesNew(pub.LangRefValueNew(pub.NilLangRef, "tag"))
|
||||||
|
|||||||
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
@@ -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
@@ -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
@@ -33,10 +33,24 @@ func documentFromTrailRecord(r *core.Record, author *core.Record, includeShares
|
|||||||
tags[i] = v.GetString("name")
|
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 := ""
|
category := ""
|
||||||
|
categoryIcon := ""
|
||||||
trailCategory := r.ExpandedOne("category")
|
trailCategory := r.ExpandedOne("category")
|
||||||
if trailCategory != nil {
|
if trailCategory != nil {
|
||||||
category = trailCategory.GetString("name")
|
category = trailCategory.GetString("name")
|
||||||
|
categoryIcon = trailCategory.GetString("icon")
|
||||||
}
|
}
|
||||||
|
|
||||||
bounds := getStoredBounds(r)
|
bounds := getStoredBounds(r)
|
||||||
@@ -52,34 +66,40 @@ func documentFromTrailRecord(r *core.Record, author *core.Record, includeShares
|
|||||||
}
|
}
|
||||||
|
|
||||||
document := map[string]any{
|
document := map[string]any{
|
||||||
"id": r.Id,
|
"id": r.Id,
|
||||||
"author": author.Id,
|
"author": author.Id,
|
||||||
"author_name": author.GetString("preferred_username"),
|
"author_name": author.GetString("preferred_username"),
|
||||||
"author_avatar": author.GetString("icon"),
|
"author_avatar": author.GetString("icon"),
|
||||||
"name": r.GetString("name"),
|
"name": r.GetString("name"),
|
||||||
"description": r.GetString("description"),
|
"description": r.GetString("description"),
|
||||||
"location": r.GetString("location"),
|
"location": r.GetString("location"),
|
||||||
"distance": r.GetFloat("distance"),
|
"distance": r.GetFloat("distance"),
|
||||||
"elevation_gain": r.GetFloat("elevation_gain"),
|
"elevation_gain": r.GetFloat("elevation_gain"),
|
||||||
"elevation_loss": r.GetFloat("elevation_loss"),
|
"elevation_loss": r.GetFloat("elevation_loss"),
|
||||||
"duration": r.GetFloat("duration"),
|
"duration": r.GetFloat("duration"),
|
||||||
"difficulty": difficultyToNumber(r.GetString("difficulty")),
|
"difficulty": difficultyToNumber(r.GetString("difficulty")),
|
||||||
"category": category,
|
"category": category,
|
||||||
"completed": r.GetBool("completed"),
|
"category_id": categoryIDValue,
|
||||||
"date": r.GetDateTime("date").Time().Unix(),
|
"category_icon": categoryIcon,
|
||||||
"created": r.GetDateTime("created").Time().Unix(),
|
"subcategory_id": subcategoryIDValue,
|
||||||
"public": r.GetBool("public"),
|
"is_federated": !author.GetBool("is_local"),
|
||||||
"thumbnail": thumbnail,
|
"federated_category_name": r.GetString("federated_category_name"),
|
||||||
"gpx": r.GetString("gpx"),
|
"federated_subcategory_name": r.GetString("federated_subcategory_name"),
|
||||||
"tags": tags,
|
"completed": r.GetBool("completed"),
|
||||||
"polyline": r.GetString("polyline"),
|
"date": r.GetDateTime("date").Time().Unix(),
|
||||||
"domain": domain,
|
"created": r.GetDateTime("created").Time().Unix(),
|
||||||
"iri": r.GetString("iri"),
|
"public": r.GetBool("public"),
|
||||||
"min_lat": bounds[0],
|
"thumbnail": thumbnail,
|
||||||
"max_lat": bounds[1],
|
"gpx": r.GetString("gpx"),
|
||||||
"min_lon": bounds[2],
|
"tags": tags,
|
||||||
"max_lon": bounds[3],
|
"polyline": r.GetString("polyline"),
|
||||||
"bounding_box_diagonal": diagonal,
|
"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{
|
"_geo": map[string]float64{
|
||||||
"lat": r.GetFloat("lat"),
|
"lat": r.GetFloat("lat"),
|
||||||
"lng": r.GetFloat("lon"),
|
"lng": r.GetFloat("lon"),
|
||||||
|
|||||||
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
@@ -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
|
||||||
|
}
|
||||||
@@ -51,6 +51,10 @@ export default defineConfig({
|
|||||||
label: 'Create/Edit a trail',
|
label: 'Create/Edit a trail',
|
||||||
link: '/use/create-a-trail/'
|
link: '/use/create-a-trail/'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Categories',
|
||||||
|
link: '/use/categories/'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Summit logs',
|
label: 'Summit logs',
|
||||||
link: '/use/summit-logs/'
|
link: '/use/summit-logs/'
|
||||||
|
|||||||
2612
docs/package-lock.json
generated
@@ -12,18 +12,18 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/check": "^0.9.9",
|
"@astrojs/check": "^0.9.9",
|
||||||
"@astrojs/node": "^10.1.2",
|
"@astrojs/node": "^11.0.0",
|
||||||
"@astrojs/starlight": "^0.39.2",
|
"@astrojs/starlight": "^0.41.1",
|
||||||
"@astrojs/starlight-tailwind": "^5.0.0",
|
"@astrojs/starlight-tailwind": "^5.0.0",
|
||||||
"@astrojs/svelte": "^8.1.2",
|
"@astrojs/svelte": "^9.0.0",
|
||||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||||
"@fontsource/ibm-plex-sans": "^5.2.8",
|
"@fontsource/ibm-plex-sans": "^5.2.8",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.2",
|
||||||
"astro": "^6.4.8",
|
"astro": "^7.0.4",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.35.2",
|
||||||
"starlight-openapi": "^0.25.3",
|
"starlight-openapi": "^0.26.0",
|
||||||
"svelte": "^5.56.0",
|
"svelte": "^5.56.4",
|
||||||
"tailwindcss": "^4.1.10",
|
"tailwindcss": "^4.3.2",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 460 KiB After Width: | Height: | Size: 221 KiB |
BIN
docs/src/assets/guides/pocketbase_subcategories.png
Normal file
|
After Width: | Height: | Size: 342 KiB |
BIN
docs/src/assets/guides/wanderer_settings_categories.png
Normal file
|
After Width: | Height: | Size: 160 KiB |
BIN
docs/src/assets/guides/wanderer_trails_adjust.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
docs/src/assets/guides/wanderer_trails_category_filter.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
@@ -536,12 +536,14 @@ Supported host fields:
|
|||||||
| `privacy` | string | Trail import | `original` keeps provider visibility; `settings` uses the local user trail privacy setting. |
|
| `privacy` | string | Trail import | `original` keeps provider visibility; `settings` uses the local user trail privacy setting. |
|
||||||
| `merge.enabled` | boolean | Trail import | Runs auto-merge after creating imported trails. |
|
| `merge.enabled` | boolean | Trail import | Runs auto-merge after creating imported trails. |
|
||||||
| `createSummitLogForCompleted` | boolean | Trail import | Creates summit logs for completed imported trails. Defaults to `true`. |
|
| `createSummitLogForCompleted` | boolean | Trail import | Creates summit logs for completed imported trails. Defaults to `true`. |
|
||||||
| `categoryMapping` | object | Trail import | Maps plugin-provided `metadata.providerCategory` values to local category IDs or category names. |
|
| `categoryMapping` | object | Trail import | Maps plugin-provided `metadata.providerCategory` values to local category or subcategory targets. |
|
||||||
| `connectors` | object | Host request/media policy | Concrete settings for configured connectors. |
|
| `connectors` | object | Host request/media policy | Concrete settings for configured connectors. |
|
||||||
|
|
||||||
The settings UI lets users edit `categoryMapping` per plugin instance for trail
|
The settings UI lets users edit `categoryMapping` per plugin instance for trail
|
||||||
import plugins. Unknown or empty provider categories still fall back to the
|
import plugins. A mapping value can be a string for broad category-only
|
||||||
host's activity-type mapping.
|
compatibility, or an object with `category` and optional `subcategory`. Category
|
||||||
|
and subcategory values may be local record IDs or canonical names. Unknown or
|
||||||
|
empty provider categories still fall back to the host's activity-type mapping.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
@@ -549,7 +551,14 @@ Example:
|
|||||||
{
|
{
|
||||||
"hostConfig": {
|
"hostConfig": {
|
||||||
"categoryMapping": {
|
"categoryMapping": {
|
||||||
"Ride": "Biking",
|
"Ride": {
|
||||||
|
"category": "Biking",
|
||||||
|
"subcategory": "Road"
|
||||||
|
},
|
||||||
|
"GravelRide": {
|
||||||
|
"category": "Biking",
|
||||||
|
"subcategory": "Gravel"
|
||||||
|
},
|
||||||
"Hike": "Hiking"
|
"Hike": "Hiking"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
---
|
---
|
||||||
title: Custom categories
|
title: Custom categories
|
||||||
description: How to create custom trail categories
|
description: How to configure trail categories and subcategories
|
||||||
---
|
---
|
||||||
|
|
||||||
<span class="-tracking-[0.075em]">wanderer</span> uses categories to classify what kind of activity a trail belongs to.
|
<span class="-tracking-[0.075em]">wanderer</span> uses categories to classify what kind of activity a trail belongs to.
|
||||||
Out of the box you get: Biking, Canoeing, Climbing, Hiking, Skiing and Walking.
|
Out of the box you get: Biking, Canoeing, Climbing, Hiking, Running, Skiing and Walking.
|
||||||
However, you can adapt these categories to your needs or add completely new ones.
|
Some broad categories also have subcategories, for example Biking can be refined into MTB, Gravel, Road or E-Bike.
|
||||||
|
You can adapt this taxonomy to your needs in the PocketBase admin panel.
|
||||||
|
|
||||||
## Modifying categories
|
## Modifying categories
|
||||||
|
|
||||||
@@ -15,7 +16,69 @@ In the PocketBase admin panel, click on the `categories` table in the list on th
|
|||||||
All existing categories will be listed here.
|
All existing categories will be listed here.
|
||||||
To edit one simply click on the row, edit the data you want to change, and click "Save".
|
To edit one simply click on the row, edit the data you want to change, and click "Save".
|
||||||
To delete a category check the box at the beginning of the row and click "Delete selected".
|
To delete a category check the box at the beginning of the row and click "Delete selected".
|
||||||
To create a new category click the "New record" button in the top right corner, give your new category a name and a background image, and click "Save".
|
To create a new category click the "New record" button in the top right corner, give your new category a name, optionally fill in display metadata such as `short_name`, `icon`, or localized `translations`, and click "Save".
|
||||||
|
|
||||||
|
The category `name` is the canonical, language-independent identity.
|
||||||
|
Use stable names such as `Hiking` or `Biking`; display labels in different languages should be stored in `translations`.
|
||||||
|
Incoming federated trails and integration imports match categories by a normalized version of `name`, so changing a category name can affect future matching.
|
||||||
|
|
||||||
|
### Category fields
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
| ----- | ----------- |
|
||||||
|
| `name` | Canonical category name. This is used for matching across imports and federation. |
|
||||||
|
| `short_name` | Optional compact label for space-constrained UI. |
|
||||||
|
| `icon` | Optional Font Awesome Free icon name without the `fa-` prefix, for example `person-hiking`. |
|
||||||
|
| `translations` | Optional localized display labels. |
|
||||||
|
| `settings` | Optional JSON settings for category-specific backend behavior. |
|
||||||
|
|
||||||
|
`translations` uses supported base locale codes such as `de`, `en`, `fr`, or `pt` as keys.
|
||||||
|
Do not use region-specific keys such as `de-CH` or `pt-BR`; the frontend resolves user locales to their base locale before looking up category translations.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"de": {
|
||||||
|
"name": "Radfahren",
|
||||||
|
"short_name": "RAD"
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"name": "Biking",
|
||||||
|
"short_name": "BIKE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Modifying subcategories
|
||||||
|
|
||||||
|
Subcategories live in the `subcategories` table and act as optional refinements below a single parent category. Their names only need to be unique within that parent, so `Road` can exist under both Biking and Running at the same time.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
To add one, create a new record in `subcategories`, choose its parent `category`, set a canonical `name`, and optionally add display metadata.
|
||||||
|
|
||||||
|
### Subcategory fields
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
| ----- | ----------- |
|
||||||
|
| `category` | Required parent category. |
|
||||||
|
| `name` | Canonical subcategory name, unique within the parent category after normalization. |
|
||||||
|
| `short_name` | Compact label shown in icon-based filters, for example `MTB`, `GRVL`, or `ROAD`. |
|
||||||
|
| `icon` | Optional Font Awesome Free icon name. If empty, the parent category icon is used. |
|
||||||
|
| `badge_icon` | Optional Font Awesome Free overlay icon, for example `snowflake`, `mountain`, `bolt`, or `cross`. |
|
||||||
|
| `translations` | Optional localized display labels, using the same structure as category translations. |
|
||||||
|
|
||||||
|
Most subcategories should reuse the parent category's icon and rely on `short_name` — plus a `badge_icon` where it helps — to set themselves apart, rather than each carrying a distinct full icon. You can browse available icon names at [fontawesome.com](https://fontawesome.com/search?ic=free-collection).
|
||||||
|
|
||||||
|
:::note
|
||||||
|
Unknown remote categories and subcategories are not automatically created during federation.
|
||||||
|
Raw remote values are stored on the trail and can be matched later when an admin creates a compatible local category or subcategory.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Migrating old custom categories
|
||||||
|
|
||||||
|
If your instance already had custom categories such as `MTB` or `Gravel` that now overlap with a default subcategory, you can reassign the affected trails in bulk from the web UI. See [Categories](/use/categories/#editing-several-trails-at-once) for the step-by-step migration path.
|
||||||
|
|
||||||
## Category settings
|
## Category settings
|
||||||
|
|
||||||
|
|||||||
58
docs/src/content/docs/use/categories.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
title: Categories
|
||||||
|
description: How to use trail categories, subcategories, and category visibility settings
|
||||||
|
---
|
||||||
|
|
||||||
|
Every trail has a category that describes its broad activity type — Hiking, Biking, Running, Skiing, and so on. Many categories can be narrowed down further with a subcategory, such as Biking / Gravel or Hiking / Snowshoeing, whenever you want to be more specific.
|
||||||
|
|
||||||
|
## Choosing a category
|
||||||
|
|
||||||
|
When you create or edit a trail, pick the activity type with the **Category** selector in the trail form. It lists the broad categories together with their subcategories, so you can stay general or get specific:
|
||||||
|
|
||||||
|
- Choose **Hiking** for an ordinary hiking trail.
|
||||||
|
- Choose **Hiking / Snowshoeing** to mark it as a snowshoe route.
|
||||||
|
- Choose **Biking / Gravel** for a gravel ride.
|
||||||
|
|
||||||
|
A broad category on its own is always enough; a subcategory is optional. On trail cards and in lists, the category icon carries a small badge for subcategories that need one — for example a snowflake for winter variants — so you can tell refinements apart at a glance.
|
||||||
|
|
||||||
|
## Filtering trails
|
||||||
|
|
||||||
|
The filter panel shows each category as an icon. Click an icon to add that category to the filter.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Categories that have subcategories reveal a subcategory overlay when you hover or focus the icon; on touch devices, long-press it instead. From there you can filter by:
|
||||||
|
|
||||||
|
- the whole category,
|
||||||
|
- only trails that have no subcategory, or
|
||||||
|
- one or more specific subcategories.
|
||||||
|
|
||||||
|
When a subcategory filter is active, a small indicator appears on the category icon — that's how you tell "all Biking trails" apart from "only the Biking subcategories I picked".
|
||||||
|
|
||||||
|
## Editing several trails at once
|
||||||
|
|
||||||
|
To reclassify many trails in one go, select them in the trail list, open the action menu, and choose **Adjust**. The modal lets you set a new category, subcategory, or difficulty for the whole selection.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
This is handy after upgrading an instance, when an older standalone category overlaps with a new subcategory: every trail previously filed under `Gravel`, for instance, can be moved to Biking / Gravel in a single step.
|
||||||
|
|
||||||
|
## Category preferences
|
||||||
|
|
||||||
|
Open **Settings → Categories** to control how categories behave for your account.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Each category has one visibility toggle:
|
||||||
|
|
||||||
|
- **Show** controls whether the category is part of your exploration and planning. While it is on, the category appears in search and discovery and is offered in the category picker when you create or edit a trail. Turn it off to hide all trails in that category from those places, including federated trails from other instances.
|
||||||
|
|
||||||
|
You can **reorder** categories by dragging them. This order carries over to pickers and filters, and it also decides which category is preselected when you create a new trail. New accounts start with Hiking as the first category unless an older favourite-sport setting is migrated.
|
||||||
|
|
||||||
|
Categories with subcategories can be expanded. Inside the expanded section, each subcategory has its own visibility toggle and can be reordered by dragging. Hidden subcategories appear muted and drop out of your pickers and filters; hiding a parent category also hides its subcategories. When a category is collapsed, the compact badges below the category name show which subcategories belong to it.
|
||||||
|
|
||||||
|
These settings are personal. They never delete categories, change other users' settings, or remove category assignments that already exist on trails.
|
||||||
|
|
||||||
|
:::note
|
||||||
|
Categories and subcategories themselves are defined by the instance administrator in PocketBase. As a regular user you choose from the available taxonomy and set your own visibility preferences, but you cannot create global categories from the web UI.
|
||||||
|
:::
|
||||||
@@ -65,7 +65,7 @@ While drawing or editing a route, the anchor list shows the route's start, inter
|
|||||||
- **Distance / Duration / Elevation** – These are automatically calculated but can be manually adjusted if needed.
|
- **Distance / Duration / Elevation** – These are automatically calculated but can be manually adjusted if needed.
|
||||||
- **Tags** – Add descriptive tags to help categorize and search for your trail (e.g. forest, sunset, dog-friendly). Start typing to add a tag and press Enter to confirm.
|
- **Tags** – Add descriptive tags to help categorize and search for your trail (e.g. forest, sunset, dog-friendly). Start typing to add a tag and press Enter to confirm.
|
||||||
- **Difficulty** – Select the trail's difficulty (e.g. Easy, Moderate, Hard)
|
- **Difficulty** – Select the trail's difficulty (e.g. Easy, Moderate, Hard)
|
||||||
- **Category** – Choose the activity type (e.g. Hiking, Cycling)
|
- **[Category](/use/categories/)** – Choose the activity type. You can select a broad category such as Hiking, or a more specific subcategory such as Biking / Gravel.
|
||||||
|
|
||||||
#### Visibility
|
#### Visibility
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ Waypoints are points of interest along the trail.
|
|||||||
- When you are not editing the route, click on the map to open a popup with a **Create waypoint** button at that location.
|
- When you are not editing the route, click on the map to open a popup with a **Create waypoint** button at that location.
|
||||||
- While drawing or editing the route, right-click on the map to open the same popup without placing a route anchor. This works only while waypoint markers are visible (see the waypoint toggle in the route editing toolbar).
|
- While drawing or editing the route, right-click on the map to open the same popup without placing a route anchor. This works only while waypoint markers are visible (see the waypoint toggle in the route editing toolbar).
|
||||||
- Each waypoint can have a name, description, icon, and photos.
|
- Each waypoint can have a name, description, icon, and photos.
|
||||||
- Use Font Awesome icons for map markers. You can browse them at [fontawesome.com](https://fontawesome.com/search?q=share&o=r&m=free).
|
- Use Font Awesome icons for map markers. You can browse them at [fontawesome.com](https://fontawesome.com/search?ic=free-collection).
|
||||||
|
|
||||||
Alternatively, click **From Photos** to upload photos with GPS metadata. Waypoints will be created automatically based on the photo locations.
|
Alternatively, click **From Photos** to upload photos with GPS metadata. Waypoints will be created automatically based on the photo locations.
|
||||||
|
|
||||||
|
|||||||
@@ -204,10 +204,24 @@ Manifest `configSchema` defines plugin-owned settings that are passed to plugin
|
|||||||
| `merge.available` | Controls whether the UI offers auto-merge for this plugin. Defaults to `true`. |
|
| `merge.available` | Controls whether the UI offers auto-merge for this plugin. Defaults to `true`. |
|
||||||
| `merge.enabled` | Runs auto-merge after trail import. |
|
| `merge.enabled` | Runs auto-merge after trail import. |
|
||||||
| `createSummitLogForCompleted` | Creates summit logs for completed imports. |
|
| `createSummitLogForCompleted` | Creates summit logs for completed imports. |
|
||||||
| `categoryMapping` | Maps `metadata.providerCategory` to local category IDs or names. |
|
| `categoryMapping` | Maps `metadata.providerCategory` to local category or subcategory targets. |
|
||||||
| `connectors` | Provides host-owned base URL, TLS, private-network, and storage redirect settings for configured connectors. |
|
| `connectors` | Provides host-owned base URL, TLS, private-network, and storage redirect settings for configured connectors. |
|
||||||
|
|
||||||
The settings UI lets users edit `categoryMapping` per plugin instance for trail import plugins.
|
The settings UI lets users edit `categoryMapping` per plugin instance for trail import plugins.
|
||||||
|
Mapping values can be strings for broad category-only compatibility, or objects
|
||||||
|
with `category` and optional `subcategory`, using local record IDs or canonical
|
||||||
|
names:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"categoryMapping": {
|
||||||
|
"Ride": { "category": "Biking", "subcategory": "Road" },
|
||||||
|
"GravelRide": { "category": "Biking", "subcategory": "Gravel" },
|
||||||
|
"Hike": "Hiking"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
Plugins may describe provider-owned category values for the settings UI with
|
Plugins may describe provider-owned category values for the settings UI with
|
||||||
`metadata.providerCategories`. This is display-only metadata; `categoryMapping`
|
`metadata.providerCategories`. This is display-only metadata; `categoryMapping`
|
||||||
keys still use the raw provider category values emitted as
|
keys still use the raw provider category values emitted as
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
"github.com/open-wanderer/wanderer/plugins/sdk"
|
"github.com/open-wanderer/wanderer/plugins/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const hammerheadJSONMaxBytes int64 = 16 * 1024 * 1024
|
||||||
|
|
||||||
type hammerheadClient struct {
|
type hammerheadClient struct {
|
||||||
userID string
|
userID string
|
||||||
token string
|
token string
|
||||||
@@ -37,7 +39,7 @@ func login(email string, password string) (string, error) {
|
|||||||
},
|
},
|
||||||
Expect: sdk.ResponseExpect{
|
Expect: sdk.ResponseExpect{
|
||||||
ContentTypes: []string{"application/json"},
|
ContentTypes: []string{"application/json"},
|
||||||
MaxBytes: 1048576,
|
MaxBytes: hammerheadJSONMaxBytes,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
response, body, err := sdk.HostRequest(spec)
|
response, body, err := sdk.HostRequest(spec)
|
||||||
@@ -92,7 +94,7 @@ func (c hammerheadClient) get(path string, query []sdk.QueryParam, out any) erro
|
|||||||
},
|
},
|
||||||
Expect: sdk.ResponseExpect{
|
Expect: sdk.ResponseExpect{
|
||||||
ContentTypes: []string{"application/json"},
|
ContentTypes: []string{"application/json"},
|
||||||
MaxBytes: 1048576,
|
MaxBytes: hammerheadJSONMaxBytes,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ func prepareTrailSendV1() int32 {
|
|||||||
},
|
},
|
||||||
Expect: sdk.ResponseExpect{
|
Expect: sdk.ResponseExpect{
|
||||||
ContentTypes: []string{"application/json"},
|
ContentTypes: []string{"application/json"},
|
||||||
MaxBytes: 1048576,
|
MaxBytes: hammerheadJSONMaxBytes,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"maxBytes": 1048576,
|
"maxBytes": 16777216,
|
||||||
"contentTypes": [
|
"contentTypes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -101,31 +101,31 @@
|
|||||||
"hostConfig": {
|
"hostConfig": {
|
||||||
"categoryMapping": {
|
"categoryMapping": {
|
||||||
"hike": "Hiking",
|
"hike": "Hiking",
|
||||||
"mountaineering": "Hiking",
|
"mountaineering": { "category": "Hiking", "subcategory": "Alpine" },
|
||||||
"racebike": "Biking",
|
"racebike": { "category": "Biking", "subcategory": "Road" },
|
||||||
"e_racebike": "Biking",
|
"e_racebike": { "category": "Biking", "subcategory": "E-Bike" },
|
||||||
"touringbicycle": "Biking",
|
"touringbicycle": { "category": "Biking", "subcategory": "Touring" },
|
||||||
"e_touringbicycle": "Biking",
|
"e_touringbicycle": { "category": "Biking", "subcategory": "Touring" },
|
||||||
"mtb": "Biking",
|
"mtb": { "category": "Biking", "subcategory": "MTB" },
|
||||||
"e_mtb": "Biking",
|
"e_mtb": { "category": "Biking", "subcategory": "MTB" },
|
||||||
"mtb_easy": "Biking",
|
"mtb_easy": { "category": "Biking", "subcategory": "MTB" },
|
||||||
"e_mtb_easy": "Biking",
|
"e_mtb_easy": { "category": "Biking", "subcategory": "MTB" },
|
||||||
"mtb_advanced": "Biking",
|
"mtb_advanced": { "category": "Biking", "subcategory": "MTB" },
|
||||||
"e_mtb_advanced": "Biking",
|
"e_mtb_advanced": { "category": "Biking", "subcategory": "MTB" },
|
||||||
"downhillbike": "Biking",
|
"downhillbike": { "category": "Biking", "subcategory": "MTB" },
|
||||||
"unicycle": "Biking",
|
"unicycle": "Biking",
|
||||||
"citybike": "Biking",
|
"citybike": { "category": "Biking", "subcategory": "Touring" },
|
||||||
"jogging": "Walking",
|
"jogging": { "category": "Running", "subcategory": "Road" },
|
||||||
"nordicwalking": "Walking",
|
"nordicwalking": "Walking",
|
||||||
"skaten": "Walking",
|
"skaten": "Walking",
|
||||||
"other": "Walking",
|
"other": "Walking",
|
||||||
"climbing": "Climbing",
|
"climbing": "Climbing",
|
||||||
"nordic": "Skiing",
|
"nordic": { "category": "Skiing", "subcategory": "Cross-country" },
|
||||||
"skialpin": "Skiing",
|
"skialpin": "Skiing",
|
||||||
"skitour": "Skiing",
|
"skitour": { "category": "Skiing", "subcategory": "Backcountry" },
|
||||||
"sled": "Skiing",
|
"sled": "Skiing",
|
||||||
"snowboard": "Skiing",
|
"snowboard": "Skiing",
|
||||||
"snowshoe": "Skiing"
|
"snowshoe": { "category": "Hiking", "subcategory": "Snowshoeing" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
|
|||||||
@@ -412,7 +412,17 @@
|
|||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"categoryMapping": {
|
"categoryMapping": {
|
||||||
"$ref": "#/definitions/stringMap"
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/categoryMappingTarget"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"connectors": {
|
"connectors": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -476,6 +486,18 @@
|
|||||||
"$ref": "#/definitions/stringMap"
|
"$ref": "#/definitions/stringMap"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"categoryMappingTarget": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"category": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subcategory": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
plugins/strava/activity_type.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
func activityTypeFromProvider(value string) string {
|
||||||
|
switch value {
|
||||||
|
case "AlpineSki", "BackcountrySki", "IceSkate", "NordicSki", "RollerSki", "Snowboard":
|
||||||
|
return "skiing"
|
||||||
|
case "Canoeing", "Kayaking", "Kitesurf", "Rowing", "Sail", "StandUpPaddling", "Surfing", "Windsurf":
|
||||||
|
return "canoeing"
|
||||||
|
case "Hike", "Snowshoe":
|
||||||
|
return "hiking"
|
||||||
|
case "Run", "TrailRun", "VirtualRun":
|
||||||
|
return "running"
|
||||||
|
case "Walk", "Golf", "Skateboard", "Wheelchair":
|
||||||
|
return "walking"
|
||||||
|
case "Ride", "EBikeRide", "Handcycle", "InlineSkate", "Velomobile", "VirtualRide":
|
||||||
|
return "biking"
|
||||||
|
case "RockClimbing":
|
||||||
|
return "climbing"
|
||||||
|
default:
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -208,21 +208,5 @@ func providerActivityType(activity *detailedActivity) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func activityType(activity *detailedActivity) string {
|
func activityType(activity *detailedActivity) string {
|
||||||
value := providerActivityType(activity)
|
return activityTypeFromProvider(providerActivityType(activity))
|
||||||
switch value {
|
|
||||||
case "AlpineSki", "BackcountrySki", "IceSkate", "NordicSki", "RollerSki", "Snowboard":
|
|
||||||
return "skiing"
|
|
||||||
case "Canoeing", "Kayaking", "Kitesurf", "Rowing", "Sail", "StandUpPaddling", "Surfing", "Windsurf":
|
|
||||||
return "canoeing"
|
|
||||||
case "Hike", "Snowshoe":
|
|
||||||
return "hiking"
|
|
||||||
case "Run", "VirtualRun", "Walk", "Golf", "Skateboard", "Wheelchair":
|
|
||||||
return "walking"
|
|
||||||
case "Ride", "EBikeRide", "Handcycle", "InlineSkate", "Velomobile", "VirtualRide":
|
|
||||||
return "biking"
|
|
||||||
case "RockClimbing":
|
|
||||||
return "climbing"
|
|
||||||
default:
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
18
plugins/strava/mapper_test.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestActivityTypeMapsRunsToRunning(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"Run": "running",
|
||||||
|
"TrailRun": "running",
|
||||||
|
"VirtualRun": "running",
|
||||||
|
"Walk": "walking",
|
||||||
|
}
|
||||||
|
|
||||||
|
for providerType, want := range cases {
|
||||||
|
if got := activityTypeFromProvider(providerType); got != want {
|
||||||
|
t.Fatalf("activityTypeFromProvider(%q) = %q, want %q", providerType, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
"oauth_access_token"
|
"oauth_access_token"
|
||||||
],
|
],
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"maxBytes": 1048576,
|
"maxBytes": 16777216,
|
||||||
"contentTypes": [
|
"contentTypes": [
|
||||||
"application/json",
|
"application/json",
|
||||||
"application/gpx+xml",
|
"application/gpx+xml",
|
||||||
@@ -145,15 +145,15 @@
|
|||||||
"route:1": "Biking",
|
"route:1": "Biking",
|
||||||
"route:2": "Walking",
|
"route:2": "Walking",
|
||||||
"AlpineSki": "Skiing",
|
"AlpineSki": "Skiing",
|
||||||
"BackcountrySki": "Skiing",
|
"BackcountrySki": { "category": "Skiing", "subcategory": "Backcountry" },
|
||||||
"Badminton": "Other",
|
"Badminton": "Other",
|
||||||
"Canoeing": "Canoeing",
|
"Canoeing": "Canoeing",
|
||||||
"Crossfit": "Workout",
|
"Crossfit": "Other",
|
||||||
"EBikeRide": "Biking",
|
"EBikeRide": { "category": "Biking", "subcategory": "E-Bike" },
|
||||||
"EMountainBikeRide": "Biking",
|
"EMountainBikeRide": { "category": "Biking", "subcategory": "E-Bike" },
|
||||||
"Elliptical": "Workout",
|
"Elliptical": "Other",
|
||||||
"Golf": "Other",
|
"Golf": "Other",
|
||||||
"GravelRide": "Biking",
|
"GravelRide": { "category": "Biking", "subcategory": "Gravel" },
|
||||||
"Handcycle": "Biking",
|
"Handcycle": "Biking",
|
||||||
"HighIntensityIntervalTraining": "Other",
|
"HighIntensityIntervalTraining": "Other",
|
||||||
"Hike": "Hiking",
|
"Hike": "Hiking",
|
||||||
@@ -161,40 +161,40 @@
|
|||||||
"InlineSkate": "Walking",
|
"InlineSkate": "Walking",
|
||||||
"Kayaking": "Canoeing",
|
"Kayaking": "Canoeing",
|
||||||
"Kitesurf": "Canoeing",
|
"Kitesurf": "Canoeing",
|
||||||
"MountainBikeRide": "Biking",
|
"MountainBikeRide": { "category": "Biking", "subcategory": "MTB" },
|
||||||
"NordicSki": "Skiing",
|
"NordicSki": { "category": "Skiing", "subcategory": "Cross-country" },
|
||||||
"Pickleball": "Other",
|
"Pickleball": "Other",
|
||||||
"Pilates": "Other",
|
"Pilates": "Other",
|
||||||
"Racquetball": "Other",
|
"Racquetball": "Other",
|
||||||
"Ride": "Biking",
|
"Ride": { "category": "Biking", "subcategory": "Road" },
|
||||||
"RockClimbing": "Climbing",
|
"RockClimbing": "Climbing",
|
||||||
"RollerSki": "Skiing",
|
"RollerSki": { "category": "Skiing", "subcategory": "Cross-country" },
|
||||||
"Rowing": "Canoeing",
|
"Rowing": "Canoeing",
|
||||||
"Run": "Walking",
|
"Run": { "category": "Running", "subcategory": "Road" },
|
||||||
"Sail": "Canoeing",
|
"Sail": "Canoeing",
|
||||||
"Skateboard": "Walking",
|
"Skateboard": "Walking",
|
||||||
"Snowboard": "Skiing",
|
"Snowboard": "Skiing",
|
||||||
"Snowshoe": "Hiking",
|
"Snowshoe": { "category": "Hiking", "subcategory": "Snowshoeing" },
|
||||||
"Soccer": "Other",
|
"Soccer": "Other",
|
||||||
"Squash": "Other",
|
"Squash": "Other",
|
||||||
"StairStepper": "Workout",
|
"StairStepper": "Other",
|
||||||
"StandUpPaddling": "Canoeing",
|
"StandUpPaddling": "Canoeing",
|
||||||
"Surfing": "Canoeing",
|
"Surfing": "Canoeing",
|
||||||
"Swim": "Other",
|
"Swim": "Other",
|
||||||
"TableTennis": "Other",
|
"TableTennis": "Other",
|
||||||
"Tennis": "Other",
|
"Tennis": "Other",
|
||||||
"TrailRun": "Other",
|
"TrailRun": { "category": "Running", "subcategory": "Trail" },
|
||||||
"Training": "Other",
|
"Training": "Other",
|
||||||
"Velomobile": "Biking",
|
"Velomobile": "Biking",
|
||||||
"VirtualRide": "Biking",
|
"VirtualRide": "Biking",
|
||||||
"VirtualRow": "Other",
|
"VirtualRow": "Other",
|
||||||
"VirtualRun": "Walking",
|
"VirtualRun": "Running",
|
||||||
"Walk": "Walking",
|
"Walk": "Walking",
|
||||||
"WeightTraining": "Workout",
|
"WeightTraining": "Other",
|
||||||
"Wheelchair": "Walking",
|
"Wheelchair": "Walking",
|
||||||
"Windsurf": "Canoeing",
|
"Windsurf": "Canoeing",
|
||||||
"Workout": "Workout",
|
"Workout": "Other",
|
||||||
"Yoga": "Workout"
|
"Yoga": "Other"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import (
|
|||||||
"github.com/open-wanderer/wanderer/plugins/sdk"
|
"github.com/open-wanderer/wanderer/plugins/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const stravaDownloadMaxBytes int64 = 16 * 1024 * 1024
|
||||||
|
|
||||||
// Strava is migrating its API host: the new host "https://www.api-v3.strava.com"
|
// Strava is migrating its API host: the new host "https://www.api-v3.strava.com"
|
||||||
// is available from 2027-01-04 and the old one is retired on 2027-06-01 (June
|
// is available from 2027-01-04 and the old one is retired on 2027-06-01 (June
|
||||||
// 2026 Developer Program update). We cut over on 2027-03-01 — after the new host
|
// 2026 Developer Program update). We cut over on 2027-03-01 — after the new host
|
||||||
@@ -132,7 +134,7 @@ func (c *stravaClient) request(path string, query []sdk.QueryParam, contentTypes
|
|||||||
},
|
},
|
||||||
Expect: sdk.ResponseExpect{
|
Expect: sdk.ResponseExpect{
|
||||||
ContentTypes: contentTypes,
|
ContentTypes: contentTypes,
|
||||||
MaxBytes: 1048576,
|
MaxBytes: stravaDownloadMaxBytes,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
1869
web/package-lock.json
generated
@@ -15,30 +15,30 @@
|
|||||||
"test:unit": "vitest --passWithNoTests"
|
"test:unit": "vitest --passWithNoTests"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.61.1",
|
||||||
"@sveltejs/adapter-auto": "^7.0.1",
|
"@sveltejs/adapter-auto": "^7.0.1",
|
||||||
"@sveltejs/enhanced-img": "^0.10.4",
|
"@sveltejs/enhanced-img": "^0.11.0",
|
||||||
"@sveltejs/kit": "^2.60.1",
|
"@sveltejs/kit": "^2.68.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.20",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/node": "^25.3.3",
|
"@types/node": "^26.0.1",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.16",
|
||||||
"svelte": "^5.55.7",
|
"svelte": "^5.56.4",
|
||||||
"svelte-check": "^4.3.6",
|
"svelte-check": "^4.7.1",
|
||||||
"sveltekit-openapi-generator": "^0.1.6",
|
"sveltekit-openapi-generator": "^0.1.6",
|
||||||
"tslib": "^2.4.1",
|
"tslib": "^2.8.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^8.0.16"
|
"vite": "8.1.1"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@felte/validator-zod": "^1.0.18",
|
"@felte/validator-zod": "^1.0.18",
|
||||||
"@fortawesome/fontawesome-free": "^7.1.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
"@sveltejs/adapter-node": "^5.5.2",
|
"@sveltejs/adapter-node": "^5.5.7",
|
||||||
"@tailwindcss/vite": "^4.2.4",
|
"@tailwindcss/vite": "^4.3.2",
|
||||||
"@threlte/core": "^8.5.9",
|
"@threlte/core": "^8.5.16",
|
||||||
"@threlte/extras": "^9.14.6",
|
"@threlte/extras": "^9.21.0",
|
||||||
"@tiptap/core": "^3.27.1",
|
"@tiptap/core": "^3.27.1",
|
||||||
"@tiptap/extension-heading": "^3.27.1",
|
"@tiptap/extension-heading": "^3.27.1",
|
||||||
"@tiptap/extension-link": "^3.27.1",
|
"@tiptap/extension-link": "^3.27.1",
|
||||||
@@ -47,42 +47,42 @@
|
|||||||
"@tiptap/pm": "^3.27.1",
|
"@tiptap/pm": "^3.27.1",
|
||||||
"@tiptap/starter-kit": "^3.27.1",
|
"@tiptap/starter-kit": "^3.27.1",
|
||||||
"@tiptap/suggestion": "^3.27.1",
|
"@tiptap/suggestion": "^3.27.1",
|
||||||
"@turf/destination": "^7.3.3",
|
"@turf/destination": "^7.3.5",
|
||||||
"@turf/distance": "^7.3.3",
|
"@turf/distance": "^7.3.5",
|
||||||
"@types/chart.js": "^4.0.1",
|
"@types/chart.js": "^4.0.1",
|
||||||
"@types/supercluster": "^7.1.3",
|
"@types/supercluster": "^7.1.3",
|
||||||
"@types/three": "^0.183.1",
|
"@types/three": "^0.185.0",
|
||||||
"@xmldom/xmldom": "^0.8.12",
|
"@xmldom/xmldom": "^0.9.10",
|
||||||
"activitypub-types": "^1.1.0",
|
"activitypub-types": "^1.1.0",
|
||||||
"autoprefixer": "^10.4.24",
|
"autoprefixer": "^10.5.2",
|
||||||
"canvas-confetti": "^1.9.4",
|
"canvas-confetti": "^1.9.4",
|
||||||
"canvg": "^4.0.3",
|
"canvg": "^4.0.3",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"chartjs-plugin-crosshair": "^2.0.0",
|
"chartjs-plugin-crosshair": "^2.0.0",
|
||||||
"chartjs-plugin-zoom": "^2.1.0",
|
"chartjs-plugin-zoom": "^2.2.0",
|
||||||
"chokidar": "^5.0.0",
|
"chokidar": "^5.0.0",
|
||||||
"crypto-random-string": "^5.0.0",
|
"crypto-random-string": "^5.0.0",
|
||||||
"felte": "^1.3.0",
|
"felte": "^1.3.0",
|
||||||
"heic2any": "^0.0.4",
|
"heic2any": "^0.0.4",
|
||||||
"instead": "^1.0.3",
|
"instead": "^1.0.3",
|
||||||
"isomorphic-xml2js": "^0.1.3",
|
"isomorphic-xml2js": "^0.1.3",
|
||||||
"json-diff-ts": "^4.8.2",
|
"json-diff-ts": "^4.10.4",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"maplibre-gl": "^4.7.1",
|
"maplibre-gl": "^5.24.0",
|
||||||
"marked": "^17.0.4",
|
"marked": "^18.0.5",
|
||||||
"meilisearch": "^0.57.0",
|
"meilisearch": "^0.58.0",
|
||||||
"ngeohash": "^0.6.3",
|
"ngeohash": "^0.6.3",
|
||||||
"nouislider": "^15.7.1",
|
"nouislider": "^15.8.1",
|
||||||
"pdfkit": "^0.17.2",
|
"pdfkit": "^0.19.1",
|
||||||
"photoswipe": "^5.4.3",
|
"photoswipe": "^5.4.4",
|
||||||
"pocketbase": "^0.26.8",
|
"pocketbase": "^0.27.0",
|
||||||
"qrcode": "^1.4.4",
|
"qrcode": "^1.5.4",
|
||||||
"supercluster": "^8.0.1",
|
"supercluster": "^8.0.1",
|
||||||
"svelte-i18n": "^4.0.0",
|
"svelte-i18n": "^4.0.1",
|
||||||
"tailwindcss": "^4.2.4",
|
"tailwindcss": "^4.3.2",
|
||||||
"three": "^0.183.1",
|
"three": "^0.185.0",
|
||||||
"vitest": "^4.1.4",
|
"vitest": "^4.1.9",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
@reference "./app.css";
|
@reference "./app.css";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--tooltip-background: rgba(36, 39, 52, 0.75);
|
||||||
|
--tooltip-border-radius: 4px;
|
||||||
|
--tooltip-color: #fff;
|
||||||
|
--tooltip-font-size: 12px;
|
||||||
|
--tooltip-offset-top: 24px;
|
||||||
|
--tooltip-padding: 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
@apply min-h-10 text-white rounded-lg px-4 py-2 bg-primary font-semibold transition-all hover:bg-primary-hover focus:ring-4 ring-input-ring
|
@apply min-h-10 text-white rounded-lg px-4 py-2 bg-primary font-semibold transition-all hover:bg-primary-hover focus:ring-4 ring-input-ring
|
||||||
}
|
}
|
||||||
@@ -58,13 +67,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tooltip:before {
|
.tooltip:before {
|
||||||
background: rgba(36, 39, 52, 0.75);
|
background: var(--tooltip-background);
|
||||||
border-radius: 4px;
|
border-radius: var(--tooltip-border-radius);
|
||||||
color: #fff;
|
color: var(--tooltip-color);
|
||||||
content: attr(data-title);
|
content: attr(data-title);
|
||||||
font-size: 12px;
|
font-size: var(--tooltip-font-size);
|
||||||
padding: 6px 10px;
|
padding: var(--tooltip-padding);
|
||||||
top: 24px;
|
top: var(--tooltip-offset-top);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
z-index: 10
|
z-index: 10
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { SummitLog } from "$lib/models/summit_log";
|
import type { SummitLog } from "$lib/models/summit_log";
|
||||||
import { range } from "$lib/util/array_util";
|
import { range } from "$lib/util/array_util";
|
||||||
|
import { displayCategoryName } from "$lib/util/category_util";
|
||||||
import { isSameDay, isToday } from "../../util/date_util";
|
import { isSameDay, isToday } from "../../util/date_util";
|
||||||
import { _, date } from "svelte-i18n";
|
import { _, date, locale } from "svelte-i18n";
|
||||||
interface Props {
|
interface Props {
|
||||||
logs?: SummitLog[];
|
logs?: SummitLog[];
|
||||||
colorMap?: Record<string, string>;
|
colorMap?: Record<string, string>;
|
||||||
@@ -99,7 +100,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function colorKey(a: typeof currentMonthArray, i: number) {
|
function colorKey(a: typeof currentMonthArray, i: number) {
|
||||||
return $_(a[i]?.log?.expand?.trail?.expand?.category?.name ?? "");
|
return displayCategoryName(
|
||||||
|
a[i]?.log?.expand?.trail?.expand?.category,
|
||||||
|
$locale,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDateClick(date?: Date) {
|
function handleDateClick(date?: Date) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
children?: Snippet<[any]>;
|
children?: Snippet<[any]>;
|
||||||
content?: Snippet;
|
content?: Snippet;
|
||||||
footer?: Snippet<[any]>;
|
footer?: Snippet<[any]>;
|
||||||
|
onclose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
children,
|
children,
|
||||||
content,
|
content,
|
||||||
footer,
|
footer,
|
||||||
|
onclose,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
export function openModal() {
|
export function openModal() {
|
||||||
@@ -42,6 +44,7 @@
|
|||||||
{id}
|
{id}
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
onclose={() => onclose?.()}
|
||||||
class="{size} max-h-full rounded-xl text-content"
|
class="{size} max-h-full rounded-xl text-content"
|
||||||
>
|
>
|
||||||
<!-- Modal content -->
|
<!-- Modal content -->
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Category } from "$lib/models/category";
|
|
||||||
import { getFileURL } from "$lib/util/file_util";
|
|
||||||
import { _ } from "svelte-i18n";
|
|
||||||
interface Props {
|
|
||||||
category: Category;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { category }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="category-card relative rounded-2xl shadow-md max-h-48 aspect-video overflow-hidden cursor-pointer"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
class="w-full h-full"
|
|
||||||
src={getFileURL(category, category.img)}
|
|
||||||
alt=""
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
class="absolute bottom-0 w-full h-1/2 bg-gradient-to-b from-transparent to-black opacity-50"
|
|
||||||
></div>
|
|
||||||
<h5 class="absolute text-white font-bold bottom-4 left-4 text-xl">
|
|
||||||
{$_(category.name)}
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.category-card img {
|
|
||||||
object-fit: cover;
|
|
||||||
transition: 0.25s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-card:hover img {
|
|
||||||
scale: 1.075;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Modal from "$lib/components/base/modal.svelte";
|
import Modal from "$lib/components/base/modal.svelte";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -12,6 +13,7 @@
|
|||||||
onconfirm?: () => void
|
onconfirm?: () => void
|
||||||
oncancel?: () => void
|
oncancel?: () => void
|
||||||
onalternative?: () => void
|
onalternative?: () => void
|
||||||
|
children?: Snippet
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -23,34 +25,52 @@
|
|||||||
id = "confirm-modal",
|
id = "confirm-modal",
|
||||||
onconfirm,
|
onconfirm,
|
||||||
oncancel,
|
oncancel,
|
||||||
onalternative
|
onalternative,
|
||||||
|
children,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let modal: Modal;
|
let modal: Modal;
|
||||||
|
let closingFromAction = false;
|
||||||
|
|
||||||
export function openModal() {
|
export function openModal() {
|
||||||
|
closingFromAction = false;
|
||||||
modal.openModal();
|
modal.openModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleModalClose() {
|
||||||
|
if (closingFromAction) {
|
||||||
|
closingFromAction = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
oncancel?.();
|
||||||
|
}
|
||||||
|
|
||||||
function cancel() {
|
function cancel() {
|
||||||
|
closingFromAction = true;
|
||||||
modal.closeModal!();
|
modal.closeModal!();
|
||||||
oncancel?.();
|
oncancel?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
function alternativeAction() {
|
function alternativeAction() {
|
||||||
|
closingFromAction = true;
|
||||||
modal.closeModal!();
|
modal.closeModal!();
|
||||||
onalternative?.();
|
onalternative?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirm() {
|
function confirm() {
|
||||||
|
closingFromAction = true;
|
||||||
modal.closeModal!();
|
modal.closeModal!();
|
||||||
onconfirm?.()
|
onconfirm?.()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal {id} {title} bind:this={modal}>
|
<Modal {id} {title} bind:this={modal} onclose={handleModalClose}>
|
||||||
{#snippet content()}
|
{#snippet content()}
|
||||||
<p>{text}</p>
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{:else}
|
||||||
|
<p>{text}</p>
|
||||||
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet footer()}
|
{#snippet footer()}
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
|
|||||||
@@ -10,7 +10,13 @@
|
|||||||
formatTimeHHMM,
|
formatTimeHHMM,
|
||||||
formatTimeSince,
|
formatTimeSince,
|
||||||
} from "$lib/util/format_util";
|
} from "$lib/util/format_util";
|
||||||
import { _ } from "svelte-i18n";
|
import {
|
||||||
|
displayCategoryIcon,
|
||||||
|
displayCategoryName,
|
||||||
|
displaySubcategoryIcon,
|
||||||
|
displaySubcategoryLabel,
|
||||||
|
} from "$lib/util/category_util";
|
||||||
|
import { _, locale } from "svelte-i18n";
|
||||||
import TrailDropdown from "../trail/trail_dropdown.svelte";
|
import TrailDropdown from "../trail/trail_dropdown.svelte";
|
||||||
interface Props {
|
interface Props {
|
||||||
feedItem: FeedItem;
|
feedItem: FeedItem;
|
||||||
@@ -26,11 +32,24 @@
|
|||||||
|
|
||||||
const photos = $derived((feedItem.expand.item as Trail).photos);
|
const photos = $derived((feedItem.expand.item as Trail).photos);
|
||||||
const location = $derived((feedItem.expand.item as Trail).location);
|
const location = $derived((feedItem.expand.item as Trail).location);
|
||||||
const category = $derived((feedItem.expand.item as Trail).expand?.category?.name);
|
const category = $derived(
|
||||||
|
(feedItem.expand.item as Trail).expand?.category,
|
||||||
|
);
|
||||||
|
const subcategory = $derived(
|
||||||
|
(feedItem.expand.item as Trail).expand?.subcategory,
|
||||||
|
);
|
||||||
|
|
||||||
const trails = $derived((feedItem.expand.item as List).trails);
|
const trails = $derived((feedItem.expand.item as List).trails);
|
||||||
|
|
||||||
const author = $derived(feedItem.expand.item.expand?.author);
|
const author = $derived(feedItem.expand.item.expand?.author);
|
||||||
|
|
||||||
|
function feedCategoryIcon() {
|
||||||
|
if (subcategory) {
|
||||||
|
return displaySubcategoryIcon(subcategory, category);
|
||||||
|
}
|
||||||
|
|
||||||
|
return displayCategoryIcon(category);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="feed-card px-6 py-4 rounded-xl border border-input-border">
|
<div class="feed-card px-6 py-4 rounded-xl border border-input-border">
|
||||||
@@ -78,7 +97,20 @@
|
|||||||
<div class="flex flex-wrap gap-x-8 gap-y-1">
|
<div class="flex flex-wrap gap-x-8 gap-y-1">
|
||||||
{#if category}
|
{#if category}
|
||||||
<p>
|
<p>
|
||||||
<i class="fa fa-shapes mr-3"> </i>{$_(category)}
|
<i
|
||||||
|
class="fa {feedCategoryIcon()} mr-3"
|
||||||
|
></i>{displayCategoryName(
|
||||||
|
category,
|
||||||
|
$locale,
|
||||||
|
)}
|
||||||
|
{#if subcategory}
|
||||||
|
<span class="text-gray-500">
|
||||||
|
/ {displaySubcategoryLabel(
|
||||||
|
subcategory,
|
||||||
|
$locale,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if location}
|
{#if location}
|
||||||
|
|||||||
@@ -6,11 +6,18 @@
|
|||||||
import TextField from "$lib/components/base/text_field.svelte";
|
import TextField from "$lib/components/base/text_field.svelte";
|
||||||
import Toggle from "$lib/components/base/toggle.svelte";
|
import Toggle from "$lib/components/base/toggle.svelte";
|
||||||
import PluginMergeSettings from "$lib/components/settings/plugins/plugin_merge_settings.svelte";
|
import PluginMergeSettings from "$lib/components/settings/plugins/plugin_merge_settings.svelte";
|
||||||
|
import CategoryPicker from "$lib/components/trail/category_picker.svelte";
|
||||||
import type { Category } from "$lib/models/category";
|
import type { Category } from "$lib/models/category";
|
||||||
import type { PluginInstance } from "$lib/models/plugin_instance";
|
import type { PluginInstance } from "$lib/models/plugin_instance";
|
||||||
import type { ConfigField, PluginProvider } from "$lib/models/plugin_provider";
|
import type { ConfigField, PluginProvider } from "$lib/models/plugin_provider";
|
||||||
|
import type { Subcategory } from "$lib/models/subcategory";
|
||||||
import { plugin_auth_validate, plugin_oauth_start } from "$lib/stores/plugin_instance_store";
|
import { plugin_auth_validate, plugin_oauth_start } from "$lib/stores/plugin_instance_store";
|
||||||
import { show_toast } from "$lib/stores/toast_store.svelte";
|
import { show_toast } from "$lib/stores/toast_store.svelte";
|
||||||
|
import {
|
||||||
|
categoryMappingTargetFromUnknown,
|
||||||
|
categoryMappingTargetToPickerValue,
|
||||||
|
type CategoryMappingTarget,
|
||||||
|
} from "$lib/util/category_util";
|
||||||
import { translatePluginAPIError } from "$lib/util/plugin_error_i18n";
|
import { translatePluginAPIError } from "$lib/util/plugin_error_i18n";
|
||||||
import {
|
import {
|
||||||
configFieldDescription,
|
configFieldDescription,
|
||||||
@@ -34,12 +41,13 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
plugin: PluginProvider;
|
plugin: PluginProvider;
|
||||||
categories?: Category[];
|
categories?: Category[];
|
||||||
|
subcategories?: Subcategory[];
|
||||||
instance?: PluginInstance;
|
instance?: PluginInstance;
|
||||||
onbeforecategorymappingsave?: (instance: PluginInstanceForm) => Promise<boolean> | boolean;
|
onbeforecategorymappingsave?: (instance: PluginInstanceForm) => Promise<boolean> | boolean;
|
||||||
onsave?: (instance: Partial<PluginInstance>) => Promise<PluginInstance | void> | PluginInstance | void;
|
onsave?: (instance: Partial<PluginInstance>) => Promise<PluginInstance | void> | PluginInstance | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { plugin, categories = [], instance, onbeforecategorymappingsave, onsave }: Props = $props();
|
let { plugin, categories = [], subcategories = [], instance, onbeforecategorymappingsave, onsave }: Props = $props();
|
||||||
|
|
||||||
let modal: Modal;
|
let modal: Modal;
|
||||||
let auth: Record<string, string> = $state(initialAuth());
|
let auth: Record<string, string> = $state(initialAuth());
|
||||||
@@ -73,14 +81,6 @@
|
|||||||
);
|
);
|
||||||
let mergeAvailable = $derived((hostConfig().merge as any)?.available !== false);
|
let mergeAvailable = $derived((hostConfig().merge as any)?.available !== false);
|
||||||
let supportsCategoryMapping = $derived(supportsPlanned || supportsCompleted);
|
let supportsCategoryMapping = $derived(supportsPlanned || supportsCompleted);
|
||||||
let categorySelectItems: SelectItem[] = $derived(
|
|
||||||
categories
|
|
||||||
.map((category) => ({
|
|
||||||
text: $_(category.name),
|
|
||||||
value: category.id,
|
|
||||||
}))
|
|
||||||
.sort((a, b) => a.text.localeCompare(b.text, $locale ?? undefined)),
|
|
||||||
);
|
|
||||||
let providerCategorySelectItems: SelectItem[] = $derived(providerCategoryItems());
|
let providerCategorySelectItems: SelectItem[] = $derived(providerCategoryItems());
|
||||||
let canAddCategoryMappingRow = $derived(
|
let canAddCategoryMappingRow = $derived(
|
||||||
categoryMappingRows.every((row) => row.providerCategory && row.category) &&
|
categoryMappingRows.every((row) => row.providerCategory && row.category) &&
|
||||||
@@ -143,52 +143,108 @@
|
|||||||
|
|
||||||
function initialCategoryMappingRows(): CategoryMappingRow[] {
|
function initialCategoryMappingRows(): CategoryMappingRow[] {
|
||||||
return Object.entries(categoryMapping())
|
return Object.entries(categoryMapping())
|
||||||
.filter(([, category]) => category !== "")
|
.filter(([, target]) => !isBlankCategoryMappingTarget(target))
|
||||||
.map(([providerCategory, category]) => ({
|
.map(([providerCategory, target]) => ({
|
||||||
providerCategory,
|
providerCategory,
|
||||||
category: categoryTargetValue(category),
|
category: categoryPickerValue(target),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function manifestCategoryMapping(): Record<string, string> {
|
function manifestCategoryMapping(): Record<string, CategoryMappingTarget> {
|
||||||
const raw = plugin.hostConfig?.categoryMapping;
|
const raw = plugin.hostConfig?.categoryMapping;
|
||||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
return stringMapping(raw as Record<string, unknown>);
|
return categoryTargetMapping(raw as Record<string, unknown>);
|
||||||
}
|
}
|
||||||
|
|
||||||
function categoryMapping(): Record<string, string> {
|
function categoryMapping(): Record<string, CategoryMappingTarget> {
|
||||||
const raw = hostConfig().categoryMapping;
|
const raw = hostConfig().categoryMapping;
|
||||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
return stringMapping(raw as Record<string, unknown>);
|
return categoryTargetMapping(raw as Record<string, unknown>);
|
||||||
}
|
}
|
||||||
|
|
||||||
function categoryMappingsEqual(
|
function categoryMappingsEqual(
|
||||||
left: Record<string, string>,
|
left: Record<string, CategoryMappingTarget>,
|
||||||
right: Record<string, string>,
|
right: Record<string, CategoryMappingTarget>,
|
||||||
) {
|
) {
|
||||||
const leftKeys = Object.keys(left).sort();
|
const leftKeys = Object.keys(left).sort();
|
||||||
const rightKeys = Object.keys(right).sort();
|
const rightKeys = Object.keys(right).sort();
|
||||||
if (leftKeys.length !== rightKeys.length) {
|
if (leftKeys.length !== rightKeys.length) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return leftKeys.every((key, index) => key === rightKeys[index] && left[key] === right[key]);
|
return leftKeys.every(
|
||||||
}
|
(key, index) =>
|
||||||
|
key === rightKeys[index] &&
|
||||||
function stringMapping(raw: Record<string, unknown>): Record<string, string> {
|
normalizedCategoryMappingTarget(left[key]) === normalizedCategoryMappingTarget(right[key]),
|
||||||
return Object.fromEntries(
|
|
||||||
Object.entries(raw)
|
|
||||||
.filter(([, value]) => typeof value === "string")
|
|
||||||
.map(([key, value]) => [key, value as string]),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function categoryTargetValue(value: string): string {
|
function categoryTargetMapping(raw: Record<string, unknown>): Record<string, CategoryMappingTarget> {
|
||||||
const match = categories.find((category) => category.id === value || category.name === value);
|
return Object.fromEntries(
|
||||||
return match?.id ?? value;
|
Object.entries(raw)
|
||||||
|
.map(([key, value]) => [
|
||||||
|
key,
|
||||||
|
categoryMappingTargetFromUnknown(value),
|
||||||
|
])
|
||||||
|
.filter(([, value]) => value !== undefined),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlankCategoryMappingTarget(target: CategoryMappingTarget): boolean {
|
||||||
|
if (typeof target === "string") {
|
||||||
|
return target.trim() === "";
|
||||||
|
}
|
||||||
|
return !target.category?.trim() && !target.subcategory?.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedCategoryMappingTarget(target: CategoryMappingTarget): string {
|
||||||
|
if (typeof target === "string") {
|
||||||
|
return target.trim();
|
||||||
|
}
|
||||||
|
return JSON.stringify({
|
||||||
|
category: target.category?.trim() ?? "",
|
||||||
|
subcategory: target.subcategory?.trim() ?? "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryPickerValue(target: CategoryMappingTarget): string {
|
||||||
|
return categoryMappingTargetToPickerValue(
|
||||||
|
target,
|
||||||
|
categories,
|
||||||
|
subcategories,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryMappingTargetFromPickerValue(value: string): CategoryMappingTarget {
|
||||||
|
if (value.startsWith("subcategory:")) {
|
||||||
|
const subcategoryId = value.replace("subcategory:", "");
|
||||||
|
const subcategory = subcategories.find((candidate) => candidate.id === subcategoryId);
|
||||||
|
return {
|
||||||
|
category: subcategory?.category ?? "",
|
||||||
|
subcategory: subcategoryId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (value.startsWith("category:")) {
|
||||||
|
return {
|
||||||
|
category: value.replace("category:", ""),
|
||||||
|
subcategory: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryPickerCurrentCategoryId(value: string): string | null {
|
||||||
|
if (value.startsWith("category:")) {
|
||||||
|
return value.replace("category:", "");
|
||||||
|
}
|
||||||
|
if (value.startsWith("subcategory:")) {
|
||||||
|
const subcategoryId = value.replace("subcategory:", "");
|
||||||
|
return subcategories.find((candidate) => candidate.id === subcategoryId)?.category ?? null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function providerCategoryItems(): SelectItem[] {
|
function providerCategoryItems(): SelectItem[] {
|
||||||
@@ -389,7 +445,7 @@
|
|||||||
...currentMergeConfig,
|
...currentMergeConfig,
|
||||||
enabled: mergeAvailable && mergeEnabled,
|
enabled: mergeAvailable && mergeEnabled,
|
||||||
};
|
};
|
||||||
const categoryMappingConfig: Record<string, string> = {};
|
const categoryMappingConfig: Record<string, CategoryMappingTarget> = {};
|
||||||
const assignedProviderCategories = new Set<string>();
|
const assignedProviderCategories = new Set<string>();
|
||||||
for (const row of categoryMappingRows) {
|
for (const row of categoryMappingRows) {
|
||||||
const providerCategory = row.providerCategory.trim();
|
const providerCategory = row.providerCategory.trim();
|
||||||
@@ -397,7 +453,7 @@
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
assignedProviderCategories.add(providerCategory);
|
assignedProviderCategories.add(providerCategory);
|
||||||
categoryMappingConfig[providerCategory] = row.category;
|
categoryMappingConfig[providerCategory] = categoryMappingTargetFromPickerValue(row.category);
|
||||||
}
|
}
|
||||||
for (const providerCategory of [
|
for (const providerCategory of [
|
||||||
...Object.keys(manifestCategoryMapping()),
|
...Object.keys(manifestCategoryMapping()),
|
||||||
@@ -651,7 +707,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if supportsCategoryMapping && categorySelectItems.length > 0}
|
{#if supportsCategoryMapping && categories.length > 0}
|
||||||
<div class="space-y-2 pt-4 border-t border-input-border">
|
<div class="space-y-2 pt-4 border-t border-input-border">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -671,7 +727,7 @@
|
|||||||
{#if categoryMappingRows.length > 0}
|
{#if categoryMappingRows.length > 0}
|
||||||
<div class="hidden md:grid grid-cols-[minmax(0,1.2fr)_minmax(14rem,1fr)_2.75rem] gap-3 text-sm font-medium">
|
<div class="hidden md:grid grid-cols-[minmax(0,1.2fr)_minmax(14rem,1fr)_2.75rem] gap-3 text-sm font-medium">
|
||||||
<span>{$_("provider-category")}</span>
|
<span>{$_("provider-category")}</span>
|
||||||
<span>{$_("category")}</span>
|
<span>{$_("category")} / {$_("subcategory")}</span>
|
||||||
<span></span>
|
<span></span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -694,13 +750,18 @@
|
|||||||
bind:value={row.providerCategory}
|
bind:value={row.providerCategory}
|
||||||
disabled={row.providerCategory !== "" && providerItems.length <= 1}
|
disabled={row.providerCategory !== "" && providerItems.length <= 1}
|
||||||
></SingleSelect>
|
></SingleSelect>
|
||||||
<SingleSelect
|
<CategoryPicker
|
||||||
ariaLabel={$_("category")}
|
value={row.category}
|
||||||
|
label=""
|
||||||
placeholder={$_("select-category")}
|
placeholder={$_("select-category")}
|
||||||
items={categorySelectItems}
|
currentCategoryId={categoryPickerCurrentCategoryId(row.category)}
|
||||||
bind:value={row.category}
|
fixedDropdown
|
||||||
disabled={row.category !== "" && categorySelectItems.length <= 1}
|
onchange={(selection) => {
|
||||||
></SingleSelect>
|
row.category = selection.subcategory
|
||||||
|
? `subcategory:${selection.subcategory}`
|
||||||
|
: `category:${selection.category}`;
|
||||||
|
}}
|
||||||
|
></CategoryPicker>
|
||||||
<button
|
<button
|
||||||
class="btn-icon h-10"
|
class="btn-icon h-10"
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
formatHTMLAsText,
|
formatHTMLAsText,
|
||||||
formatTimeHHMM,
|
formatTimeHHMM,
|
||||||
} from "$lib/util/format_util";
|
} from "$lib/util/format_util";
|
||||||
import { _ } from "svelte-i18n";
|
import { displayCategoryName } from "$lib/util/category_util";
|
||||||
|
import { _, locale } from "svelte-i18n";
|
||||||
import PhotoGallery from "../photo_gallery.svelte";
|
import PhotoGallery from "../photo_gallery.svelte";
|
||||||
import Dropdown, { type DropdownItem } from "../base/dropdown.svelte";
|
import Dropdown, { type DropdownItem } from "../base/dropdown.svelte";
|
||||||
|
|
||||||
@@ -159,7 +160,14 @@
|
|||||||
</td>
|
</td>
|
||||||
{#if showCategory}
|
{#if showCategory}
|
||||||
<td>
|
<td>
|
||||||
{$_(log.expand?.trail?.expand?.category?.name ?? "-")}
|
{#if log.expand?.trail?.expand?.category}
|
||||||
|
{displayCategoryName(
|
||||||
|
log.expand.trail.expand.category,
|
||||||
|
$locale,
|
||||||
|
)}
|
||||||
|
{:else}
|
||||||
|
-
|
||||||
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
{/if}
|
{/if}
|
||||||
{#if showTrail}
|
{#if showTrail}
|
||||||
|
|||||||
340
web/src/lib/components/trail/category_picker.svelte
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
<script module lang="ts">
|
||||||
|
export type CategoryPickerSelection = {
|
||||||
|
category: string;
|
||||||
|
subcategory: string;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { categories, categories_index } from "$lib/stores/category_store";
|
||||||
|
import {
|
||||||
|
categoryPreferences,
|
||||||
|
category_preferences_index,
|
||||||
|
} from "$lib/stores/category_preference_store";
|
||||||
|
import {
|
||||||
|
subcategories,
|
||||||
|
subcategories_index,
|
||||||
|
} from "$lib/stores/subcategory_store";
|
||||||
|
import {
|
||||||
|
subcategoryPreferences,
|
||||||
|
subcategory_preferences_index,
|
||||||
|
} from "$lib/stores/subcategory_preference_store";
|
||||||
|
import {
|
||||||
|
designSelectableCategories,
|
||||||
|
displayCategoryIcon,
|
||||||
|
displayCategoryName,
|
||||||
|
displaySubcategoryBadgeIcon,
|
||||||
|
displaySubcategoryIcon,
|
||||||
|
displaySubcategoryLabel,
|
||||||
|
sortedSubcategoriesByPreference,
|
||||||
|
subcategoryVisible,
|
||||||
|
} from "$lib/util/category_util";
|
||||||
|
import { onDestroy, onMount } from "svelte";
|
||||||
|
import { _, locale } from "svelte-i18n";
|
||||||
|
|
||||||
|
type CategoryPickerItem = {
|
||||||
|
text: string;
|
||||||
|
detail?: string;
|
||||||
|
value: string;
|
||||||
|
icon: string;
|
||||||
|
badgeIcon?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value?: string;
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
hiddenInputs?: boolean;
|
||||||
|
loadData?: boolean;
|
||||||
|
currentCategoryId?: string | null;
|
||||||
|
fixedDropdown?: boolean;
|
||||||
|
onchange?: (selection: CategoryPickerSelection) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = "",
|
||||||
|
label = $_("category"),
|
||||||
|
placeholder = $_("category"),
|
||||||
|
hiddenInputs = false,
|
||||||
|
loadData = false,
|
||||||
|
currentCategoryId = null,
|
||||||
|
fixedDropdown = false,
|
||||||
|
onchange,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let buttonElement: HTMLButtonElement;
|
||||||
|
let dropdownElement: HTMLUListElement | undefined = $state();
|
||||||
|
let dropdownStyle = $state("");
|
||||||
|
let fixedDropdownListenersAttached = false;
|
||||||
|
let selectedSubcategoryId = $derived(
|
||||||
|
value.startsWith("subcategory:") ? value.replace("subcategory:", "") : "",
|
||||||
|
);
|
||||||
|
|
||||||
|
let items: CategoryPickerItem[] = $derived(
|
||||||
|
designSelectableCategories(
|
||||||
|
$categories,
|
||||||
|
$categoryPreferences,
|
||||||
|
$locale,
|
||||||
|
currentCategoryId,
|
||||||
|
).flatMap((category): CategoryPickerItem[] => [
|
||||||
|
{
|
||||||
|
text: displayCategoryName(category, $locale),
|
||||||
|
value: `category:${category.id}`,
|
||||||
|
icon: displayCategoryIcon(category),
|
||||||
|
},
|
||||||
|
...sortedSubcategoriesByPreference(
|
||||||
|
$subcategories.filter(
|
||||||
|
(subcategory) =>
|
||||||
|
subcategory.category === category.id &&
|
||||||
|
(subcategoryVisible(
|
||||||
|
subcategory.id,
|
||||||
|
$subcategoryPreferences,
|
||||||
|
) ||
|
||||||
|
subcategory.id === selectedSubcategoryId),
|
||||||
|
),
|
||||||
|
$subcategoryPreferences,
|
||||||
|
$locale,
|
||||||
|
)
|
||||||
|
.map((subcategory) => ({
|
||||||
|
text: displayCategoryName(category, $locale),
|
||||||
|
detail: displaySubcategoryLabel(subcategory, $locale),
|
||||||
|
value: `subcategory:${subcategory.id}`,
|
||||||
|
icon: displaySubcategoryIcon(subcategory, category),
|
||||||
|
badgeIcon: displaySubcategoryBadgeIcon(subcategory),
|
||||||
|
})),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
let selectedItem = $derived(items.find((item) => item.value === value));
|
||||||
|
let hiddenCategory = $derived(resolveSelection(value)?.category ?? "");
|
||||||
|
let hiddenSubcategory = $derived(resolveSelection(value)?.subcategory ?? "");
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
if (!loadData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
categories_index(),
|
||||||
|
subcategories_index(),
|
||||||
|
category_preferences_index(),
|
||||||
|
subcategory_preferences_index(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
function resolveSelection(nextValue: string): CategoryPickerSelection | undefined {
|
||||||
|
if (nextValue.startsWith("subcategory:")) {
|
||||||
|
const subcategoryId = nextValue.replace("subcategory:", "");
|
||||||
|
const subcategory = $subcategories.find(
|
||||||
|
(item) => item.id === subcategoryId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!subcategory) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
category: subcategory.category,
|
||||||
|
subcategory: subcategory.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextValue.startsWith("category:")) {
|
||||||
|
return {
|
||||||
|
category: nextValue.replace("category:", ""),
|
||||||
|
subcategory: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePickerClick(e: MouseEvent) {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWindowClick() {
|
||||||
|
closePicker();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDropdownPosition() {
|
||||||
|
if (!fixedDropdown || !buttonElement) {
|
||||||
|
dropdownStyle = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = buttonElement.getBoundingClientRect();
|
||||||
|
const viewportPadding = 16;
|
||||||
|
const left = Math.max(
|
||||||
|
viewportPadding,
|
||||||
|
Math.min(rect.left, window.innerWidth - viewportPadding - rect.width),
|
||||||
|
);
|
||||||
|
|
||||||
|
dropdownStyle = [
|
||||||
|
"position: fixed",
|
||||||
|
`top: ${rect.bottom + 4}px`,
|
||||||
|
`left: ${left}px`,
|
||||||
|
`min-width: ${rect.width}px`,
|
||||||
|
`max-height: min(18rem, calc(100vh - ${rect.bottom + viewportPadding + 4}px))`,
|
||||||
|
].join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFixedDropdownResize() {
|
||||||
|
if (!open || !fixedDropdown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDropdownPosition();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFixedDropdownScroll(e: Event) {
|
||||||
|
if (!open || !fixedDropdown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
e.target instanceof Node &&
|
||||||
|
dropdownElement?.contains(e.target)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
closePicker();
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachFixedDropdownListeners() {
|
||||||
|
if (!fixedDropdown || fixedDropdownListenersAttached) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("scroll", handleFixedDropdownScroll, true);
|
||||||
|
window.addEventListener("resize", handleFixedDropdownResize);
|
||||||
|
fixedDropdownListenersAttached = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detachFixedDropdownListeners() {
|
||||||
|
if (!fixedDropdownListenersAttached) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.removeEventListener("scroll", handleFixedDropdownScroll, true);
|
||||||
|
window.removeEventListener("resize", handleFixedDropdownResize);
|
||||||
|
fixedDropdownListenersAttached = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePicker() {
|
||||||
|
if (!open) {
|
||||||
|
updateDropdownPosition();
|
||||||
|
open = true;
|
||||||
|
attachFixedDropdownListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
closePicker();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePicker() {
|
||||||
|
open = false;
|
||||||
|
detachFixedDropdownListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectItem(item: CategoryPickerItem) {
|
||||||
|
const selection = resolveSelection(item.value);
|
||||||
|
if (!selection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = item.value;
|
||||||
|
onchange?.(selection);
|
||||||
|
closePicker();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
detachFixedDropdownListeners();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onmouseup={handleWindowClick} />
|
||||||
|
|
||||||
|
<div class="relative" role="presentation" onmouseup={handlePickerClick}>
|
||||||
|
{#if hiddenInputs}
|
||||||
|
<input type="hidden" name="category" value={hiddenCategory} />
|
||||||
|
<input type="hidden" name="subcategory" value={hiddenSubcategory} />
|
||||||
|
{/if}
|
||||||
|
{#if label}
|
||||||
|
<label for="category-picker" class="text-sm font-medium pb-1">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
id="category-picker"
|
||||||
|
bind:this={buttonElement}
|
||||||
|
type="button"
|
||||||
|
class="relative flex h-10 w-full cursor-pointer items-center justify-between gap-3 rounded-md border border-input-border bg-input-background px-4 pr-10 transition-colors focus:border-input-border-focus focus:outline-none focus:ring-0"
|
||||||
|
onclick={togglePicker}
|
||||||
|
>
|
||||||
|
<span class="flex min-w-0 items-center gap-3">
|
||||||
|
{#if selectedItem}
|
||||||
|
<span class="relative w-4 shrink-0 text-center">
|
||||||
|
<i class="fa {selectedItem.icon}"></i>
|
||||||
|
{#if selectedItem.badgeIcon}
|
||||||
|
<i
|
||||||
|
class="fa {selectedItem.badgeIcon} absolute -right-1 -top-1 text-[8px]"
|
||||||
|
></i>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="truncate">
|
||||||
|
{selectedItem.detail?.trim() ?? selectedItem.text.trim()}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<i
|
||||||
|
class="fa fa-shapes w-4 shrink-0 text-center text-gray-500"
|
||||||
|
></i>
|
||||||
|
<span class="truncate text-gray-500">
|
||||||
|
{placeholder}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<i
|
||||||
|
class="fa fa-caret-down absolute right-4 top-1/2 -translate-y-1/2 text-gray-500 transition-transform"
|
||||||
|
class:rotate-180={open}
|
||||||
|
></i>
|
||||||
|
</button>
|
||||||
|
{#if open}
|
||||||
|
<ul
|
||||||
|
bind:this={dropdownElement}
|
||||||
|
class="{fixedDropdown
|
||||||
|
? 'fixed z-50 min-w-full w-max max-w-[calc(100vw-2rem)] overflow-y-auto rounded-md border border-input-border bg-menu-background shadow-lg'
|
||||||
|
: 'absolute z-10 mt-1 max-h-72 min-w-full w-max max-w-[calc(100vw-2rem)] overflow-y-auto rounded-md border border-input-border bg-menu-background shadow-lg'}"
|
||||||
|
style={dropdownStyle}
|
||||||
|
>
|
||||||
|
{#each items as item}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus"
|
||||||
|
class:bg-menu-item-background-focus={item.value === value}
|
||||||
|
onclick={() => selectItem(item)}
|
||||||
|
>
|
||||||
|
<span class="relative w-4 shrink-0 text-center">
|
||||||
|
<i class="fa {item.icon}"></i>
|
||||||
|
{#if item.badgeIcon}
|
||||||
|
<i
|
||||||
|
class="fa {item.badgeIcon} absolute -right-1 -top-1 text-[8px]"
|
||||||
|
></i>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="whitespace-nowrap">
|
||||||
|
{item.text.trim()}
|
||||||
|
{#if item.detail}
|
||||||
|
<span class="text-gray-500">
|
||||||
|
/ {item.detail.trim()}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
167
web/src/lib/components/trail/trail_bulk_edit_modal.svelte
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
<script module lang="ts">
|
||||||
|
export type TrailBulkEditChanges = {
|
||||||
|
category?: {
|
||||||
|
category: string;
|
||||||
|
subcategory: string;
|
||||||
|
};
|
||||||
|
difficulty?: "easy" | "moderate" | "difficult";
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import Modal from "$lib/components/base/modal.svelte";
|
||||||
|
import Select from "$lib/components/base/select.svelte";
|
||||||
|
import CategoryPicker, {
|
||||||
|
type CategoryPickerSelection,
|
||||||
|
} from "./category_picker.svelte";
|
||||||
|
import { _ } from "svelte-i18n";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedCount?: number;
|
||||||
|
initialCategorySelection?: CategoryPickerSelection;
|
||||||
|
initialDifficulty?: TrailBulkEditChanges["difficulty"];
|
||||||
|
onapply?: (changes: TrailBulkEditChanges) => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
selectedCount = 0,
|
||||||
|
initialCategorySelection,
|
||||||
|
initialDifficulty,
|
||||||
|
onapply,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let modal: Modal;
|
||||||
|
let applyCategory = $state(false);
|
||||||
|
let applyDifficulty = $state(false);
|
||||||
|
let categorySelection: CategoryPickerSelection | undefined = $state();
|
||||||
|
let categoryValue = $state("");
|
||||||
|
let difficultyValue: "easy" | "moderate" | "difficult" = $state("easy");
|
||||||
|
let loading = $state(false);
|
||||||
|
|
||||||
|
let canApply = $derived(
|
||||||
|
(applyCategory && categorySelection !== undefined) || applyDifficulty,
|
||||||
|
);
|
||||||
|
|
||||||
|
export function openModal() {
|
||||||
|
applyCategory = false;
|
||||||
|
applyDifficulty = false;
|
||||||
|
categorySelection = initialCategorySelection;
|
||||||
|
categoryValue = categoryValueFromSelection(initialCategorySelection);
|
||||||
|
difficultyValue = initialDifficulty ?? "easy";
|
||||||
|
modal.openModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryValueFromSelection(selection?: CategoryPickerSelection) {
|
||||||
|
if (!selection) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return selection.subcategory
|
||||||
|
? `subcategory:${selection.subcategory}`
|
||||||
|
: `category:${selection.category}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCategoryChange(selection: CategoryPickerSelection) {
|
||||||
|
categorySelection = selection;
|
||||||
|
categoryValue = selection.subcategory
|
||||||
|
? `subcategory:${selection.subcategory}`
|
||||||
|
: `category:${selection.category}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apply(closeModal: () => void) {
|
||||||
|
if (!canApply) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const changes: TrailBulkEditChanges = {};
|
||||||
|
if (applyCategory) {
|
||||||
|
if (!categorySelection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
changes.category = categorySelection;
|
||||||
|
}
|
||||||
|
if (applyDifficulty) {
|
||||||
|
changes.difficulty = difficultyValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
await onapply?.(changes);
|
||||||
|
closeModal();
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
id="trail-bulk-edit-modal"
|
||||||
|
title={$_("adjust")}
|
||||||
|
size="md:min-w-sm"
|
||||||
|
bind:this={modal}
|
||||||
|
>
|
||||||
|
{#snippet content()}
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
{$_("bulk-edit-selected-trails", {
|
||||||
|
values: { n: selectedCount },
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 accent-primary"
|
||||||
|
bind:checked={applyCategory}
|
||||||
|
/>
|
||||||
|
<span class="font-medium">{$_("category")}</span>
|
||||||
|
</label>
|
||||||
|
{#if applyCategory}
|
||||||
|
<CategoryPicker
|
||||||
|
value={categoryValue}
|
||||||
|
label=""
|
||||||
|
loadData
|
||||||
|
fixedDropdown
|
||||||
|
onchange={handleCategoryChange}
|
||||||
|
></CategoryPicker>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 accent-primary"
|
||||||
|
bind:checked={applyDifficulty}
|
||||||
|
/>
|
||||||
|
<span class="font-medium">{$_("difficulty")}</span>
|
||||||
|
</label>
|
||||||
|
{#if applyDifficulty}
|
||||||
|
<Select
|
||||||
|
value={difficultyValue}
|
||||||
|
onchange={(value) => (difficultyValue = value)}
|
||||||
|
items={[
|
||||||
|
{ text: $_("easy"), value: "easy" },
|
||||||
|
{ text: $_("moderate"), value: "moderate" },
|
||||||
|
{ text: $_("difficult"), value: "difficult" },
|
||||||
|
]}
|
||||||
|
></Select>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet footer({ closeModal })}
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<button class="btn-secondary" type="button" onclick={closeModal}>
|
||||||
|
{$_("cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn-primary"
|
||||||
|
class:btn-disabled={!canApply || loading}
|
||||||
|
disabled={!canApply || loading}
|
||||||
|
type="button"
|
||||||
|
onclick={() => apply(closeModal)}
|
||||||
|
>
|
||||||
|
{$_("apply")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Modal>
|
||||||
@@ -10,7 +10,13 @@
|
|||||||
formatElevation,
|
formatElevation,
|
||||||
formatTimeHHMM,
|
formatTimeHHMM,
|
||||||
} from "$lib/util/format_util";
|
} from "$lib/util/format_util";
|
||||||
import { _ } from "svelte-i18n";
|
import {
|
||||||
|
displayCategoryName,
|
||||||
|
displaySubcategoryLabel,
|
||||||
|
displayTrailCategoryBadgeIcon,
|
||||||
|
displayTrailCategoryIcon,
|
||||||
|
} from "$lib/util/category_util";
|
||||||
|
import { _, locale } from "svelte-i18n";
|
||||||
import type { MouseEventHandler } from "svelte/elements";
|
import type { MouseEventHandler } from "svelte/elements";
|
||||||
import Chip from "../base/chip.svelte";
|
import Chip from "../base/chip.svelte";
|
||||||
|
|
||||||
@@ -63,6 +69,7 @@
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
expandedTags = !expandedTags;
|
expandedTags = !expandedTags;
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -202,11 +209,27 @@
|
|||||||
<div class="flex gap-x-4 gap-y-1 text-base flex-wrap">
|
<div class="flex gap-x-4 gap-y-1 text-base flex-wrap">
|
||||||
{#if trail.expand?.category?.name || trail.category}
|
{#if trail.expand?.category?.name || trail.category}
|
||||||
<p>
|
<p>
|
||||||
<i class="fa fa-shapes mr-3"> </i>{$_(
|
<span class="relative mr-3 inline-block w-4 text-center">
|
||||||
trail.expand?.category?.name ??
|
<i class="fa {displayTrailCategoryIcon(trail)}"></i>
|
||||||
trail.category ??
|
{#if displayTrailCategoryBadgeIcon(trail)}
|
||||||
"-",
|
<i
|
||||||
)}
|
class="fa {displayTrailCategoryBadgeIcon(
|
||||||
|
trail,
|
||||||
|
)} absolute -right-1 -top-1 text-[8px]"
|
||||||
|
></i>
|
||||||
|
{/if}
|
||||||
|
</span>{displayCategoryName(
|
||||||
|
trail.expand?.category ?? { name: trail.category ?? "" },
|
||||||
|
$locale,
|
||||||
|
) || "-"}
|
||||||
|
{#if trail.expand?.subcategory}
|
||||||
|
<span class="text-gray-500">
|
||||||
|
/ {displaySubcategoryLabel(
|
||||||
|
trail.expand.subcategory,
|
||||||
|
$locale,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if trail.location}
|
{#if trail.location}
|
||||||
|
|||||||
693
web/src/lib/components/trail/trail_category_filter.svelte
Normal file
@@ -0,0 +1,693 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Category } from "$lib/models/category";
|
||||||
|
import type { Subcategory } from "$lib/models/subcategory";
|
||||||
|
import type { TrailFilter } from "$lib/models/trail";
|
||||||
|
import { categoryPreferences } from "$lib/stores/category_preference_store";
|
||||||
|
import { subcategoryPreferences } from "$lib/stores/subcategory_preference_store";
|
||||||
|
import { subcategories } from "$lib/stores/subcategory_store";
|
||||||
|
import {
|
||||||
|
noSubcategoryFilterCategory,
|
||||||
|
noSubcategoryFilterValue,
|
||||||
|
} from "$lib/util/trail_filter_util";
|
||||||
|
import {
|
||||||
|
displayCategoryIcon,
|
||||||
|
displayCategoryName,
|
||||||
|
displaySubcategoryBadgeIcon,
|
||||||
|
displaySubcategoryIcon,
|
||||||
|
displaySubcategoryLabel,
|
||||||
|
displaySubcategoryShortBadge,
|
||||||
|
preferenceForCategory,
|
||||||
|
sortedCategoriesByPreference,
|
||||||
|
sortedSubcategoriesByPreference,
|
||||||
|
subcategoryVisible,
|
||||||
|
} from "$lib/util/category_util";
|
||||||
|
import { _, locale } from "svelte-i18n";
|
||||||
|
import type { SelectItem } from "../base/select.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
categories: Category[];
|
||||||
|
filter: TrailFilter;
|
||||||
|
onupdate?: (filter: TrailFilter) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CategorySelectItem = SelectItem & {
|
||||||
|
icon: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORY_BUTTON_WIDTH = 40;
|
||||||
|
const CATEGORY_BUTTON_GAP = 8;
|
||||||
|
const FALLBACK_VISIBLE_CATEGORY_LIMIT = 4;
|
||||||
|
|
||||||
|
let { categories, filter = $bindable(), onupdate }: Props = $props();
|
||||||
|
|
||||||
|
let categorySelectItems = $derived(
|
||||||
|
sortedCategoriesByPreference(
|
||||||
|
categories,
|
||||||
|
$categoryPreferences,
|
||||||
|
$locale,
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
(c) =>
|
||||||
|
preferenceForCategory($categoryPreferences, c.id)?.visible !==
|
||||||
|
false || filter.category.includes(c.id),
|
||||||
|
)
|
||||||
|
.map((c) => ({
|
||||||
|
value: c.id,
|
||||||
|
text: displayCategoryName(c, $locale),
|
||||||
|
icon: displayCategoryIcon(c),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
let orderedCategorySelectItems = $derived(
|
||||||
|
[...categorySelectItems].sort((a, b) => {
|
||||||
|
const aSelected = filter.category.includes(a.value);
|
||||||
|
const bSelected = filter.category.includes(b.value);
|
||||||
|
|
||||||
|
if (aSelected === bSelected) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return aSelected ? -1 : 1;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let categoryListElement: HTMLDivElement | undefined = $state();
|
||||||
|
let categoryListWidth = $state(0);
|
||||||
|
let visibleCategoryLimit = $derived(
|
||||||
|
visibleCategoryLimitForWidth(categoryListWidth),
|
||||||
|
);
|
||||||
|
let calculatedVisibleCategoryItems = $derived(
|
||||||
|
orderedCategorySelectItems.slice(0, visibleCategoryLimit),
|
||||||
|
);
|
||||||
|
let calculatedOverflowCategoryItems = $derived(
|
||||||
|
visibleCategoryLimit >= orderedCategorySelectItems.length
|
||||||
|
? []
|
||||||
|
: orderedCategorySelectItems.slice(visibleCategoryLimit),
|
||||||
|
);
|
||||||
|
let overflowExpanded = $state(false);
|
||||||
|
let visibleCategorySnapshot: CategorySelectItem[] = $state([]);
|
||||||
|
let overflowCategorySnapshot: CategorySelectItem[] = $state([]);
|
||||||
|
let visibleCategoryItems = $derived(
|
||||||
|
overflowExpanded
|
||||||
|
? visibleCategorySnapshot
|
||||||
|
: calculatedVisibleCategoryItems,
|
||||||
|
);
|
||||||
|
let overflowCategoryItems = $derived(
|
||||||
|
overflowExpanded
|
||||||
|
? overflowCategorySnapshot
|
||||||
|
: calculatedOverflowCategoryItems,
|
||||||
|
);
|
||||||
|
let overflowHasActiveFilters = $derived(
|
||||||
|
overflowCategoryItems.some((category) =>
|
||||||
|
filter.category.includes(category.value),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let activeHiddenCategories = $derived(
|
||||||
|
categories
|
||||||
|
.filter(
|
||||||
|
(category) =>
|
||||||
|
filter.category.includes(category.id) &&
|
||||||
|
preferenceForCategory($categoryPreferences, category.id)
|
||||||
|
?.visible === false,
|
||||||
|
)
|
||||||
|
.map((category) => displayCategoryName(category, $locale)),
|
||||||
|
);
|
||||||
|
let hoveredCategoryId: string | undefined = $state();
|
||||||
|
let categoryTooltip = $state("");
|
||||||
|
let categoryTooltipStyle = $state("");
|
||||||
|
let longPressTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let longPressCategoryId: string | undefined;
|
||||||
|
let longPressStart:
|
||||||
|
| {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
let suppressCategoryClick: string | undefined;
|
||||||
|
let suppressCategoryClickTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let hoveredCategoryItem = $derived(
|
||||||
|
categorySelectItems.find(
|
||||||
|
(category) => category.value === hoveredCategoryId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let selectedSubcategoryIds = $derived(filter.subcategory ?? []);
|
||||||
|
let hoveredSubcategories = $derived(
|
||||||
|
hoveredCategoryId
|
||||||
|
? sortedSubcategoriesByPreference(
|
||||||
|
$subcategories.filter(
|
||||||
|
(subcategory) =>
|
||||||
|
subcategory.category === hoveredCategoryId &&
|
||||||
|
(subcategoryVisible(
|
||||||
|
subcategory.id,
|
||||||
|
$subcategoryPreferences,
|
||||||
|
) ||
|
||||||
|
selectedSubcategoryIds.includes(subcategory.id)),
|
||||||
|
),
|
||||||
|
$subcategoryPreferences,
|
||||||
|
$locale,
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
let subcategoryOverlayStyle = $state("");
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!categoryListElement || typeof ResizeObserver === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(([entry]) => {
|
||||||
|
categoryListWidth = entry.contentRect.width;
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(categoryListElement);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
function visibleCategoryLimitForWidth(width: number) {
|
||||||
|
if (width <= 0) {
|
||||||
|
return FALLBACK_VISIBLE_CATEGORY_LIMIT;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slotCount = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor(
|
||||||
|
(width + CATEGORY_BUTTON_GAP) /
|
||||||
|
(CATEGORY_BUTTON_WIDTH + CATEGORY_BUTTON_GAP),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (orderedCategorySelectItems.length <= slotCount) {
|
||||||
|
return orderedCategorySelectItems.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(0, slotCount - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update() {
|
||||||
|
onupdate?.(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleOverflow() {
|
||||||
|
if (overflowExpanded) {
|
||||||
|
closeOverflow();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
visibleCategorySnapshot = calculatedVisibleCategoryItems;
|
||||||
|
overflowCategorySnapshot = calculatedOverflowCategoryItems;
|
||||||
|
overflowExpanded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeOverflow() {
|
||||||
|
overflowExpanded = false;
|
||||||
|
visibleCategorySnapshot = [];
|
||||||
|
overflowCategorySnapshot = [];
|
||||||
|
hoveredCategoryId = undefined;
|
||||||
|
subcategoryOverlayStyle = "";
|
||||||
|
hideFilterTooltip();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCategoryFilter(category: CategorySelectItem) {
|
||||||
|
if (filter.category.includes(category.value)) {
|
||||||
|
filter.category = filter.category.filter((id) => id !== category.value);
|
||||||
|
if (hoveredCategoryId === category.value) {
|
||||||
|
hoveredCategoryId = undefined;
|
||||||
|
}
|
||||||
|
filter.subcategory = selectedSubcategoryIds.filter(
|
||||||
|
(id) =>
|
||||||
|
noSubcategoryFilterCategory(id) !== category.value &&
|
||||||
|
!$subcategories.some(
|
||||||
|
(subcategory) =>
|
||||||
|
subcategory.id === id &&
|
||||||
|
subcategory.category === category.value,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
filter.category = [...filter.category, category.value];
|
||||||
|
}
|
||||||
|
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCategoryClick(e: MouseEvent, category: CategorySelectItem) {
|
||||||
|
if (suppressCategoryClick === category.value) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
clearSuppressedCategoryClick();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleCategoryFilter(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleNoSubcategoryFilter(category: CategorySelectItem) {
|
||||||
|
const value = noSubcategoryFilterValue(category.value);
|
||||||
|
|
||||||
|
if (selectedSubcategoryIds.includes(value)) {
|
||||||
|
filter.subcategory = selectedSubcategoryIds.filter((id) => id !== value);
|
||||||
|
} else {
|
||||||
|
if (!filter.category.includes(category.value)) {
|
||||||
|
filter.category = [...filter.category, category.value];
|
||||||
|
}
|
||||||
|
filter.subcategory = [...selectedSubcategoryIds, value];
|
||||||
|
}
|
||||||
|
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSubcategoryFilter(subcategory: Subcategory) {
|
||||||
|
if (selectedSubcategoryIds.includes(subcategory.id)) {
|
||||||
|
filter.subcategory = selectedSubcategoryIds.filter(
|
||||||
|
(id) => id !== subcategory.id,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (!filter.category.includes(subcategory.category)) {
|
||||||
|
filter.category = [...filter.category, subcategory.category];
|
||||||
|
}
|
||||||
|
filter.subcategory = [...selectedSubcategoryIds, subcategory.id];
|
||||||
|
}
|
||||||
|
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSubcategoryOverlay(
|
||||||
|
category: CategorySelectItem,
|
||||||
|
hasSubcategories: boolean,
|
||||||
|
target: EventTarget | null,
|
||||||
|
) {
|
||||||
|
if (!(target instanceof HTMLElement)) {
|
||||||
|
hoveredCategoryId = undefined;
|
||||||
|
subcategoryOverlayStyle = "";
|
||||||
|
hideFilterTooltip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = target.getBoundingClientRect();
|
||||||
|
showCategoryTooltip(category.text, rect);
|
||||||
|
|
||||||
|
if (!hasSubcategories) {
|
||||||
|
hoveredCategoryId = undefined;
|
||||||
|
subcategoryOverlayStyle = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
subcategoryOverlayStyle = [
|
||||||
|
`top: ${rect.bottom}px`,
|
||||||
|
`left: ${rect.left}px`,
|
||||||
|
"max-width: calc(100vw - 2rem)",
|
||||||
|
].join("; ");
|
||||||
|
hoveredCategoryId = category.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideSubcategoryOverlay(category: CategorySelectItem) {
|
||||||
|
if (hoveredCategoryId === category.value) {
|
||||||
|
hoveredCategoryId = undefined;
|
||||||
|
subcategoryOverlayStyle = "";
|
||||||
|
}
|
||||||
|
hideFilterTooltip();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCategoryLongPress(
|
||||||
|
e: PointerEvent,
|
||||||
|
category: CategorySelectItem,
|
||||||
|
hasSubcategories: boolean,
|
||||||
|
) {
|
||||||
|
if (e.pointerType === "mouse" || !hasSubcategories) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearCategoryLongPress();
|
||||||
|
longPressCategoryId = category.value;
|
||||||
|
longPressStart = { x: e.clientX, y: e.clientY };
|
||||||
|
const target = e.currentTarget;
|
||||||
|
|
||||||
|
longPressTimer = setTimeout(() => {
|
||||||
|
suppressNextCategoryClick(category.value);
|
||||||
|
showSubcategoryOverlay(category, hasSubcategories, target);
|
||||||
|
longPressTimer = undefined;
|
||||||
|
}, 450);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveCategoryLongPress(e: PointerEvent) {
|
||||||
|
if (!longPressStart || e.pointerType === "mouse") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaX = Math.abs(e.clientX - longPressStart.x);
|
||||||
|
const deltaY = Math.abs(e.clientY - longPressStart.y);
|
||||||
|
if (deltaX > 10 || deltaY > 10) {
|
||||||
|
clearCategoryLongPress();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCategoryLongPress() {
|
||||||
|
if (longPressTimer) {
|
||||||
|
clearTimeout(longPressTimer);
|
||||||
|
}
|
||||||
|
longPressTimer = undefined;
|
||||||
|
longPressCategoryId = undefined;
|
||||||
|
longPressStart = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCategoryPointerUp(e: PointerEvent) {
|
||||||
|
if (
|
||||||
|
e.pointerType !== "mouse" &&
|
||||||
|
longPressCategoryId &&
|
||||||
|
!longPressTimer
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
clearCategoryLongPress();
|
||||||
|
}
|
||||||
|
|
||||||
|
function suppressNextCategoryClick(categoryId: string) {
|
||||||
|
clearSuppressedCategoryClick();
|
||||||
|
suppressCategoryClick = categoryId;
|
||||||
|
suppressCategoryClickTimer = setTimeout(() => {
|
||||||
|
clearSuppressedCategoryClick();
|
||||||
|
}, 700);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSuppressedCategoryClick() {
|
||||||
|
if (suppressCategoryClickTimer) {
|
||||||
|
clearTimeout(suppressCategoryClickTimer);
|
||||||
|
}
|
||||||
|
suppressCategoryClickTimer = undefined;
|
||||||
|
suppressCategoryClick = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showFilterTooltip(text: string, target: EventTarget | null) {
|
||||||
|
if (!(target instanceof HTMLElement)) {
|
||||||
|
hideFilterTooltip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showCategoryTooltip(text, target.getBoundingClientRect());
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideFilterTooltip() {
|
||||||
|
categoryTooltip = "";
|
||||||
|
categoryTooltipStyle = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCategoryTooltip(text: string, rect: DOMRect) {
|
||||||
|
const tooltipWidth = text.length * 7 + 20;
|
||||||
|
const viewportPadding = 8;
|
||||||
|
const left = Math.max(
|
||||||
|
viewportPadding,
|
||||||
|
Math.min(
|
||||||
|
rect.left + rect.width / 2 - tooltipWidth / 2,
|
||||||
|
window.innerWidth - viewportPadding - tooltipWidth,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
categoryTooltip = text;
|
||||||
|
categoryTooltipStyle = [
|
||||||
|
`top: calc(${rect.top}px + var(--tooltip-offset-top))`,
|
||||||
|
`left: ${left}px`,
|
||||||
|
`width: ${tooltipWidth}px`,
|
||||||
|
"background: var(--tooltip-background)",
|
||||||
|
"border-radius: var(--tooltip-border-radius)",
|
||||||
|
"color: var(--tooltip-color)",
|
||||||
|
"font-size: var(--tooltip-font-size)",
|
||||||
|
"padding: var(--tooltip-padding)",
|
||||||
|
].join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubcategoryOverlayFocusOut(
|
||||||
|
e: FocusEvent,
|
||||||
|
category: CategorySelectItem,
|
||||||
|
) {
|
||||||
|
const nextTarget = e.relatedTarget;
|
||||||
|
if (
|
||||||
|
nextTarget instanceof Node &&
|
||||||
|
(e.currentTarget as HTMLElement).contains(nextTarget)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hideSubcategoryOverlay(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet categoryButton(category: CategorySelectItem)}
|
||||||
|
{@const selected = filter.category.includes(category.value)}
|
||||||
|
{@const hasSubcategories = $subcategories.some(
|
||||||
|
(subcategory) => subcategory.category === category.value,
|
||||||
|
)}
|
||||||
|
{@const selectedSubcategoriesForCategory = selectedSubcategoryIds.filter(
|
||||||
|
(id) =>
|
||||||
|
noSubcategoryFilterCategory(id) === category.value ||
|
||||||
|
$subcategories.some(
|
||||||
|
(subcategory) =>
|
||||||
|
subcategory.id === id &&
|
||||||
|
subcategory.category === category.value,
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
{@const noSubcategorySelected = selectedSubcategoryIds.includes(
|
||||||
|
noSubcategoryFilterValue(category.value),
|
||||||
|
)}
|
||||||
|
{@const noSubcategoryInherited =
|
||||||
|
selected && selectedSubcategoriesForCategory.length === 0}
|
||||||
|
{@const noSubcategoryActive =
|
||||||
|
noSubcategorySelected || noSubcategoryInherited}
|
||||||
|
<div
|
||||||
|
class="relative shrink-0"
|
||||||
|
role="presentation"
|
||||||
|
onmouseenter={(e) =>
|
||||||
|
showSubcategoryOverlay(
|
||||||
|
category,
|
||||||
|
hasSubcategories,
|
||||||
|
e.currentTarget,
|
||||||
|
)}
|
||||||
|
onmouseleave={() => hideSubcategoryOverlay(category)}
|
||||||
|
onfocusin={(e) =>
|
||||||
|
showSubcategoryOverlay(
|
||||||
|
category,
|
||||||
|
hasSubcategories,
|
||||||
|
e.currentTarget,
|
||||||
|
)}
|
||||||
|
onfocusout={(e) => handleSubcategoryOverlayFocusOut(e, category)}
|
||||||
|
onpointerdown={(e) =>
|
||||||
|
startCategoryLongPress(e, category, hasSubcategories)}
|
||||||
|
onpointermove={moveCategoryLongPress}
|
||||||
|
onpointerup={handleCategoryPointerUp}
|
||||||
|
onpointercancel={clearCategoryLongPress}
|
||||||
|
oncontextmenu={(e) => {
|
||||||
|
if (suppressCategoryClick === category.value) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={category.text}
|
||||||
|
aria-pressed={selected}
|
||||||
|
class="relative flex h-10 w-10 items-center justify-center rounded-md border transition-colors focus:outline-none focus:ring-1 focus:ring-inset focus:ring-input-ring"
|
||||||
|
class:border-primary={selected}
|
||||||
|
class:bg-primary={selected}
|
||||||
|
class:text-white={selected}
|
||||||
|
class:border-input-border={!selected}
|
||||||
|
class:bg-input-background={!selected}
|
||||||
|
class:text-gray-500={!selected}
|
||||||
|
class:hover:bg-menu-item-background-hover={!selected}
|
||||||
|
onclick={(e) => handleCategoryClick(e, category)}
|
||||||
|
>
|
||||||
|
<i class="fa {category.icon} text-2xl"></i>
|
||||||
|
{#if selectedSubcategoriesForCategory.length > 0}
|
||||||
|
<i
|
||||||
|
class="fa fa-filter absolute left-1 top-1 text-[8px] text-white"
|
||||||
|
></i>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{#if hoveredCategoryId === category.value && hoveredCategoryItem && hoveredSubcategories.length}
|
||||||
|
<div class="fixed z-20 min-w-max pt-1" style={subcategoryOverlayStyle}>
|
||||||
|
<div
|
||||||
|
class="rounded-md border border-input-border bg-menu-background p-2 shadow-lg"
|
||||||
|
>
|
||||||
|
<p class="mb-2 text-xs font-medium text-gray-500">
|
||||||
|
{hoveredCategoryItem.text}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={$_("no-subcategory")}
|
||||||
|
aria-pressed={noSubcategoryActive}
|
||||||
|
class="relative flex h-10 w-10 items-center justify-center rounded-md border transition-colors focus:outline-none focus:ring-1 focus:ring-inset focus:ring-input-ring"
|
||||||
|
class:border-primary={noSubcategoryActive}
|
||||||
|
class:bg-primary={noSubcategoryActive}
|
||||||
|
class:text-white={noSubcategoryActive}
|
||||||
|
class:opacity-70={noSubcategoryInherited}
|
||||||
|
class:border-input-border={!noSubcategoryActive}
|
||||||
|
class:bg-input-background={!noSubcategoryActive}
|
||||||
|
class:text-gray-500={!noSubcategoryActive}
|
||||||
|
class:hover:bg-menu-item-background-hover={!noSubcategoryActive}
|
||||||
|
onmouseenter={(e) =>
|
||||||
|
showFilterTooltip(
|
||||||
|
$_("no-subcategory"),
|
||||||
|
e.currentTarget,
|
||||||
|
)}
|
||||||
|
onmouseleave={hideFilterTooltip}
|
||||||
|
onfocus={(e) =>
|
||||||
|
showFilterTooltip(
|
||||||
|
$_("no-subcategory"),
|
||||||
|
e.currentTarget,
|
||||||
|
)}
|
||||||
|
onblur={hideFilterTooltip}
|
||||||
|
onclick={() => toggleNoSubcategoryFilter(category)}
|
||||||
|
>
|
||||||
|
<i class="fa {category.icon} text-2xl"></i>
|
||||||
|
<i
|
||||||
|
class="fa-regular fa-circle absolute -bottom-1 -right-1 rounded-full bg-background text-[10px] text-content"
|
||||||
|
class:text-white={noSubcategoryActive}
|
||||||
|
></i>
|
||||||
|
</button>
|
||||||
|
<div class="h-8 border-l border-separator"></div>
|
||||||
|
{#each hoveredSubcategories as subcategory}
|
||||||
|
{@const subcategorySelected = selectedSubcategoryIds.includes(subcategory.id)}
|
||||||
|
{@const subcategoryInherited = selected && selectedSubcategoriesForCategory.length === 0}
|
||||||
|
{@const subcategoryActive = subcategorySelected || subcategoryInherited}
|
||||||
|
{@const subcategoryLabel = displaySubcategoryLabel(subcategory, $locale)}
|
||||||
|
{@const badge = displaySubcategoryShortBadge(
|
||||||
|
subcategory,
|
||||||
|
$locale,
|
||||||
|
)}
|
||||||
|
{@const badgeIcon = displaySubcategoryBadgeIcon(subcategory)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={subcategoryLabel}
|
||||||
|
aria-pressed={subcategoryActive}
|
||||||
|
class="relative flex h-10 w-10 items-center justify-center rounded-md border transition-colors focus:outline-none focus:ring-1 focus:ring-inset focus:ring-input-ring"
|
||||||
|
class:border-primary={subcategoryActive}
|
||||||
|
class:bg-primary={subcategoryActive}
|
||||||
|
class:text-white={subcategoryActive}
|
||||||
|
class:opacity-70={subcategoryInherited}
|
||||||
|
class:border-input-border={!subcategoryActive}
|
||||||
|
class:bg-input-background={!subcategoryActive}
|
||||||
|
class:text-gray-500={!subcategoryActive}
|
||||||
|
class:hover:bg-menu-item-background-hover={!subcategoryActive}
|
||||||
|
onmouseenter={(e) =>
|
||||||
|
showFilterTooltip(
|
||||||
|
subcategoryLabel,
|
||||||
|
e.currentTarget,
|
||||||
|
)}
|
||||||
|
onmouseleave={hideFilterTooltip}
|
||||||
|
onfocus={(e) =>
|
||||||
|
showFilterTooltip(
|
||||||
|
subcategoryLabel,
|
||||||
|
e.currentTarget,
|
||||||
|
)}
|
||||||
|
onblur={hideFilterTooltip}
|
||||||
|
onclick={() => toggleSubcategoryFilter(subcategory)}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
class="fa {displaySubcategoryIcon(
|
||||||
|
subcategory,
|
||||||
|
hoveredCategoryItem,
|
||||||
|
)} text-2xl"
|
||||||
|
></i>
|
||||||
|
{#if badgeIcon}
|
||||||
|
<i
|
||||||
|
class="fa {badgeIcon} absolute right-0.5 top-0.5 text-[10px] text-gray-500"
|
||||||
|
class:text-white={subcategoryActive}
|
||||||
|
></i>
|
||||||
|
{/if}
|
||||||
|
{#if badge}
|
||||||
|
<span
|
||||||
|
class="absolute -bottom-1 -right-1 max-w-10 truncate rounded-sm border border-input-border bg-background px-0.5 text-[7px] font-semibold leading-3 text-content"
|
||||||
|
>
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium pb-2">{$_("categories")}</p>
|
||||||
|
<div bind:this={categoryListElement} class="flex gap-2 overflow-visible pb-2">
|
||||||
|
{#each visibleCategoryItems as category}
|
||||||
|
{@render categoryButton(category)}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if overflowCategoryItems.length}
|
||||||
|
<div
|
||||||
|
class="relative shrink-0"
|
||||||
|
onfocusout={(e) => {
|
||||||
|
const nextTarget = e.relatedTarget;
|
||||||
|
if (
|
||||||
|
nextTarget instanceof Node &&
|
||||||
|
(e.currentTarget as HTMLElement).contains(nextTarget)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeOverflow();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={$_("more")}
|
||||||
|
aria-expanded={overflowExpanded}
|
||||||
|
class="relative flex h-10 w-10 items-center justify-center rounded-md border transition-colors focus:outline-none focus:ring-1 focus:ring-inset focus:ring-input-ring"
|
||||||
|
class:border-primary={overflowHasActiveFilters}
|
||||||
|
class:bg-primary={overflowHasActiveFilters}
|
||||||
|
class:text-white={overflowHasActiveFilters}
|
||||||
|
class:border-input-border={!overflowHasActiveFilters}
|
||||||
|
class:bg-input-background={!overflowHasActiveFilters}
|
||||||
|
class:text-gray-500={!overflowHasActiveFilters}
|
||||||
|
class:hover:bg-menu-item-background-hover={!overflowHasActiveFilters}
|
||||||
|
onclick={toggleOverflow}
|
||||||
|
>
|
||||||
|
<span class="text-xs font-semibold">
|
||||||
|
+{overflowCategoryItems.length}
|
||||||
|
</span>
|
||||||
|
{#if overflowHasActiveFilters}
|
||||||
|
<i
|
||||||
|
class="fa fa-filter absolute left-1 top-1 text-[8px] text-white"
|
||||||
|
></i>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if overflowExpanded}
|
||||||
|
<div
|
||||||
|
class="absolute right-0 top-full z-10 mt-1 rounded-md border border-input-border bg-menu-background p-2 shadow-lg"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="grid gap-2"
|
||||||
|
style="grid-template-columns: repeat(4, 2.5rem);"
|
||||||
|
>
|
||||||
|
{#each overflowCategoryItems as category}
|
||||||
|
{@render categoryButton(category)}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if categoryTooltip}
|
||||||
|
<div
|
||||||
|
class="fixed z-30 pointer-events-none whitespace-nowrap"
|
||||||
|
style={categoryTooltipStyle}
|
||||||
|
>
|
||||||
|
{categoryTooltip}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if activeHiddenCategories.length}
|
||||||
|
<div
|
||||||
|
class="mt-3 rounded-xl border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-sm text-yellow-800 dark:text-yellow-200"
|
||||||
|
>
|
||||||
|
<i class="fa fa-warning mr-2"></i>
|
||||||
|
{$_("category-filter-hidden-active", {
|
||||||
|
values: {
|
||||||
|
categories: activeHiddenCategories.join(", "),
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -26,6 +26,9 @@
|
|||||||
import ConfirmModal from "../confirm_modal.svelte";
|
import ConfirmModal from "../confirm_modal.svelte";
|
||||||
import ListSearchModal from "../list/list_search_modal.svelte";
|
import ListSearchModal from "../list/list_search_modal.svelte";
|
||||||
import TrailExportModal from "./trail_export_modal.svelte";
|
import TrailExportModal from "./trail_export_modal.svelte";
|
||||||
|
import TrailBulkEditModal, {
|
||||||
|
type TrailBulkEditChanges,
|
||||||
|
} from "./trail_bulk_edit_modal.svelte";
|
||||||
import TrailSendModal from "./trail_send_modal.svelte";
|
import TrailSendModal from "./trail_send_modal.svelte";
|
||||||
import TrailShareModal from "./trail_share_modal.svelte";
|
import TrailShareModal from "./trail_share_modal.svelte";
|
||||||
import {
|
import {
|
||||||
@@ -51,7 +54,7 @@
|
|||||||
toggle?: Snippet<[any]>;
|
toggle?: Snippet<[any]>;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
onShare?: () => void;
|
onShare?: () => void;
|
||||||
onUpdate?: () => void;
|
onUpdate?: (updatedTrails?: Trail[]) => void;
|
||||||
onMerge?: (result: MergeResult) => void;
|
onMerge?: (result: MergeResult) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +66,7 @@
|
|||||||
let trailSendModal: TrailSendModal;
|
let trailSendModal: TrailSendModal;
|
||||||
let trailShareModal: TrailShareModal;
|
let trailShareModal: TrailShareModal;
|
||||||
let trailMergeModal: TrailMergeModal;
|
let trailMergeModal: TrailMergeModal;
|
||||||
|
let trailBulkEditModal: TrailBulkEditModal;
|
||||||
|
|
||||||
let lists: List[] = $state([]);
|
let lists: List[] = $state([]);
|
||||||
|
|
||||||
@@ -190,6 +194,81 @@
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function allowBulkEdit(): boolean {
|
||||||
|
return allowPublish();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedCategorySelection():
|
||||||
|
| TrailBulkEditChanges["category"]
|
||||||
|
| undefined {
|
||||||
|
if (!trails || trails.size === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedTrails = [...trails];
|
||||||
|
const firstCategory =
|
||||||
|
selectedTrails[0]?.category ??
|
||||||
|
selectedTrails[0]?.expand?.category?.id ??
|
||||||
|
selectedTrails[0]?.expand?.subcategory?.category ??
|
||||||
|
"";
|
||||||
|
const firstSubcategory =
|
||||||
|
selectedTrails[0]?.subcategory ??
|
||||||
|
selectedTrails[0]?.expand?.subcategory?.id ??
|
||||||
|
"";
|
||||||
|
|
||||||
|
if (!firstCategory) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allSame = selectedTrails.every((candidate) => {
|
||||||
|
const category =
|
||||||
|
candidate.category ??
|
||||||
|
candidate.expand?.category?.id ??
|
||||||
|
candidate.expand?.subcategory?.category ??
|
||||||
|
"";
|
||||||
|
const subcategory =
|
||||||
|
candidate.subcategory ?? candidate.expand?.subcategory?.id ?? "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
category === firstCategory &&
|
||||||
|
subcategory === firstSubcategory
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!allSame) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
category: firstCategory,
|
||||||
|
subcategory: firstSubcategory,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedDifficulty():
|
||||||
|
| TrailBulkEditChanges["difficulty"]
|
||||||
|
| undefined {
|
||||||
|
if (!trails || trails.size === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedTrails = [...trails];
|
||||||
|
const firstDifficulty = selectedTrails[0]?.difficulty;
|
||||||
|
if (!firstDifficulty) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!selectedTrails.every(
|
||||||
|
(candidate) => candidate.difficulty === firstDifficulty,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstDifficulty;
|
||||||
|
}
|
||||||
|
|
||||||
function dropdownItems(): DropdownItem[] {
|
function dropdownItems(): DropdownItem[] {
|
||||||
const separator = (value: string): DropdownItem => ({
|
const separator = (value: string): DropdownItem => ({
|
||||||
text: "",
|
text: "",
|
||||||
@@ -202,10 +281,20 @@
|
|||||||
|
|
||||||
if (isMultiselectMode()) {
|
if (isMultiselectMode()) {
|
||||||
return [
|
return [
|
||||||
|
...(allowBulkEdit()
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
text: $_("adjust"),
|
||||||
|
value: "bulk-edit",
|
||||||
|
icon: "pen",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
...(allowMerge()
|
...(allowMerge()
|
||||||
? [{ text: $_("link"), value: "merge", icon: "link" }]
|
? [{ text: $_("link"), value: "merge", icon: "link" }]
|
||||||
: []),
|
: []),
|
||||||
...(allowMerge() && (canExport() || allowListManagement || allowPublish() || allowDelete())
|
...((allowBulkEdit() || allowMerge()) &&
|
||||||
|
(canExport() || allowListManagement || allowPublish() || allowDelete())
|
||||||
? [separator("sep-multi-actions")]
|
? [separator("sep-multi-actions")]
|
||||||
: []),
|
: []),
|
||||||
...(canExport()
|
...(canExport()
|
||||||
@@ -288,14 +377,16 @@
|
|||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...(allowFindSimilarTrails()
|
...(allowFindSimilarTrails()
|
||||||
? [{
|
? [
|
||||||
text: $_("find-similar-trails"),
|
{
|
||||||
value: "find-similar-trails",
|
text: $_("find-similar-trails"),
|
||||||
icon: "link",
|
value: "find-similar-trails",
|
||||||
}]
|
icon: "link",
|
||||||
|
},
|
||||||
|
]
|
||||||
: []),
|
: []),
|
||||||
...(allowCopy()
|
...(allowCopy()
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
text: $_("duplicate"),
|
text: $_("duplicate"),
|
||||||
value: "copy",
|
value: "copy",
|
||||||
@@ -500,6 +591,8 @@
|
|||||||
}
|
}
|
||||||
} else if (ddVal == "publish") {
|
} else if (ddVal == "publish") {
|
||||||
updateTrailsVisibility();
|
updateTrailsVisibility();
|
||||||
|
} else if (ddVal == "bulk-edit") {
|
||||||
|
trailBulkEditModal.openModal();
|
||||||
} else if (ddVal == "delete") {
|
} else if (ddVal == "delete") {
|
||||||
confirmModal.openModal();
|
confirmModal.openModal();
|
||||||
} else if (item.value == "merge") {
|
} else if (item.value == "merge") {
|
||||||
@@ -604,6 +697,66 @@
|
|||||||
onUpdate?.();
|
onUpdate?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateTrailsBulk(changes: TrailBulkEditChanges) {
|
||||||
|
loading = true;
|
||||||
|
let updatedCount = 0;
|
||||||
|
const updatedTrails: Trail[] = [];
|
||||||
|
|
||||||
|
for (const cTrail of trails ?? []) {
|
||||||
|
if (!cTrail || !canEditTrail(cTrail)) continue;
|
||||||
|
if (!cTrail.expand?.author?.id) continue;
|
||||||
|
|
||||||
|
const origTrail: Trail = {
|
||||||
|
...cTrail,
|
||||||
|
author: cTrail.expand.author.id,
|
||||||
|
};
|
||||||
|
const updatedTrail: Trail = {
|
||||||
|
...origTrail,
|
||||||
|
...(changes.category
|
||||||
|
? {
|
||||||
|
category: changes.category.category,
|
||||||
|
subcategory: changes.category.subcategory,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(changes.difficulty
|
||||||
|
? { difficulty: changes.difficulty }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await trails_update(
|
||||||
|
origTrail,
|
||||||
|
updatedTrail,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
["tags"],
|
||||||
|
);
|
||||||
|
updatedTrails.push(updated);
|
||||||
|
updatedCount += 1;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
|
||||||
|
show_toast({
|
||||||
|
type: "error",
|
||||||
|
icon: "close",
|
||||||
|
text: `${$_("error-saving-trail")}: ${cTrail.name}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
if (updatedCount > 0) {
|
||||||
|
show_toast({
|
||||||
|
type: "success",
|
||||||
|
icon: "check",
|
||||||
|
text: $_("bulk-edit-updated-trails", {
|
||||||
|
values: { n: updatedCount },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onUpdate?.(updatedTrails);
|
||||||
|
}
|
||||||
|
|
||||||
async function exportTrails(exportSettings: {
|
async function exportTrails(exportSettings: {
|
||||||
fileFormat: "gpx" | "json";
|
fileFormat: "gpx" | "json";
|
||||||
photos: boolean;
|
photos: boolean;
|
||||||
@@ -850,5 +1003,12 @@
|
|||||||
bind:this={trailMergeModal}
|
bind:this={trailMergeModal}
|
||||||
onmerge={(settings, selection) => mergeTrails(settings, selection)}
|
onmerge={(settings, selection) => mergeTrails(settings, selection)}
|
||||||
></TrailMergeModal>
|
></TrailMergeModal>
|
||||||
|
<TrailBulkEditModal
|
||||||
|
selectedCount={trails?.size ?? 0}
|
||||||
|
initialCategorySelection={selectedCategorySelection()}
|
||||||
|
initialDifficulty={selectedDifficulty()}
|
||||||
|
bind:this={trailBulkEditModal}
|
||||||
|
onapply={(changes) => updateTrailsBulk(changes)}
|
||||||
|
></TrailBulkEditModal>
|
||||||
|
|
||||||
<MergeDialog/>
|
<MergeDialog/>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
import Search, { type SearchItem } from "../base/search.svelte";
|
import Search, { type SearchItem } from "../base/search.svelte";
|
||||||
import type { SelectItem } from "../base/select.svelte";
|
import type { SelectItem } from "../base/select.svelte";
|
||||||
import Slider from "../base/slider.svelte";
|
import Slider from "../base/slider.svelte";
|
||||||
|
import TrailCategoryFilter from "./trail_category_filter.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
categories: Category[];
|
categories: Category[];
|
||||||
@@ -37,13 +38,6 @@
|
|||||||
onupdate,
|
onupdate,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let categorySelectItems = $derived(
|
|
||||||
categories.map((c) => ({
|
|
||||||
value: c.name,
|
|
||||||
text: c.name,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
const radioGroupCompletenessItems: RadioItem[] = [
|
const radioGroupCompletenessItems: RadioItem[] = [
|
||||||
{ text: $_("completed"), value: "completed" },
|
{ text: $_("completed"), value: "completed" },
|
||||||
{ text: $_("not-completed"), value: "not_completed" },
|
{ text: $_("not-completed"), value: "not_completed" },
|
||||||
@@ -66,12 +60,6 @@
|
|||||||
onupdate?.(filter);
|
onupdate?.(filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setCategoryFilter(categories: SelectItem[]) {
|
|
||||||
filter.category = categories.map((c) => c.value);
|
|
||||||
|
|
||||||
update();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setAuthorFilter(item: SearchItem) {
|
function setAuthorFilter(item: SearchItem) {
|
||||||
filter.author = item.value.id;
|
filter.author = item.value.id;
|
||||||
update();
|
update();
|
||||||
@@ -200,15 +188,11 @@
|
|||||||
{#if showTrailSearch}
|
{#if showTrailSearch}
|
||||||
<hr class="my-4 border-separator" />
|
<hr class="my-4 border-separator" />
|
||||||
{/if}
|
{/if}
|
||||||
<MultiSelect
|
<TrailCategoryFilter
|
||||||
onchange={(value) => setCategoryFilter(value)}
|
{categories}
|
||||||
value={categorySelectItems.filter((i) =>
|
bind:filter
|
||||||
filter.category.includes(i.value),
|
onupdate={update}
|
||||||
)}
|
/>
|
||||||
label={$_("categories")}
|
|
||||||
items={categorySelectItems}
|
|
||||||
placeholder={`${$_("filter-categories")}...`}
|
|
||||||
></MultiSelect>
|
|
||||||
<hr class="my-4 border-separator" />
|
<hr class="my-4 border-separator" />
|
||||||
<Combobox
|
<Combobox
|
||||||
bind:value={getFilterTags, setFilterTags}
|
bind:value={getFilterTags, setFilterTags}
|
||||||
|
|||||||
@@ -21,6 +21,12 @@
|
|||||||
formatElevation,
|
formatElevation,
|
||||||
formatTimeHHMM,
|
formatTimeHHMM,
|
||||||
} from "$lib/util/format_util";
|
} from "$lib/util/format_util";
|
||||||
|
import {
|
||||||
|
displayCategoryIcon,
|
||||||
|
displayCategoryName,
|
||||||
|
displaySubcategoryIcon,
|
||||||
|
displaySubcategoryLabel,
|
||||||
|
} from "$lib/util/category_util";
|
||||||
|
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg";
|
import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg";
|
||||||
@@ -30,7 +36,7 @@
|
|||||||
import * as M from "maplibre-gl";
|
import * as M from "maplibre-gl";
|
||||||
import "photoswipe/style.css";
|
import "photoswipe/style.css";
|
||||||
import { onMount, untrack } from "svelte";
|
import { onMount, untrack } from "svelte";
|
||||||
import { _ } from "svelte-i18n";
|
import { _, locale } from "svelte-i18n";
|
||||||
import Button from "../base/button.svelte";
|
import Button from "../base/button.svelte";
|
||||||
import Chip from "../base/chip.svelte";
|
import Chip from "../base/chip.svelte";
|
||||||
import SkeletonNotificationCard from "../base/skeleton_notification_card.svelte";
|
import SkeletonNotificationCard from "../base/skeleton_notification_card.svelte";
|
||||||
@@ -87,6 +93,17 @@
|
|||||||
|
|
||||||
let trail = $state(untrack(() => initTrail));
|
let trail = $state(untrack(() => initTrail));
|
||||||
|
|
||||||
|
function trailCategoryIcon() {
|
||||||
|
if (trail.expand?.subcategory) {
|
||||||
|
return displaySubcategoryIcon(
|
||||||
|
trail.expand.subcategory,
|
||||||
|
trail.expand?.category,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return displayCategoryIcon(trail.expand?.category);
|
||||||
|
}
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
$_("summit-book"),
|
$_("summit-book"),
|
||||||
$_("photos"),
|
$_("photos"),
|
||||||
@@ -747,10 +764,20 @@
|
|||||||
>{#if mode == "overview"}
|
>{#if mode == "overview"}
|
||||||
{$_("category")}
|
{$_("category")}
|
||||||
{:else}
|
{:else}
|
||||||
<i class="fa fa-route"></i>
|
<i class="fa {trailCategoryIcon()}"></i>
|
||||||
{/if}</span
|
{/if}</span
|
||||||
>
|
>
|
||||||
<span class="">{$_(trail.expand.category.name)}</span>
|
<span class="">
|
||||||
|
{displayCategoryName(trail.expand.category, $locale)}
|
||||||
|
{#if trail.expand?.subcategory}
|
||||||
|
<span class="text-gray-500">
|
||||||
|
/ {displaySubcategoryLabel(
|
||||||
|
trail.expand.subcategory,
|
||||||
|
$locale,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
filter = $bindable(null),
|
filter = $bindable(null),
|
||||||
trails,
|
trails = $bindable([]),
|
||||||
pagination = {
|
pagination = {
|
||||||
page: 1,
|
page: 1,
|
||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
@@ -307,7 +307,40 @@
|
|||||||
onupdate?.(filter, selection);
|
onupdate?.(filter, selection);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleTrailsEditDone(resetSelection: boolean = false) {
|
async function handleTrailsEditDone(
|
||||||
|
resetSelection: boolean = false,
|
||||||
|
updatedTrails: Trail[] = [],
|
||||||
|
) {
|
||||||
|
if (updatedTrails.length > 0) {
|
||||||
|
const updatedByID = new Map(
|
||||||
|
updatedTrails
|
||||||
|
.filter((trail) => trail.id)
|
||||||
|
.map((trail) => [trail.id, trail]),
|
||||||
|
);
|
||||||
|
// Keep the richer expand fields from the existing (search-derived)
|
||||||
|
// trail (e.g. author, likes) that trails_update does not return,
|
||||||
|
// and only overlay the changed values and their expands.
|
||||||
|
const mergeUpdated = (trail: Trail) => {
|
||||||
|
const updated = updatedByID.get(trail.id);
|
||||||
|
if (!updated) return trail;
|
||||||
|
return {
|
||||||
|
...trail,
|
||||||
|
...updated,
|
||||||
|
expand: { ...trail.expand, ...updated.expand },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
trails = trails.map(mergeUpdated);
|
||||||
|
if (resetSelection) {
|
||||||
|
selection = new Set<Trail>();
|
||||||
|
hoveredTrail = undefined;
|
||||||
|
} else if (selection) {
|
||||||
|
selection = new Set([...selection].map(mergeUpdated));
|
||||||
|
}
|
||||||
|
// The updated trails already carry the fresh data; skip the refetch
|
||||||
|
// because the search index is updated asynchronously and would
|
||||||
|
// return stale categories, overwriting the local update.
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (resetSelection) {
|
if (resetSelection) {
|
||||||
selection = new Set<Trail>();
|
selection = new Set<Trail>();
|
||||||
hoveredTrail = undefined;
|
hoveredTrail = undefined;
|
||||||
@@ -356,7 +389,8 @@
|
|||||||
onDelete={() => handleTrailsEditDone(true)}
|
onDelete={() => handleTrailsEditDone(true)}
|
||||||
onShare={() => handleTrailsEditDone(false)}
|
onShare={() => handleTrailsEditDone(false)}
|
||||||
onMerge={() => handleTrailsMergeDone(true)}
|
onMerge={() => handleTrailsMergeDone(true)}
|
||||||
onUpdate={() => handleTrailsEditDone(true)}
|
onUpdate={(updatedTrails) =>
|
||||||
|
handleTrailsEditDone(true, updatedTrails)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -11,7 +11,13 @@
|
|||||||
formatHTMLAsText,
|
formatHTMLAsText,
|
||||||
formatTimeHHMM,
|
formatTimeHHMM,
|
||||||
} from "$lib/util/format_util";
|
} from "$lib/util/format_util";
|
||||||
import { _ } from "svelte-i18n";
|
import {
|
||||||
|
displayCategoryName,
|
||||||
|
displaySubcategoryLabel,
|
||||||
|
displayTrailCategoryBadgeIcon,
|
||||||
|
displayTrailCategoryIcon,
|
||||||
|
} from "$lib/util/category_util";
|
||||||
|
import { _, locale } from "svelte-i18n";
|
||||||
import ShareInfo from "../share_info.svelte";
|
import ShareInfo from "../share_info.svelte";
|
||||||
import { handleFromRecordWithIRI } from "$lib/util/activitypub_util";
|
import { handleFromRecordWithIRI } from "$lib/util/activitypub_util";
|
||||||
import Chip from "../base/chip.svelte";
|
import Chip from "../base/chip.svelte";
|
||||||
@@ -57,6 +63,7 @@
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
expandedTags = !expandedTags;
|
expandedTags = !expandedTags;
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<li
|
<li
|
||||||
@@ -155,9 +162,27 @@
|
|||||||
<div class="flex flex-wrap gap-x-8 gap-y-1">
|
<div class="flex flex-wrap gap-x-8 gap-y-1">
|
||||||
{#if trail.expand?.category?.name || trail.category}
|
{#if trail.expand?.category?.name || trail.category}
|
||||||
<p>
|
<p>
|
||||||
<i class="fa fa-shapes mr-3"> </i>{$_(
|
<span class="relative mr-3 inline-block w-4 text-center">
|
||||||
trail.expand?.category?.name ?? trail.category ?? "-",
|
<i class="fa {displayTrailCategoryIcon(trail)}"></i>
|
||||||
)}
|
{#if displayTrailCategoryBadgeIcon(trail)}
|
||||||
|
<i
|
||||||
|
class="fa {displayTrailCategoryBadgeIcon(
|
||||||
|
trail,
|
||||||
|
)} absolute -right-1 -top-1 text-[8px]"
|
||||||
|
></i>
|
||||||
|
{/if}
|
||||||
|
</span>{displayCategoryName(
|
||||||
|
trail.expand?.category ?? { name: trail.category ?? "" },
|
||||||
|
$locale,
|
||||||
|
) || "-"}
|
||||||
|
{#if trail.expand?.subcategory}
|
||||||
|
<span class="text-gray-500">
|
||||||
|
/ {displaySubcategoryLabel(
|
||||||
|
trail.expand.subcategory,
|
||||||
|
$locale,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if trail.location}
|
{#if trail.location}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
attributionControl: false,
|
attributionControl: false,
|
||||||
dragPan: false,
|
dragPan: false,
|
||||||
scrollZoom: false,
|
scrollZoom: false,
|
||||||
preserveDrawingBuffer: true,
|
canvasContextAttributes: { preserveDrawingBuffer: true }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
"Canoeing": "Kanufahren",
|
"Canoeing": "Kanufahren",
|
||||||
"Climbing": "Klettern",
|
"Climbing": "Klettern",
|
||||||
"Hiking": "Wandern",
|
"Hiking": "Wandern",
|
||||||
|
"Running": "Laufen",
|
||||||
"Other": "Sonstiges",
|
"Other": "Sonstiges",
|
||||||
"Skiing": "Skifahren",
|
"Skiing": "Skifahren",
|
||||||
"Walking": "Laufen",
|
"Walking": "Spazieren",
|
||||||
"about": "Über",
|
"about": "Über",
|
||||||
"account-delete-confirm": "Du bist dabei, dein Konto zu löschen. Alle deine Routen werden ebenfalls gelöscht. Möchtest du fortfahren?",
|
"account-delete-confirm": "Du bist dabei, dein Konto zu löschen. Alle deine Routen werden ebenfalls gelöscht. Möchtest du fortfahren?",
|
||||||
"account-privacy": "Privatsphäre des Kontos",
|
"account-privacy": "Privatsphäre des Kontos",
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
"add-waypoint": "Wegpunkt hinzufügen",
|
"add-waypoint": "Wegpunkt hinzufügen",
|
||||||
"added-trail-to": "Route hinzugefügt zu",
|
"added-trail-to": "Route hinzugefügt zu",
|
||||||
"added-trails-to": "Routen hinzugefügt zu",
|
"added-trails-to": "Routen hinzugefügt zu",
|
||||||
|
"adjust": "Anpassen",
|
||||||
"after": "Nach",
|
"after": "Nach",
|
||||||
"all-activities": "Alle Aktivitäten",
|
"all-activities": "Alle Aktivitäten",
|
||||||
"allow-auto-geolocate": "Beginne das Zeichnen einer neuen Route am aktuellen Standort",
|
"allow-auto-geolocate": "Beginne das Zeichnen einer neuen Route am aktuellen Standort",
|
||||||
@@ -31,6 +33,7 @@
|
|||||||
"append-waypoint-description": "Kommentar anhängen",
|
"append-waypoint-description": "Kommentar anhängen",
|
||||||
"append-waypoint-photos": "Fotos hinzufügen",
|
"append-waypoint-photos": "Fotos hinzufügen",
|
||||||
"append-waypoint-title": "Titel anhängen",
|
"append-waypoint-title": "Titel anhängen",
|
||||||
|
"apply": "Anwenden",
|
||||||
"apply-user-settings": "Benutzereinstellungen anwenden",
|
"apply-user-settings": "Benutzereinstellungen anwenden",
|
||||||
"attraction": "Sehenswürdigkeit",
|
"attraction": "Sehenswürdigkeit",
|
||||||
"author": "Autor",
|
"author": "Autor",
|
||||||
@@ -49,6 +52,8 @@
|
|||||||
"bicycle-rental": "Fahrradverleih",
|
"bicycle-rental": "Fahrradverleih",
|
||||||
"bicycle-shop": "Fahrrad-Reparatur",
|
"bicycle-shop": "Fahrrad-Reparatur",
|
||||||
"bike-type": "Fahrradtyp",
|
"bike-type": "Fahrradtyp",
|
||||||
|
"bulk-edit-selected-trails": "{n, plural, =1 {1 ausgewählte Route} other {# ausgewählte Routen}}",
|
||||||
|
"bulk-edit-updated-trails": "{n, plural, =1 {1 Route aktualisiert} other {# Routen aktualisiert}}",
|
||||||
"bus-stop": "Bushaltestelle",
|
"bus-stop": "Bushaltestelle",
|
||||||
"by": "von",
|
"by": "von",
|
||||||
"calendar": {
|
"calendar": {
|
||||||
@@ -70,6 +75,24 @@
|
|||||||
"card": "{n, plural, =1 {Karte} other {Karten}}",
|
"card": "{n, plural, =1 {Karte} other {Karten}}",
|
||||||
"categories": "Kategorien",
|
"categories": "Kategorien",
|
||||||
"category": "Kategorie",
|
"category": "Kategorie",
|
||||||
|
"category-preference-visible": "Anzeigen",
|
||||||
|
"category-preferences": "Kategorie-Einstellungen",
|
||||||
|
"category-preferences-description": "Definiere, welche Kategorien und Unterkategorien für deine Routen relevant sind und in welcher Reihenfolge Kategorien erscheinen sollen.",
|
||||||
|
"confirm-disable-category-with-trails-title": "Kategorie ausblenden?",
|
||||||
|
"confirm-disable-category-intro": "Diese Kategorie wird noch an einigen Stellen verwendet. Prüfe die Konflikte, bevor du sie ausblendest.",
|
||||||
|
"confirm-disable-category-with-trails": "{count, plural, =1 {1 deiner eigenen Routen verwendet „{name}“.} other {# deiner eigenen Routen verwenden „{name}“.}}",
|
||||||
|
"confirm-disable-category-active-plugin-mappings": "Aktive Plugins referenzieren diese Kategorie in ihren Zuordnungen: {plugins}.",
|
||||||
|
"confirm-disable-category-inactive-plugin-mappings": "Vorbereitete, aber inaktive Plugins referenzieren diese Kategorie ebenfalls: {plugins}.",
|
||||||
|
"confirm-disable-category-anyway": "Diese Kategorie trotzdem ausblenden?",
|
||||||
|
"confirm-disable-subcategory-with-trails-title": "Unterkategorie ausblenden?",
|
||||||
|
"confirm-disable-subcategory-intro": "Diese Unterkategorie wird noch an einigen Stellen verwendet. Prüfe die Konflikte, bevor du sie ausblendest.",
|
||||||
|
"confirm-disable-subcategory-with-trails": "{count, plural, =1 {1 deiner eigenen Routen verwendet „{name}“.} other {# deiner eigenen Routen verwenden „{name}“.}}",
|
||||||
|
"confirm-disable-subcategory-active-plugin-mappings": "Aktive Plugins referenzieren diese Unterkategorie in ihren Zuordnungen: {plugins}.",
|
||||||
|
"confirm-disable-subcategory-inactive-plugin-mappings": "Vorbereitete, aber inaktive Plugins referenzieren diese Unterkategorie ebenfalls: {plugins}.",
|
||||||
|
"confirm-disable-subcategory-anyway": "Diese Unterkategorie trotzdem ausblenden?",
|
||||||
|
"conflicts": "Konflikte",
|
||||||
|
"collapse-subcategories": "Unterkategorien einklappen",
|
||||||
|
"category-filter-hidden-active": "{categories} ist in deinen Kategorie-Einstellungen ausgeblendet.",
|
||||||
"category-mapping": "Kategorie-Zuordnung",
|
"category-mapping": "Kategorie-Zuordnung",
|
||||||
"category-mapping-help": "Entfernte Provider-Kategorien werden bewusst nicht zugeordnet und erhalten beim Import keine Kategorie.",
|
"category-mapping-help": "Entfernte Provider-Kategorien werden bewusst nicht zugeordnet und erhalten beim Import keine Kategorie.",
|
||||||
"change": "Ändern",
|
"change": "Ändern",
|
||||||
@@ -175,6 +198,7 @@
|
|||||||
"error-printing-map": "Fehler beim Drucken der Karte",
|
"error-printing-map": "Fehler beim Drucken der Karte",
|
||||||
"error-reading-file": "Fehler beim Lesen der Datei",
|
"error-reading-file": "Fehler beim Lesen der Datei",
|
||||||
"error-saving-list": "Fehler beim Speichern der Liste",
|
"error-saving-list": "Fehler beim Speichern der Liste",
|
||||||
|
"error-saving-settings": "Fehler beim Speichern der Einstellungen",
|
||||||
"error-saving-trail": "Fehler beim Speichern der Route",
|
"error-saving-trail": "Fehler beim Speichern der Route",
|
||||||
"error-setting-up-plugin": "Fehler beim Einrichten des {provider}-Plugins",
|
"error-setting-up-plugin": "Fehler beim Einrichten des {provider}-Plugins",
|
||||||
"error-starting-oauth": "Fehler beim Starten der OAuth-Verbindung",
|
"error-starting-oauth": "Fehler beim Starten der OAuth-Verbindung",
|
||||||
@@ -185,9 +209,14 @@
|
|||||||
"error-uploading-trail-to-hammerhead": "Fehler beim Hochladen der Route zu Hammerhead",
|
"error-uploading-trail-to-hammerhead": "Fehler beim Hochladen der Route zu Hammerhead",
|
||||||
"est-duration": "Gesch. Dauer",
|
"est-duration": "Gesch. Dauer",
|
||||||
"everyone-with-the-link": "Jeder mit dem Link",
|
"everyone-with-the-link": "Jeder mit dem Link",
|
||||||
|
"expand-subcategories": "Unterkategorien ausklappen",
|
||||||
"expand-trail-list": "",
|
"expand-trail-list": "",
|
||||||
"expiration": "",
|
"expiration": "",
|
||||||
"expires": "",
|
"expires": "",
|
||||||
|
"exclude-federated": "Föderierte ausschließen",
|
||||||
|
"exclude-search": "Von Suche ausschließen",
|
||||||
|
"include-federated": "Föderierte einschließen",
|
||||||
|
"include-search": "In Suche einschließen",
|
||||||
"explore": "Erkunden",
|
"explore": "Erkunden",
|
||||||
"explore-some-trails": "Erkunde einige Routen",
|
"explore-some-trails": "Erkunde einige Routen",
|
||||||
"export": "Exportieren",
|
"export": "Exportieren",
|
||||||
@@ -223,6 +252,9 @@
|
|||||||
"get-started": "Los geht’s",
|
"get-started": "Los geht’s",
|
||||||
"grid": "Gitter",
|
"grid": "Gitter",
|
||||||
"grocery-store": "Lebensmittelgeschäft",
|
"grocery-store": "Lebensmittelgeschäft",
|
||||||
|
"hide-design": "Im Routeneditor ausblenden",
|
||||||
|
"show-design": "Im Routeneditor auswählbar",
|
||||||
|
"hammerhead-integration-after-date-hint": "Wenn Ihr Hammerhead Konto bereits mit anderen Trail-Datenbanken wie komoot oder Strava synchronisiert ist, kann die zusätzliche Synchronisierung Ihrer Hammerhead-Daten zu Duplikaten führen. Um dies zu vermeiden, können Sie unten ein Startdatum festlegen, sodass nur Aktivitäten synchronisiert werden, die nach diesem Datum aufgezeichnet wurden.",
|
||||||
"heading": "Überschrift",
|
"heading": "Überschrift",
|
||||||
"height": "Höhe",
|
"height": "Höhe",
|
||||||
"help": "Hilfe",
|
"help": "Hilfe",
|
||||||
@@ -362,6 +394,7 @@
|
|||||||
"no-preference": "Keine Präferenz",
|
"no-preference": "Keine Präferenz",
|
||||||
"no-results": "Keine Ergebnisse gefunden",
|
"no-results": "Keine Ergebnisse gefunden",
|
||||||
"no-routes-added": "Keine Routen hinzugefügt",
|
"no-routes-added": "Keine Routen hinzugefügt",
|
||||||
|
"no-subcategory": "Keine Unterkategorie",
|
||||||
"no-waypoints-yet": "Noch keine Wegpunkte",
|
"no-waypoints-yet": "Noch keine Wegpunkte",
|
||||||
"norwegian": "Norwegisch",
|
"norwegian": "Norwegisch",
|
||||||
"not-a-valid-email-address": "Keine gültige Email-Adresse",
|
"not-a-valid-email-address": "Keine gültige Email-Adresse",
|
||||||
@@ -407,6 +440,7 @@
|
|||||||
"profile": "Profil",
|
"profile": "Profil",
|
||||||
"provider-category": "Provider-Kategorie",
|
"provider-category": "Provider-Kategorie",
|
||||||
"public": "Öffentlich",
|
"public": "Öffentlich",
|
||||||
|
"priority": "Priorität",
|
||||||
"public-access": "Öffentlicher Zugriff",
|
"public-access": "Öffentlicher Zugriff",
|
||||||
"public-share-everyone": "Jeder im Internet mit dem Link kann diese Route sehen",
|
"public-share-everyone": "Jeder im Internet mit dem Link kann diese Route sehen",
|
||||||
"public-share-limited": "Nur Leute mit Zugriff können diese Route sehen",
|
"public-share-limited": "Nur Leute mit Zugriff können diese Route sehen",
|
||||||
@@ -503,6 +537,7 @@
|
|||||||
"stop-drawing": "Zeichnen beenden",
|
"stop-drawing": "Zeichnen beenden",
|
||||||
"stop-editing": "Bearbeiten beenden",
|
"stop-editing": "Bearbeiten beenden",
|
||||||
"subway-stop": "U-Bahn Eingang",
|
"subway-stop": "U-Bahn Eingang",
|
||||||
|
"subcategory": "Unterkategorie",
|
||||||
"summit": "Gipfel",
|
"summit": "Gipfel",
|
||||||
"summit-book": "Gipfelbuch",
|
"summit-book": "Gipfelbuch",
|
||||||
"summit-log": "{n, plural, =1 {Gipfelbuch-Eintrag} other {Gipfelbuch-Einträge}}",
|
"summit-log": "{n, plural, =1 {Gipfelbuch-Eintrag} other {Gipfelbuch-Einträge}}",
|
||||||
@@ -588,6 +623,7 @@
|
|||||||
"tram-stop": "Tram Haltestelle",
|
"tram-stop": "Tram Haltestelle",
|
||||||
"unchanged": "unverändert",
|
"unchanged": "unverändert",
|
||||||
"units": "Einheiten",
|
"units": "Einheiten",
|
||||||
|
"unprioritized": "Nicht priorisiert",
|
||||||
"unlink": "Trennen",
|
"unlink": "Trennen",
|
||||||
"upload-file": "Datei hochladen",
|
"upload-file": "Datei hochladen",
|
||||||
"upload-gpx": "GPX hochladen",
|
"upload-gpx": "GPX hochladen",
|
||||||
@@ -602,6 +638,7 @@
|
|||||||
"username": "Nutzername",
|
"username": "Nutzername",
|
||||||
"username-not-unique": "Dieser Nutzername ist bereits vergeben. Bitte versuche es mit einem anderen.",
|
"username-not-unique": "Dieser Nutzername ist bereits vergeben. Bitte versuche es mit einem anderen.",
|
||||||
"view": "Ansehen",
|
"view": "Ansehen",
|
||||||
|
"view-affected-trails": "Betroffene Routen ansehen",
|
||||||
"viewpoint": "Aussichtspunkt",
|
"viewpoint": "Aussichtspunkt",
|
||||||
"visibilty": "",
|
"visibilty": "",
|
||||||
"visibilty-status": "Sichtbarkeit",
|
"visibilty-status": "Sichtbarkeit",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"Canoeing": "Canoeing",
|
"Canoeing": "Canoeing",
|
||||||
"Climbing": "Climbing",
|
"Climbing": "Climbing",
|
||||||
"Hiking": "Hiking",
|
"Hiking": "Hiking",
|
||||||
|
"Running": "Running",
|
||||||
"Other": "Other",
|
"Other": "Other",
|
||||||
"Skiing": "",
|
"Skiing": "",
|
||||||
"Walking": "Walking",
|
"Walking": "Walking",
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
"add-waypoint": "Add Waypoint",
|
"add-waypoint": "Add Waypoint",
|
||||||
"added-trail-to": "Added trail to",
|
"added-trail-to": "Added trail to",
|
||||||
"added-trails-to": "Added trails to",
|
"added-trails-to": "Added trails to",
|
||||||
|
"adjust": "Adjust",
|
||||||
"after": "After",
|
"after": "After",
|
||||||
"all-activities": "All activities",
|
"all-activities": "All activities",
|
||||||
"allow-auto-geolocate": "Begin drawing a new trail from your current location",
|
"allow-auto-geolocate": "Begin drawing a new trail from your current location",
|
||||||
@@ -31,6 +33,7 @@
|
|||||||
"append-waypoint-description": "Append description",
|
"append-waypoint-description": "Append description",
|
||||||
"append-waypoint-photos": "Add photos",
|
"append-waypoint-photos": "Add photos",
|
||||||
"append-waypoint-title": "Append title",
|
"append-waypoint-title": "Append title",
|
||||||
|
"apply": "Apply",
|
||||||
"apply-user-settings": "Apply user settings",
|
"apply-user-settings": "Apply user settings",
|
||||||
"attraction": "Attraction",
|
"attraction": "Attraction",
|
||||||
"author": "Author",
|
"author": "Author",
|
||||||
@@ -49,6 +52,8 @@
|
|||||||
"bicycle-rental": "Bicycle Rental",
|
"bicycle-rental": "Bicycle Rental",
|
||||||
"bicycle-shop": "Bicycle Shop",
|
"bicycle-shop": "Bicycle Shop",
|
||||||
"bike-type": "Bike Type",
|
"bike-type": "Bike Type",
|
||||||
|
"bulk-edit-selected-trails": "{n, plural, =1 {1 selected trail} other {# selected trails}}",
|
||||||
|
"bulk-edit-updated-trails": "{n, plural, =1 {1 trail updated} other {# trails updated}}",
|
||||||
"bus-stop": "Bus stop",
|
"bus-stop": "Bus stop",
|
||||||
"by": "by",
|
"by": "by",
|
||||||
"calendar": {
|
"calendar": {
|
||||||
@@ -70,6 +75,24 @@
|
|||||||
"card": "{n, plural, =1 {Card} other {Cards}}",
|
"card": "{n, plural, =1 {Card} other {Cards}}",
|
||||||
"categories": "Categories",
|
"categories": "Categories",
|
||||||
"category": "Category",
|
"category": "Category",
|
||||||
|
"category-preference-visible": "Show",
|
||||||
|
"category-preferences": "Category preferences",
|
||||||
|
"category-preferences-description": "Define which categories and subcategories are relevant to your trails and the order in which categories appear.",
|
||||||
|
"confirm-disable-category-with-trails-title": "Hide category?",
|
||||||
|
"confirm-disable-category-intro": "This category is still used in a few places. Review the conflicts before hiding it.",
|
||||||
|
"confirm-disable-category-with-trails": "{count, plural, =1 {1 of your own trails uses “{name}”.} other {# of your own trails use “{name}”.}}",
|
||||||
|
"confirm-disable-category-active-plugin-mappings": "Active plugins reference this category in their mappings: {plugins}.",
|
||||||
|
"confirm-disable-category-inactive-plugin-mappings": "Prepared but inactive plugins also reference this category: {plugins}.",
|
||||||
|
"confirm-disable-category-anyway": "Hide this category anyway?",
|
||||||
|
"confirm-disable-subcategory-with-trails-title": "Hide subcategory?",
|
||||||
|
"confirm-disable-subcategory-intro": "This subcategory is still used in a few places. Review the conflicts before hiding it.",
|
||||||
|
"confirm-disable-subcategory-with-trails": "{count, plural, =1 {1 of your own trails uses “{name}”.} other {# of your own trails use “{name}”.}}",
|
||||||
|
"confirm-disable-subcategory-active-plugin-mappings": "Active plugins reference this subcategory in their mappings: {plugins}.",
|
||||||
|
"confirm-disable-subcategory-inactive-plugin-mappings": "Prepared but inactive plugins also reference this subcategory: {plugins}.",
|
||||||
|
"confirm-disable-subcategory-anyway": "Hide this subcategory anyway?",
|
||||||
|
"conflicts": "Conflicts",
|
||||||
|
"collapse-subcategories": "Collapse subcategories",
|
||||||
|
"category-filter-hidden-active": "{categories} is hidden in your category preferences.",
|
||||||
"category-mapping": "Category mapping",
|
"category-mapping": "Category mapping",
|
||||||
"category-mapping-help": "Removed provider categories are intentionally left unmapped and will be imported without a category.",
|
"category-mapping-help": "Removed provider categories are intentionally left unmapped and will be imported without a category.",
|
||||||
"change": "Change",
|
"change": "Change",
|
||||||
@@ -175,6 +198,7 @@
|
|||||||
"error-printing-map": "Error printing map",
|
"error-printing-map": "Error printing map",
|
||||||
"error-reading-file": "Error reading file",
|
"error-reading-file": "Error reading file",
|
||||||
"error-saving-list": "Error saving list",
|
"error-saving-list": "Error saving list",
|
||||||
|
"error-saving-settings": "Error saving settings",
|
||||||
"error-saving-trail": "Error saving trail",
|
"error-saving-trail": "Error saving trail",
|
||||||
"error-setting-up-plugin": "Error setting up {provider} plugin",
|
"error-setting-up-plugin": "Error setting up {provider} plugin",
|
||||||
"error-starting-oauth": "Error starting OAuth connection",
|
"error-starting-oauth": "Error starting OAuth connection",
|
||||||
@@ -185,9 +209,14 @@
|
|||||||
"error-uploading-trail-to-hammerhead": "Error uploading trail to Hammerhead",
|
"error-uploading-trail-to-hammerhead": "Error uploading trail to Hammerhead",
|
||||||
"est-duration": "Est. duration",
|
"est-duration": "Est. duration",
|
||||||
"everyone-with-the-link": "Everyone with the link",
|
"everyone-with-the-link": "Everyone with the link",
|
||||||
|
"expand-subcategories": "Expand subcategories",
|
||||||
"expand-trail-list": "Expand trail list",
|
"expand-trail-list": "Expand trail list",
|
||||||
"expiration": "Expiration",
|
"expiration": "Expiration",
|
||||||
"expires": "Expires",
|
"expires": "Expires",
|
||||||
|
"exclude-federated": "Exclude federated",
|
||||||
|
"exclude-search": "Exclude from search",
|
||||||
|
"include-federated": "Include federated",
|
||||||
|
"include-search": "Include in search",
|
||||||
"explore": "Explore",
|
"explore": "Explore",
|
||||||
"explore-some-trails": "Explore some trails",
|
"explore-some-trails": "Explore some trails",
|
||||||
"export": "Export",
|
"export": "Export",
|
||||||
@@ -223,6 +252,9 @@
|
|||||||
"get-started": "Get started",
|
"get-started": "Get started",
|
||||||
"grid": "Grid",
|
"grid": "Grid",
|
||||||
"grocery-store": "Grocery store",
|
"grocery-store": "Grocery store",
|
||||||
|
"hide-design": "Hide in trail editor",
|
||||||
|
"show-design": "Available in trail editor",
|
||||||
|
"hammerhead-integration-after-date-hint": "If your hammerhead account is already synced with other trail databases, such as komoot or Strava, start syncing your Hammerhead data may result in duplicates. To avoid this, you can set an start date below, meaning only activities recorded after this date will be synced.",
|
||||||
"heading": "Heading",
|
"heading": "Heading",
|
||||||
"height": "Height",
|
"height": "Height",
|
||||||
"help": "Help",
|
"help": "Help",
|
||||||
@@ -362,6 +394,7 @@
|
|||||||
"no-preference": "No preference",
|
"no-preference": "No preference",
|
||||||
"no-results": "No results found",
|
"no-results": "No results found",
|
||||||
"no-routes-added": "No routes added",
|
"no-routes-added": "No routes added",
|
||||||
|
"no-subcategory": "No subcategory",
|
||||||
"no-waypoints-yet": "No waypoints yet",
|
"no-waypoints-yet": "No waypoints yet",
|
||||||
"norwegian": "Norwegian",
|
"norwegian": "Norwegian",
|
||||||
"not-a-valid-email-address": "Not a valid email address",
|
"not-a-valid-email-address": "Not a valid email address",
|
||||||
@@ -407,6 +440,7 @@
|
|||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"provider-category": "Provider category",
|
"provider-category": "Provider category",
|
||||||
"public": "Public",
|
"public": "Public",
|
||||||
|
"priority": "Priority",
|
||||||
"public-access": "Public access",
|
"public-access": "Public access",
|
||||||
"public-share-everyone": "Everyone on the internet with the link can see this trail",
|
"public-share-everyone": "Everyone on the internet with the link can see this trail",
|
||||||
"public-share-limited": "Only people with access can open the link",
|
"public-share-limited": "Only people with access can open the link",
|
||||||
@@ -503,6 +537,7 @@
|
|||||||
"stop-drawing": "Stop drawing",
|
"stop-drawing": "Stop drawing",
|
||||||
"stop-editing": "Stop editing",
|
"stop-editing": "Stop editing",
|
||||||
"subway-stop": "Subway entrance",
|
"subway-stop": "Subway entrance",
|
||||||
|
"subcategory": "Subcategory",
|
||||||
"summit": "Summit",
|
"summit": "Summit",
|
||||||
"summit-book": "Summit Book",
|
"summit-book": "Summit Book",
|
||||||
"summit-log": "{n, plural, =1 {Summit log} other {Summit logs}}",
|
"summit-log": "{n, plural, =1 {Summit log} other {Summit logs}}",
|
||||||
@@ -588,6 +623,7 @@
|
|||||||
"tram-stop": "Tram stop",
|
"tram-stop": "Tram stop",
|
||||||
"unchanged": "unchanged",
|
"unchanged": "unchanged",
|
||||||
"units": "Units",
|
"units": "Units",
|
||||||
|
"unprioritized": "Unprioritized",
|
||||||
"unlink": "Unlink",
|
"unlink": "Unlink",
|
||||||
"upload-file": "Upload file",
|
"upload-file": "Upload file",
|
||||||
"upload-gpx": "Upload GPX",
|
"upload-gpx": "Upload GPX",
|
||||||
@@ -602,6 +638,7 @@
|
|||||||
"username": "Username",
|
"username": "Username",
|
||||||
"username-not-unique": "This username is already taken. Please try another.",
|
"username-not-unique": "This username is already taken. Please try another.",
|
||||||
"view": "View",
|
"view": "View",
|
||||||
|
"view-affected-trails": "View affected trails",
|
||||||
"viewpoint": "Viewpoint",
|
"viewpoint": "Viewpoint",
|
||||||
"visibilty": "Visibility",
|
"visibilty": "Visibility",
|
||||||
"visibilty-status": "Visibility status",
|
"visibilty-status": "Visibility status",
|
||||||
|
|||||||
15
web/src/lib/models/api/category_preference_schema.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const UserCategoryPreferenceUpsertSchema = z.object({
|
||||||
|
category: z.string().length(15),
|
||||||
|
visible: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const UserCategoryPreferenceReorderSchema = z.object({
|
||||||
|
categories: z.array(z.string().length(15)),
|
||||||
|
});
|
||||||
|
|
||||||
|
export {
|
||||||
|
UserCategoryPreferenceReorderSchema,
|
||||||
|
UserCategoryPreferenceUpsertSchema,
|
||||||
|
};
|
||||||
@@ -158,6 +158,195 @@
|
|||||||
* type: string
|
* type: string
|
||||||
* description: Tag name
|
* description: Tag name
|
||||||
*
|
*
|
||||||
|
* CategoryTranslation:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* name:
|
||||||
|
* type: string
|
||||||
|
* short_name:
|
||||||
|
* type: string
|
||||||
|
*
|
||||||
|
* Category:
|
||||||
|
* type: object
|
||||||
|
* required:
|
||||||
|
* - id
|
||||||
|
* - name
|
||||||
|
* properties:
|
||||||
|
* id:
|
||||||
|
* type: string
|
||||||
|
* description: Category ID (15 chars)
|
||||||
|
* name:
|
||||||
|
* type: string
|
||||||
|
* short_name:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* icon:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* translations:
|
||||||
|
* type: object
|
||||||
|
* nullable: true
|
||||||
|
* additionalProperties:
|
||||||
|
* $ref: '#/components/schemas/CategoryTranslation'
|
||||||
|
* settings:
|
||||||
|
* type: object
|
||||||
|
* nullable: true
|
||||||
|
* properties:
|
||||||
|
* wp_merge_enabled:
|
||||||
|
* type: boolean
|
||||||
|
* wp_merge_radius:
|
||||||
|
* type: number
|
||||||
|
* created:
|
||||||
|
* type: string
|
||||||
|
* format: date-time
|
||||||
|
* updated:
|
||||||
|
* type: string
|
||||||
|
* format: date-time
|
||||||
|
*
|
||||||
|
* Subcategory:
|
||||||
|
* type: object
|
||||||
|
* required:
|
||||||
|
* - id
|
||||||
|
* - category
|
||||||
|
* - name
|
||||||
|
* properties:
|
||||||
|
* id:
|
||||||
|
* type: string
|
||||||
|
* description: Subcategory ID (15 chars)
|
||||||
|
* category:
|
||||||
|
* type: string
|
||||||
|
* description: Parent category ID (15 chars)
|
||||||
|
* name:
|
||||||
|
* type: string
|
||||||
|
* short_name:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* icon:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* badge_icon:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* translations:
|
||||||
|
* type: object
|
||||||
|
* nullable: true
|
||||||
|
* additionalProperties:
|
||||||
|
* $ref: '#/components/schemas/CategoryTranslation'
|
||||||
|
* created:
|
||||||
|
* type: string
|
||||||
|
* format: date-time
|
||||||
|
* updated:
|
||||||
|
* type: string
|
||||||
|
* format: date-time
|
||||||
|
*
|
||||||
|
* UserCategoryPreference:
|
||||||
|
* type: object
|
||||||
|
* required:
|
||||||
|
* - id
|
||||||
|
* - user
|
||||||
|
* - category
|
||||||
|
* - visible
|
||||||
|
* properties:
|
||||||
|
* id:
|
||||||
|
* type: string
|
||||||
|
* description: Preference ID (15 chars)
|
||||||
|
* user:
|
||||||
|
* type: string
|
||||||
|
* description: User ID (15 chars)
|
||||||
|
* category:
|
||||||
|
* type: string
|
||||||
|
* description: Category ID (15 chars)
|
||||||
|
* visible:
|
||||||
|
* type: boolean
|
||||||
|
* priority:
|
||||||
|
* type: integer
|
||||||
|
* nullable: true
|
||||||
|
* created:
|
||||||
|
* type: string
|
||||||
|
* format: date-time
|
||||||
|
* updated:
|
||||||
|
* type: string
|
||||||
|
* format: date-time
|
||||||
|
*
|
||||||
|
* UserCategoryPreferenceUpsertInput:
|
||||||
|
* type: object
|
||||||
|
* required:
|
||||||
|
* - category
|
||||||
|
* - visible
|
||||||
|
* properties:
|
||||||
|
* category:
|
||||||
|
* type: string
|
||||||
|
* description: Category ID (15 chars)
|
||||||
|
* visible:
|
||||||
|
* type: boolean
|
||||||
|
*
|
||||||
|
* UserCategoryPreferenceReorderInput:
|
||||||
|
* type: object
|
||||||
|
* required:
|
||||||
|
* - categories
|
||||||
|
* properties:
|
||||||
|
* categories:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* description: Category ID (15 chars)
|
||||||
|
*
|
||||||
|
* UserSubcategoryPreference:
|
||||||
|
* type: object
|
||||||
|
* required:
|
||||||
|
* - id
|
||||||
|
* - user
|
||||||
|
* - subcategory
|
||||||
|
* - visible
|
||||||
|
* properties:
|
||||||
|
* id:
|
||||||
|
* type: string
|
||||||
|
* description: Preference ID (15 chars)
|
||||||
|
* user:
|
||||||
|
* type: string
|
||||||
|
* description: User ID (15 chars)
|
||||||
|
* subcategory:
|
||||||
|
* type: string
|
||||||
|
* description: Subcategory ID (15 chars)
|
||||||
|
* visible:
|
||||||
|
* type: boolean
|
||||||
|
* priority:
|
||||||
|
* type: integer
|
||||||
|
* nullable: true
|
||||||
|
* created:
|
||||||
|
* type: string
|
||||||
|
* format: date-time
|
||||||
|
* updated:
|
||||||
|
* type: string
|
||||||
|
* format: date-time
|
||||||
|
*
|
||||||
|
* UserSubcategoryPreferenceUpsertInput:
|
||||||
|
* type: object
|
||||||
|
* required:
|
||||||
|
* - subcategory
|
||||||
|
* - visible
|
||||||
|
* properties:
|
||||||
|
* subcategory:
|
||||||
|
* type: string
|
||||||
|
* description: Subcategory ID (15 chars)
|
||||||
|
* visible:
|
||||||
|
* type: boolean
|
||||||
|
*
|
||||||
|
* UserSubcategoryPreferenceReorderInput:
|
||||||
|
* type: object
|
||||||
|
* required:
|
||||||
|
* - category
|
||||||
|
* - subcategories
|
||||||
|
* properties:
|
||||||
|
* category:
|
||||||
|
* type: string
|
||||||
|
* description: Category ID (15 chars)
|
||||||
|
* subcategories:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* description: Subcategory ID (15 chars)
|
||||||
|
*
|
||||||
* Trail:
|
* Trail:
|
||||||
* type: object
|
* type: object
|
||||||
* required:
|
* required:
|
||||||
@@ -220,6 +409,9 @@
|
|||||||
* category:
|
* category:
|
||||||
* type: string
|
* type: string
|
||||||
* description: Category ID (15 chars)
|
* description: Category ID (15 chars)
|
||||||
|
* subcategory:
|
||||||
|
* type: string
|
||||||
|
* description: Subcategory ID (15 chars)
|
||||||
* tags:
|
* tags:
|
||||||
* type: array
|
* type: array
|
||||||
* items:
|
* items:
|
||||||
@@ -290,6 +482,8 @@
|
|||||||
* default: 0
|
* default: 0
|
||||||
* category:
|
* category:
|
||||||
* type: string
|
* type: string
|
||||||
|
* subcategory:
|
||||||
|
* type: string
|
||||||
* tags:
|
* tags:
|
||||||
* type: array
|
* type: array
|
||||||
* items:
|
* items:
|
||||||
@@ -348,6 +542,8 @@
|
|||||||
* default: 0
|
* default: 0
|
||||||
* category:
|
* category:
|
||||||
* type: string
|
* type: string
|
||||||
|
* subcategory:
|
||||||
|
* type: string
|
||||||
* tags:
|
* tags:
|
||||||
* type: array
|
* type: array
|
||||||
* items:
|
* items:
|
||||||
|
|||||||
16
web/src/lib/models/api/subcategory_preference_schema.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const UserSubcategoryPreferenceUpsertSchema = z.object({
|
||||||
|
subcategory: z.string().length(15),
|
||||||
|
visible: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const UserSubcategoryPreferenceReorderSchema = z.object({
|
||||||
|
category: z.string().length(15),
|
||||||
|
subcategories: z.array(z.string().length(15)),
|
||||||
|
});
|
||||||
|
|
||||||
|
export {
|
||||||
|
UserSubcategoryPreferenceReorderSchema,
|
||||||
|
UserSubcategoryPreferenceUpsertSchema,
|
||||||
|
};
|
||||||
@@ -21,6 +21,7 @@ const TrailCreateSchema = z.object({
|
|||||||
thumbnail: z.number().int().nonnegative().optional(),
|
thumbnail: z.number().int().nonnegative().optional(),
|
||||||
like_count: z.number().int().min(0).optional().default(0),
|
like_count: z.number().int().min(0).optional().default(0),
|
||||||
category: z.string().length(15).optional().or(z.literal('')),
|
category: z.string().length(15).optional().or(z.literal('')),
|
||||||
|
subcategory: z.string().length(15).optional().or(z.literal('')),
|
||||||
tags: z.array(z.string()).default([]),
|
tags: z.array(z.string()).default([]),
|
||||||
gpx: z.string().optional(),
|
gpx: z.string().optional(),
|
||||||
author: z.string().length(15),
|
author: z.string().length(15),
|
||||||
@@ -47,6 +48,7 @@ const TrailUpdateSchema = z.object({
|
|||||||
thumbnail: z.number().int().nonnegative().optional(),
|
thumbnail: z.number().int().nonnegative().optional(),
|
||||||
like_count: z.number().int().min(0).optional(),
|
like_count: z.number().int().min(0).optional(),
|
||||||
category: z.string().optional(),
|
category: z.string().optional(),
|
||||||
|
subcategory: z.string().optional(),
|
||||||
tags: z.array(z.string()).optional(),
|
tags: z.array(z.string()).optional(),
|
||||||
gpx: z.string().optional(),
|
gpx: z.string().optional(),
|
||||||
}) satisfies ZodType<Partial<Trail>>
|
}) satisfies ZodType<Partial<Trail>>
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
interface Category {
|
interface Category {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
img: string;
|
short_name?: string | null;
|
||||||
|
icon?: string | null;
|
||||||
|
translations?: Record<string, CategoryTranslation> | null;
|
||||||
settings?: Settings | null;
|
settings?: Settings | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CategoryTranslation {
|
||||||
|
name?: string;
|
||||||
|
short_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Settings {
|
interface Settings {
|
||||||
wp_merge_enabled?: boolean;
|
wp_merge_enabled?: boolean;
|
||||||
wp_merge_radius?: number;
|
wp_merge_radius?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type {Category}
|
export type {Category}
|
||||||
|
export type {CategoryTranslation}
|
||||||
export type {Settings}
|
export type {Settings}
|
||||||
|
|||||||
9
web/src/lib/models/category_preference.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
interface UserCategoryPreference {
|
||||||
|
id?: string;
|
||||||
|
user: string;
|
||||||
|
category: string;
|
||||||
|
visible?: boolean;
|
||||||
|
priority?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { UserCategoryPreference };
|
||||||
@@ -24,7 +24,6 @@ class Settings {
|
|||||||
bio?: string | null;
|
bio?: string | null;
|
||||||
mapFocus?: "trails" | "location";
|
mapFocus?: "trails" | "location";
|
||||||
location?: { name: string, lat: number, lon: number } | null;
|
location?: { name: string, lat: number, lon: number } | null;
|
||||||
category?: string;
|
|
||||||
tilesets?: ({ name: string, url: string }[]) | null
|
tilesets?: ({ name: string, url: string }[]) | null
|
||||||
terrain?: { terrain?: string, hillshading?: string } | null;
|
terrain?: { terrain?: string, hillshading?: string } | null;
|
||||||
user?: string;
|
user?: string;
|
||||||
@@ -39,7 +38,6 @@ class Settings {
|
|||||||
user: string,
|
user: string,
|
||||||
params?: {
|
params?: {
|
||||||
location?: { name: string, lat: number, lon: number }
|
location?: { name: string, lat: number, lon: number }
|
||||||
category?: string
|
|
||||||
tilesets?: { name: string, url: string }[]
|
tilesets?: { name: string, url: string }[]
|
||||||
terrain?: { terrain: string, hillshading: string };
|
terrain?: { terrain: string, hillshading: string };
|
||||||
}
|
}
|
||||||
@@ -49,7 +47,6 @@ class Settings {
|
|||||||
this.mapFocus = mapFocus;
|
this.mapFocus = mapFocus;
|
||||||
this.user = user;
|
this.user = user;
|
||||||
this.location = params?.location;
|
this.location = params?.location;
|
||||||
this.category = params?.category;
|
|
||||||
this.tilesets = params?.tilesets ?? [];
|
this.tilesets = params?.tilesets ?? [];
|
||||||
this.terrain = params?.terrain;
|
this.terrain = params?.terrain;
|
||||||
}
|
}
|
||||||
|
|||||||
16
web/src/lib/models/subcategory.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Category, CategoryTranslation } from "./category";
|
||||||
|
|
||||||
|
interface Subcategory {
|
||||||
|
id: string;
|
||||||
|
category: string;
|
||||||
|
name: string;
|
||||||
|
short_name?: string | null;
|
||||||
|
icon?: string | null;
|
||||||
|
badge_icon?: string | null;
|
||||||
|
translations?: Record<string, CategoryTranslation> | null;
|
||||||
|
expand?: {
|
||||||
|
category?: Category;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { Subcategory };
|
||||||
9
web/src/lib/models/subcategory_preference.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
interface UserSubcategoryPreference {
|
||||||
|
id?: string;
|
||||||
|
user: string;
|
||||||
|
subcategory: string;
|
||||||
|
visible?: boolean;
|
||||||
|
priority?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { UserSubcategoryPreference };
|
||||||
@@ -3,6 +3,7 @@ import type { Actor } from "./activitypub/actor";
|
|||||||
import type { Category } from "./category";
|
import type { Category } from "./category";
|
||||||
import type { Comment } from "./comment";
|
import type { Comment } from "./comment";
|
||||||
import type GPX from "./gpx/gpx";
|
import type GPX from "./gpx/gpx";
|
||||||
|
import type { Subcategory } from "./subcategory";
|
||||||
import type { SummitLog } from "./summit_log";
|
import type { SummitLog } from "./summit_log";
|
||||||
import type { Tag } from "./tag";
|
import type { Tag } from "./tag";
|
||||||
import type { TrailLike } from "./trail_like";
|
import type { TrailLike } from "./trail_like";
|
||||||
@@ -29,6 +30,7 @@ class Trail {
|
|||||||
created?: string;
|
created?: string;
|
||||||
updated?: string;
|
updated?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
|
subcategory?: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
polyline?: string;
|
polyline?: string;
|
||||||
domain?: string;
|
domain?: string;
|
||||||
@@ -38,6 +40,7 @@ class Trail {
|
|||||||
expand?: {
|
expand?: {
|
||||||
tags?: Tag[]
|
tags?: Tag[]
|
||||||
category?: Category;
|
category?: Category;
|
||||||
|
subcategory?: Subcategory;
|
||||||
waypoints_via_trail?: Waypoint[]
|
waypoints_via_trail?: Waypoint[]
|
||||||
summit_logs_via_trail?: SummitLog[]
|
summit_logs_via_trail?: SummitLog[]
|
||||||
author?: Actor
|
author?: Actor
|
||||||
@@ -70,6 +73,7 @@ class Trail {
|
|||||||
gpx?: string,
|
gpx?: string,
|
||||||
gpx_data?: string,
|
gpx_data?: string,
|
||||||
category?: Category,
|
category?: Category,
|
||||||
|
subcategory?: Subcategory,
|
||||||
waypoints?: Waypoint[],
|
waypoints?: Waypoint[],
|
||||||
summit_logs?: SummitLog[],
|
summit_logs?: SummitLog[],
|
||||||
comments?: Comment[],
|
comments?: Comment[],
|
||||||
@@ -98,10 +102,13 @@ class Trail {
|
|||||||
this.photos = params?.photos ?? [];
|
this.photos = params?.photos ?? [];
|
||||||
this.tags = [];
|
this.tags = [];
|
||||||
this.gpx = params?.gpx;
|
this.gpx = params?.gpx;
|
||||||
|
this.category = params?.category?.id;
|
||||||
|
this.subcategory = params?.subcategory?.id;
|
||||||
this.bounding_box_diagonal = params?.bounding_box_diagonal ?? 0;
|
this.bounding_box_diagonal = params?.bounding_box_diagonal ?? 0;
|
||||||
this.like_count = 0
|
this.like_count = 0
|
||||||
this.expand = {
|
this.expand = {
|
||||||
category: params?.category,
|
category: params?.category,
|
||||||
|
subcategory: params?.subcategory,
|
||||||
waypoints_via_trail: params?.waypoints ?? [],
|
waypoints_via_trail: params?.waypoints ?? [],
|
||||||
summit_logs_via_trail: params?.summit_logs ?? [],
|
summit_logs_via_trail: params?.summit_logs ?? [],
|
||||||
comments_via_trail: params?.comments ?? [],
|
comments_via_trail: params?.comments ?? [],
|
||||||
@@ -129,6 +136,7 @@ class Trail {
|
|||||||
public: orig.public,
|
public: orig.public,
|
||||||
tags: orig.expand?.tags,
|
tags: orig.expand?.tags,
|
||||||
category: orig.expand?.category,
|
category: orig.expand?.category,
|
||||||
|
subcategory: orig.expand?.subcategory,
|
||||||
gpx_data: orig.expand?.gpx_data,
|
gpx_data: orig.expand?.gpx_data,
|
||||||
waypoints: orig.expand?.waypoints_via_trail?.map(wp => new Waypoint(wp.lat, wp.lon, {
|
waypoints: orig.expand?.waypoints_via_trail?.map(wp => new Waypoint(wp.lat, wp.lon, {
|
||||||
id: cryptoRandomString({ length: 15 }),
|
id: cryptoRandomString({ length: 15 }),
|
||||||
@@ -143,6 +151,7 @@ class Trail {
|
|||||||
interface TrailFilter {
|
interface TrailFilter {
|
||||||
q: string,
|
q: string,
|
||||||
category: string[],
|
category: string[],
|
||||||
|
subcategory: string[],
|
||||||
tags: string[],
|
tags: string[],
|
||||||
difficulty: (0 | 1 | 2)[]
|
difficulty: (0 | 1 | 2)[]
|
||||||
author?: string;
|
author?: string;
|
||||||
@@ -187,6 +196,7 @@ interface TrailBoundingBox {
|
|||||||
min_lat: number,
|
min_lat: number,
|
||||||
max_lon: number,
|
max_lon: number,
|
||||||
min_lon: number,
|
min_lon: number,
|
||||||
|
has_trails?: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -204,6 +214,12 @@ interface TrailSearchResult {
|
|||||||
duration: number;
|
duration: number;
|
||||||
difficulty: 0 | 1 | 2;
|
difficulty: 0 | 1 | 2;
|
||||||
category: string;
|
category: string;
|
||||||
|
category_id?: string | null;
|
||||||
|
category_icon?: string;
|
||||||
|
subcategory_id?: string | null;
|
||||||
|
is_federated?: boolean;
|
||||||
|
federated_category_name?: string | null;
|
||||||
|
federated_subcategory_name?: string | null;
|
||||||
completed: boolean;
|
completed: boolean;
|
||||||
date: number;
|
date: number;
|
||||||
created: number;
|
created: number;
|
||||||
@@ -238,6 +254,12 @@ export const defaultTrailSearchAttributes = [
|
|||||||
"duration",
|
"duration",
|
||||||
"difficulty",
|
"difficulty",
|
||||||
"category",
|
"category",
|
||||||
|
"category_id",
|
||||||
|
"category_icon",
|
||||||
|
"subcategory_id",
|
||||||
|
"is_federated",
|
||||||
|
"federated_category_name",
|
||||||
|
"federated_subcategory_name",
|
||||||
"completed",
|
"completed",
|
||||||
"date",
|
"date",
|
||||||
"created",
|
"created",
|
||||||
|
|||||||
159
web/src/lib/server/category_preference_filter.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import type { UserCategoryPreference } from "$lib/models/category_preference";
|
||||||
|
import type { UserSubcategoryPreference } from "$lib/models/subcategory_preference";
|
||||||
|
import { Collection } from "$lib/util/api_util";
|
||||||
|
import type { RequestEvent } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
type MeiliFilter = string | string[] | undefined;
|
||||||
|
|
||||||
|
type TrailPreferenceCache = {
|
||||||
|
categories?: Promise<UserCategoryPreference[]>;
|
||||||
|
subcategories?: Promise<UserSubcategoryPreference[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const preferenceCache = new WeakMap<RequestEvent, TrailPreferenceCache>();
|
||||||
|
|
||||||
|
function quotedList(ids: string[]) {
|
||||||
|
return `[${ids.map((id) => `'${id}'`).join(", ")}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function meiliFilterParts(filter: unknown): string[] {
|
||||||
|
if (!filter) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (typeof filter === "string") {
|
||||||
|
return filter.trim() ? [filter] : [];
|
||||||
|
}
|
||||||
|
if (Array.isArray(filter)) {
|
||||||
|
return filter.flatMap((item) => meiliFilterParts(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIdOnlyDetailQuery(parts: string[]) {
|
||||||
|
return parts.length > 0 && parts.every((part) => /\bid\s+IN\b/i.test(part));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function userCategoryPreferences(event: RequestEvent) {
|
||||||
|
if (!event.locals.user) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = cachedTrailPreferences(event);
|
||||||
|
cache.categories ??= event.locals.pb
|
||||||
|
.collection(Collection.user_category_preferences)
|
||||||
|
.getFullList<UserCategoryPreference>({
|
||||||
|
filter: event.locals.pb.filter("user = {:user}", {
|
||||||
|
user: event.locals.user.id,
|
||||||
|
}),
|
||||||
|
requestKey: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return cache.categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function userSubcategoryPreferences(event: RequestEvent) {
|
||||||
|
if (!event.locals.user) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = cachedTrailPreferences(event);
|
||||||
|
cache.subcategories ??= event.locals.pb
|
||||||
|
.collection(Collection.user_subcategory_preferences)
|
||||||
|
.getFullList<UserSubcategoryPreference>({
|
||||||
|
filter: event.locals.pb.filter("user = {:user}", {
|
||||||
|
user: event.locals.user.id,
|
||||||
|
}),
|
||||||
|
requestKey: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return cache.subcategories;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cachedTrailPreferences(event: RequestEvent) {
|
||||||
|
let cache = preferenceCache.get(event);
|
||||||
|
if (!cache) {
|
||||||
|
cache = {};
|
||||||
|
preferenceCache.set(event, cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trailPreferenceExclusions(event: RequestEvent) {
|
||||||
|
const [preferences, subcategoryPreferences] = await Promise.all([
|
||||||
|
userCategoryPreferences(event),
|
||||||
|
userSubcategoryPreferences(event),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
hiddenCategoryIds: preferences
|
||||||
|
.filter((preference) => preference.visible === false)
|
||||||
|
.map((preference) => preference.category),
|
||||||
|
hiddenSubcategoryIds: subcategoryPreferences
|
||||||
|
.filter((preference) => preference.visible === false)
|
||||||
|
.map((preference) => preference.subcategory),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withTrailPreferenceMeiliFilter(
|
||||||
|
event: RequestEvent,
|
||||||
|
filter: MeiliFilter,
|
||||||
|
): Promise<MeiliFilter> {
|
||||||
|
const parts = meiliFilterParts(filter);
|
||||||
|
if (!event.locals.user || isIdOnlyDetailQuery(parts)) {
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { hiddenCategoryIds, hiddenSubcategoryIds } =
|
||||||
|
await trailPreferenceExclusions(event);
|
||||||
|
|
||||||
|
const preferenceParts: string[] = [];
|
||||||
|
if (hiddenCategoryIds.length) {
|
||||||
|
preferenceParts.push(
|
||||||
|
`(category_id IS NULL OR category_id NOT IN ${quotedList(hiddenCategoryIds)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (hiddenSubcategoryIds.length) {
|
||||||
|
preferenceParts.push(
|
||||||
|
`(subcategory_id IS NULL OR subcategory_id NOT IN ${quotedList(hiddenSubcategoryIds)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextParts = [...parts, ...preferenceParts];
|
||||||
|
if (!nextParts.length) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextParts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pbNotEqualsAll(field: string, ids: string[]) {
|
||||||
|
return ids.map((id) => `${field} != "${id}"`).join(" && ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withTrailPreferencePocketBaseFilter(
|
||||||
|
event: RequestEvent,
|
||||||
|
filter?: string,
|
||||||
|
) {
|
||||||
|
if (!event.locals.user) {
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { hiddenCategoryIds, hiddenSubcategoryIds } =
|
||||||
|
await trailPreferenceExclusions(event);
|
||||||
|
|
||||||
|
const parts = filter?.trim() ? [filter] : [];
|
||||||
|
if (hiddenCategoryIds.length) {
|
||||||
|
parts.push(
|
||||||
|
`(category = "" || (${pbNotEqualsAll("category", hiddenCategoryIds)}))`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (hiddenSubcategoryIds.length) {
|
||||||
|
parts.push(
|
||||||
|
`(subcategory = "" || (${pbNotEqualsAll("subcategory", hiddenSubcategoryIds)}))`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.length ? parts.join(" && ") : undefined;
|
||||||
|
}
|
||||||
58
web/src/lib/stores/category_preference_store.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import type { UserCategoryPreference } from "$lib/models/category_preference";
|
||||||
|
import { APIError } from "$lib/util/api_util";
|
||||||
|
import { writable, type Writable } from "svelte/store";
|
||||||
|
|
||||||
|
export const categoryPreferences: Writable<UserCategoryPreference[]> = writable(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
export async function category_preferences_index(
|
||||||
|
f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch,
|
||||||
|
) {
|
||||||
|
const r = await f("/api/v1/user-category-preference", {
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const response = await r.json();
|
||||||
|
throw new APIError(r.status, response.message, response.detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = (await r.json()) as UserCategoryPreference[];
|
||||||
|
categoryPreferences.set(response);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function category_preferences_save(
|
||||||
|
preference: Pick<UserCategoryPreference, "category" | "visible">,
|
||||||
|
) {
|
||||||
|
const r = await fetch("/api/v1/user-category-preference", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(preference),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const response = await r.json();
|
||||||
|
throw new APIError(r.status, response.message, response.detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = (await r.json()) as UserCategoryPreference;
|
||||||
|
categoryPreferences.update((preferences) => [
|
||||||
|
...preferences.filter((item) => item.id !== response.id),
|
||||||
|
response,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function category_preferences_reorder(categories: string[]) {
|
||||||
|
const r = await fetch("/api/v1/user-category-preference/reorder", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ categories }),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const response = await r.json();
|
||||||
|
throw new APIError(r.status, response.message, response.detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await r.json();
|
||||||
|
}
|
||||||
@@ -79,7 +79,7 @@ export async function lists_search_filter(filter: ListFilter, page: number = 1,
|
|||||||
export async function lists_show(id: string, handle?: string, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
export async function lists_show(id: string, handle?: string, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||||
|
|
||||||
const r = await f(`/api/v1/list/${id}?` + new URLSearchParams({
|
const r = await f(`/api/v1/list/${id}?` + new URLSearchParams({
|
||||||
expand: "author,trails,trails.author,trails.category,trails.tags,list_share_via_list.actor",
|
expand: "author,trails,trails.author,trails.category,trails.subcategory,trails.subcategory.category,trails.tags,list_share_via_list.actor",
|
||||||
...(handle ? { handle } : {})
|
...(handle ? { handle } : {})
|
||||||
}), {
|
}), {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export async function profile_stats_index(handle: string, filter: SummitLogFilte
|
|||||||
|
|
||||||
const r = await f(`/api/v1/profile/${handle}/stats?` + new URLSearchParams({
|
const r = await f(`/api/v1/profile/${handle}/stats?` + new URLSearchParams({
|
||||||
filter: filterText,
|
filter: filterText,
|
||||||
expand: "trail.category,author",
|
expand: "trail.category,trail.subcategory,trail.subcategory.category,author",
|
||||||
sort: "+date",
|
sort: "+date",
|
||||||
}), {
|
}), {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
|||||||
60
web/src/lib/stores/subcategory_preference_store.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import type { UserSubcategoryPreference } from "$lib/models/subcategory_preference";
|
||||||
|
import { APIError } from "$lib/util/api_util";
|
||||||
|
import { writable, type Writable } from "svelte/store";
|
||||||
|
|
||||||
|
export const subcategoryPreferences: Writable<UserSubcategoryPreference[]> =
|
||||||
|
writable([]);
|
||||||
|
|
||||||
|
export async function subcategory_preferences_index(
|
||||||
|
f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch,
|
||||||
|
) {
|
||||||
|
const r = await f("/api/v1/user-subcategory-preference", {
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const response = await r.json();
|
||||||
|
throw new APIError(r.status, response.message, response.detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = (await r.json()) as UserSubcategoryPreference[];
|
||||||
|
subcategoryPreferences.set(response);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function subcategory_preferences_save(
|
||||||
|
preference: Pick<UserSubcategoryPreference, "subcategory" | "visible">,
|
||||||
|
) {
|
||||||
|
const r = await fetch("/api/v1/user-subcategory-preference", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(preference),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const response = await r.json();
|
||||||
|
throw new APIError(r.status, response.message, response.detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = (await r.json()) as UserSubcategoryPreference;
|
||||||
|
subcategoryPreferences.update((preferences) => [
|
||||||
|
...preferences.filter((item) => item.id !== response.id),
|
||||||
|
response,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function subcategory_preferences_reorder(
|
||||||
|
category: string,
|
||||||
|
subcategories: string[],
|
||||||
|
) {
|
||||||
|
const r = await fetch("/api/v1/user-subcategory-preference/reorder", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ category, subcategories }),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const response = await r.json();
|
||||||
|
throw new APIError(r.status, response.message, response.detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await r.json();
|
||||||
|
}
|
||||||
31
web/src/lib/stores/subcategory_store.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { Subcategory } from "$lib/models/subcategory";
|
||||||
|
import { APIError } from "$lib/util/api_util";
|
||||||
|
import { type ListResult } from "pocketbase";
|
||||||
|
import { writable, type Writable } from "svelte/store";
|
||||||
|
|
||||||
|
export const subcategories: Writable<Subcategory[]> = writable([]);
|
||||||
|
|
||||||
|
export async function subcategories_index(
|
||||||
|
f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch,
|
||||||
|
) {
|
||||||
|
const r = await f(
|
||||||
|
"/api/v1/subcategory?" +
|
||||||
|
new URLSearchParams({
|
||||||
|
perPage: "-1",
|
||||||
|
expand: "category",
|
||||||
|
sort: "category,name",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!r.ok) {
|
||||||
|
const response = await r.json();
|
||||||
|
throw new APIError(r.status, response.message, response.detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: ListResult<Subcategory> = await r.json();
|
||||||
|
subcategories.set(response.items);
|
||||||
|
|
||||||
|
return response.items as Subcategory[];
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ export async function summit_logs_index(filter?: SummitLogFilter, handle?: strin
|
|||||||
const r = await f('/api/v1/summit-log?' + new URLSearchParams({
|
const r = await f('/api/v1/summit-log?' + new URLSearchParams({
|
||||||
...(filter ? { filter: buildFilterText(filter) } : {}),
|
...(filter ? { filter: buildFilterText(filter) } : {}),
|
||||||
perPage: "-1",
|
perPage: "-1",
|
||||||
expand: "trail.category,author",
|
expand: "trail.category,trail.subcategory,trail.subcategory.category,author",
|
||||||
sort: "+date",
|
sort: "+date",
|
||||||
...(handle ? { handle } : {})
|
...(handle ? { handle } : {})
|
||||||
}), {
|
}), {
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import type { SummitLog } from "$lib/models/summit_log";
|
import type { SummitLog } from "$lib/models/summit_log";
|
||||||
import type { Tag } from "$lib/models/tag";
|
import type { Tag } from "$lib/models/tag";
|
||||||
import { MAP_MAX_POLYLINES } from "$lib/config/map";
|
import { MAP_MAX_POLYLINES } from "$lib/config/map";
|
||||||
import { defaultTrailSearchAttributes, Trail, type TrailFilter, type TrailFilterValues, type TrailSearchResult } from "$lib/models/trail";
|
import { defaultTrailSearchAttributes, Trail, type TrailBoundingBox, type TrailFilter, type TrailFilterValues, type TrailSearchResult } from "$lib/models/trail";
|
||||||
import type { Waypoint } from "$lib/models/waypoint";
|
import type { Waypoint } from "$lib/models/waypoint";
|
||||||
import { APIError } from "$lib/util/api_util";
|
import { APIError } from "$lib/util/api_util";
|
||||||
import { deepEqual } from "$lib/util/deep_util";
|
import { deepEqual } from "$lib/util/deep_util";
|
||||||
import { getFileURL, objectToFormData } from "$lib/util/file_util";
|
import { getFileURL, objectToFormData } from "$lib/util/file_util";
|
||||||
|
import { noSubcategoryFilterCategory } from "$lib/util/trail_filter_util";
|
||||||
import * as M from "maplibre-gl";
|
import * as M from "maplibre-gl";
|
||||||
import type { Hits } from "meilisearch";
|
import type { Hits } from "meilisearch";
|
||||||
import { type AuthRecord, type ListResult, type RecordModel } from "pocketbase";
|
import { type AuthRecord, type ListResult, type RecordModel } from "pocketbase";
|
||||||
import { get, writable, type Writable } from "svelte/store";
|
import { get, writable, type Writable } from "svelte/store";
|
||||||
import { summit_logs_create, summit_logs_delete, summit_logs_update } from "./summit_log_store";
|
import { summit_logs_create, summit_logs_delete, summit_logs_update } from "./summit_log_store";
|
||||||
|
import { categories } from "./category_store";
|
||||||
|
import { subcategories } from "./subcategory_store";
|
||||||
import { tags_create } from "./tag_store";
|
import { tags_create } from "./tag_store";
|
||||||
import { currentUser } from "./user_store";
|
import { currentUser } from "./user_store";
|
||||||
import { waypoints_create, waypoints_delete, waypoints_update } from "./waypoint_store";
|
import { waypoints_create, waypoints_delete, waypoints_update } from "./waypoint_store";
|
||||||
@@ -18,7 +21,7 @@ import { waypoints_create, waypoints_delete, waypoints_update } from "./waypoint
|
|||||||
export async function trails_index(perPage: number = 21, random: boolean = false, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
export async function trails_index(perPage: number = 21, random: boolean = false, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||||
const r = await f('/api/v1/trail?' + new URLSearchParams({
|
const r = await f('/api/v1/trail?' + new URLSearchParams({
|
||||||
"perPage": perPage.toString(),
|
"perPage": perPage.toString(),
|
||||||
expand: "category,waypoints_via_trail,summit_logs_via_trail,tags",
|
expand: "category,subcategory,subcategory.category,waypoints_via_trail,summit_logs_via_trail,tags",
|
||||||
sort: random ? "@random" : "",
|
sort: random ? "@random" : "",
|
||||||
}), {
|
}), {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -322,7 +325,7 @@ export async function trails_search_bounding_box(
|
|||||||
export async function trails_show(id: string, handle?: string, share?: string, loadGPX?: boolean, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
export async function trails_show(id: string, handle?: string, share?: string, loadGPX?: boolean, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||||
|
|
||||||
const r = await f(`/api/v1/trail/${id}?` + new URLSearchParams({
|
const r = await f(`/api/v1/trail/${id}?` + new URLSearchParams({
|
||||||
expand: "category,waypoints_via_trail,summit_logs_via_trail,summit_logs_via_trail.author,trail_share_via_trail.actor,trail_like_via_trail,tags,author",
|
expand: "category,subcategory,subcategory.category,waypoints_via_trail,summit_logs_via_trail,summit_logs_via_trail.author,trail_share_via_trail.actor,trail_like_via_trail,tags,author",
|
||||||
...(handle ? { handle } : {}),
|
...(handle ? { handle } : {}),
|
||||||
...(share ? { share } : {})
|
...(share ? { share } : {})
|
||||||
}), {
|
}), {
|
||||||
@@ -393,7 +396,7 @@ export async function trails_create(trail: Trail, photos: File[], gpx: File | Bl
|
|||||||
}
|
}
|
||||||
|
|
||||||
let r = await f(`/api/v1/trail/form?` + new URLSearchParams({
|
let r = await f(`/api/v1/trail/form?` + new URLSearchParams({
|
||||||
expand: "category,waypoints_via_trail,summit_logs_via_trail,trail_share_via_trail,tags",
|
expand: "category,subcategory,subcategory.category,waypoints_via_trail,summit_logs_via_trail,trail_share_via_trail,tags",
|
||||||
}), {
|
}), {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: formData,
|
body: formData,
|
||||||
@@ -520,7 +523,7 @@ export async function trails_update(oldTrail: Trail, newTrail: Trail, photos?: F
|
|||||||
|
|
||||||
|
|
||||||
const updateUrl = `/api/v1/trail/form/${newTrail.id}?` + new URLSearchParams({
|
const updateUrl = `/api/v1/trail/form/${newTrail.id}?` + new URLSearchParams({
|
||||||
expand: "category,waypoints_via_trail,summit_logs_via_trail,trail_share_via_trail,tags",
|
expand: "category,subcategory,subcategory.category,waypoints_via_trail,summit_logs_via_trail,trail_share_via_trail,tags",
|
||||||
});
|
});
|
||||||
|
|
||||||
let r = await fetch(updateUrl, {
|
let r = await fetch(updateUrl, {
|
||||||
@@ -625,7 +628,7 @@ export async function trails_get_filter_values(f: (url: RequestInfo | URL, confi
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function trails_get_bounding_box(f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch): Promise<TrailFilterValues> {
|
export async function trails_get_bounding_box(f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch): Promise<TrailBoundingBox> {
|
||||||
const r = await f('/api/v1/trail/bounding-box', {
|
const r = await f('/api/v1/trail/bounding-box', {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
})
|
})
|
||||||
@@ -687,9 +690,18 @@ export async function fetchGPX(trail: { gpx?: string } & Record<string, any>, f:
|
|||||||
|
|
||||||
export async function searchResultToTrailList(hits: Hits<TrailSearchResult>): Promise<Trail[]> {
|
export async function searchResultToTrailList(hits: Hits<TrailSearchResult>): Promise<Trail[]> {
|
||||||
const trails: Trail[] = []
|
const trails: Trail[] = []
|
||||||
|
const categoryById = new Map(get(categories).map((category) => [category.id, category]));
|
||||||
|
const subcategoryById = new Map(
|
||||||
|
get(subcategories).map((subcategory) => [subcategory.id, subcategory]),
|
||||||
|
);
|
||||||
|
|
||||||
for (const h of hits) {
|
for (const h of hits) {
|
||||||
const created = Number(h.created || 0);
|
const created = Number(h.created || 0);
|
||||||
const date = Number(h.date || 0);
|
const date = Number(h.date || 0);
|
||||||
|
const category = h.category_id ? categoryById.get(h.category_id) : undefined;
|
||||||
|
const subcategory = h.subcategory_id
|
||||||
|
? subcategoryById.get(h.subcategory_id)
|
||||||
|
: undefined;
|
||||||
const t: Trail & RecordModel = {
|
const t: Trail & RecordModel = {
|
||||||
collectionId: "trails",
|
collectionId: "trails",
|
||||||
collectionName: "trails",
|
collectionName: "trails",
|
||||||
@@ -702,7 +714,8 @@ export async function searchResultToTrailList(hits: Hits<TrailSearchResult>): Pr
|
|||||||
summit_logs: [],
|
summit_logs: [],
|
||||||
waypoints: [],
|
waypoints: [],
|
||||||
tags: h.tags ?? [],
|
tags: h.tags ?? [],
|
||||||
category: h.category,
|
category: h.category_id ?? "",
|
||||||
|
subcategory: h.subcategory_id ?? "",
|
||||||
created: new Date(created * 1000).toISOString(),
|
created: new Date(created * 1000).toISOString(),
|
||||||
date: new Date(date * 1000).toISOString(),
|
date: new Date(date * 1000).toISOString(),
|
||||||
description: h.description,
|
description: h.description,
|
||||||
@@ -723,6 +736,14 @@ export async function searchResultToTrailList(hits: Hits<TrailSearchResult>): Pr
|
|||||||
thumbnail: 0,
|
thumbnail: 0,
|
||||||
like_count: h.like_count,
|
like_count: h.like_count,
|
||||||
expand: {
|
expand: {
|
||||||
|
category: category ?? (h.category
|
||||||
|
? {
|
||||||
|
id: h.category_id ?? "",
|
||||||
|
name: h.category,
|
||||||
|
icon: h.category_icon,
|
||||||
|
}
|
||||||
|
: undefined),
|
||||||
|
subcategory,
|
||||||
author: {
|
author: {
|
||||||
collectionId: "activitypub_actors",
|
collectionId: "activitypub_actors",
|
||||||
is_local: (h.domain?.length ?? 0) == 0,
|
is_local: (h.domain?.length ?? 0) == 0,
|
||||||
@@ -838,9 +859,52 @@ function buildFilterText(user: AuthRecord, filter: TrailFilter, includeGeo: bool
|
|||||||
filterText += ` AND date <= ${new Date(filter.endDate).getTime() / 1000}`
|
filterText += ` AND date <= ${new Date(filter.endDate).getTime() / 1000}`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.category.length > 0) {
|
const selectedSubcategoryIds = filter.subcategory ?? [];
|
||||||
const categoryValues = filter.category.map(category => `'${category}'`).join(", ");
|
if (filter.category.length > 0 || selectedSubcategoryIds.length > 0) {
|
||||||
filterText += ` AND category IN [${categoryValues}]`;
|
const selectedNoSubcategoryCategoryIds = selectedSubcategoryIds
|
||||||
|
.map(noSubcategoryFilterCategory)
|
||||||
|
.filter((category): category is string => category !== undefined);
|
||||||
|
const selectedRealSubcategoryIds = selectedSubcategoryIds.filter(
|
||||||
|
(id) => noSubcategoryFilterCategory(id) === undefined,
|
||||||
|
);
|
||||||
|
const selectedSubcategoryParentIds = new Set(
|
||||||
|
get(subcategories)
|
||||||
|
.filter((subcategory) => selectedRealSubcategoryIds.includes(subcategory.id))
|
||||||
|
.map((subcategory) => subcategory.category),
|
||||||
|
);
|
||||||
|
for (const categoryId of selectedNoSubcategoryCategoryIds) {
|
||||||
|
selectedSubcategoryParentIds.add(categoryId);
|
||||||
|
}
|
||||||
|
const categoriesWithoutSubcategoryFilter = filter.category.filter(
|
||||||
|
(category) => !selectedSubcategoryParentIds.has(category),
|
||||||
|
);
|
||||||
|
const categoryParts: string[] = [];
|
||||||
|
|
||||||
|
if (categoriesWithoutSubcategoryFilter.length > 0) {
|
||||||
|
const categoryValues = categoriesWithoutSubcategoryFilter
|
||||||
|
.map(category => `'${category}'`)
|
||||||
|
.join(", ");
|
||||||
|
categoryParts.push(`category_id IN [${categoryValues}]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedNoSubcategoryCategoryIds.length > 0) {
|
||||||
|
const noSubcategoryParts = selectedNoSubcategoryCategoryIds.map(
|
||||||
|
(category) =>
|
||||||
|
`(category_id = '${category}' AND subcategory_id IS NULL)`,
|
||||||
|
);
|
||||||
|
categoryParts.push(...noSubcategoryParts);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedRealSubcategoryIds.length > 0) {
|
||||||
|
const subcategoryValues = selectedRealSubcategoryIds
|
||||||
|
.map(subcategory => `'${subcategory}'`)
|
||||||
|
.join(", ");
|
||||||
|
categoryParts.push(`subcategory_id IN [${subcategoryValues}]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categoryParts.length > 0) {
|
||||||
|
filterText += ` AND (${categoryParts.join(" OR ")})`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.tags.length > 0) {
|
if (filter.tags.length > 0) {
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ export enum Collection {
|
|||||||
notifications = "notifications",
|
notifications = "notifications",
|
||||||
profile_feed = "profile_feed",
|
profile_feed = "profile_feed",
|
||||||
settings = "settings",
|
settings = "settings",
|
||||||
|
subcategories = "subcategories",
|
||||||
|
user_category_preferences = "user_category_preferences",
|
||||||
|
user_subcategory_preferences = "user_subcategory_preferences",
|
||||||
summit_logs = "summit_logs",
|
summit_logs = "summit_logs",
|
||||||
trail_like = "trail_like",
|
trail_like = "trail_like",
|
||||||
trail_share = "trail_share",
|
trail_share = "trail_share",
|
||||||
@@ -162,7 +165,14 @@ export function handleError(e: any) {
|
|||||||
return json({ ...e.response, message: e.message, detail: e.originalError.data }, { status: e.status })
|
return json({ ...e.response, message: e.message, detail: e.originalError.data }, { status: e.status })
|
||||||
} else if (e instanceof SyntaxError) {
|
} else if (e instanceof SyntaxError) {
|
||||||
return json({ message: "invalid_json" }, { status: 400 })
|
return json({ message: "invalid_json" }, { status: 400 })
|
||||||
|
} else if (e instanceof Error) {
|
||||||
|
return json({ message: e.message }, { status: 500 })
|
||||||
} else {
|
} else {
|
||||||
return json({ message: e }, { status: 500 })
|
const message = typeof e?.message === "string"
|
||||||
|
? e.message
|
||||||
|
: typeof e === "string"
|
||||||
|
? e
|
||||||
|
: "internal_server_error";
|
||||||
|
return json({ message, detail: e }, { status: 500 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
411
web/src/lib/util/category_util.ts
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
import type { Category } from "$lib/models/category";
|
||||||
|
import type { UserCategoryPreference } from "$lib/models/category_preference";
|
||||||
|
import type { Subcategory } from "$lib/models/subcategory";
|
||||||
|
import type { UserSubcategoryPreference } from "$lib/models/subcategory_preference";
|
||||||
|
|
||||||
|
type CategoryDisplayEntity = Pick<Category | Subcategory, "name" | "translations">;
|
||||||
|
type CategoryShortNameEntity = Pick<
|
||||||
|
Category | Subcategory,
|
||||||
|
"name" | "short_name" | "translations"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type CategoryMappingTarget =
|
||||||
|
| string
|
||||||
|
| {
|
||||||
|
category?: string;
|
||||||
|
subcategory?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResolvedCategoryMappingTarget = {
|
||||||
|
categoryId?: string;
|
||||||
|
subcategoryId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeCategoryName(name: string): string {
|
||||||
|
// Best-effort mirror of the backend normalization for resolving ?category= links.
|
||||||
|
// Full Unicode casefold parity would require a dedicated frontend casefold implementation.
|
||||||
|
return name
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/\p{Mn}/gu, "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[\s_-]+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function categoryMappingTargetFromUnknown(
|
||||||
|
value: unknown,
|
||||||
|
): CategoryMappingTarget | undefined {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = value as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
category: typeof raw.category === "string" ? raw.category : "",
|
||||||
|
subcategory: typeof raw.subcategory === "string" ? raw.subcategory : "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCategoryTargetValue(
|
||||||
|
value: string,
|
||||||
|
categories: Category[],
|
||||||
|
subcategories: Subcategory[],
|
||||||
|
): ResolvedCategoryMappingTarget {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const directCategory = categories.find((category) => category.id === trimmed);
|
||||||
|
if (directCategory) {
|
||||||
|
return { categoryId: directCategory.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
const directSubcategory = subcategories.find(
|
||||||
|
(subcategory) => subcategory.id === trimmed,
|
||||||
|
);
|
||||||
|
if (directSubcategory) {
|
||||||
|
return {
|
||||||
|
categoryId: directSubcategory.category,
|
||||||
|
subcategoryId: directSubcategory.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.includes("/")) {
|
||||||
|
const [rawCategory, rawSubcategory] = trimmed.split("/", 2);
|
||||||
|
const category = categories.find(
|
||||||
|
(candidate) =>
|
||||||
|
normalizeCategoryName(candidate.name) ===
|
||||||
|
normalizeCategoryName(rawCategory),
|
||||||
|
);
|
||||||
|
const subcategory = subcategories.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.category === category?.id &&
|
||||||
|
normalizeCategoryName(candidate.name) ===
|
||||||
|
normalizeCategoryName(rawSubcategory),
|
||||||
|
);
|
||||||
|
if (subcategory) {
|
||||||
|
return {
|
||||||
|
categoryId: subcategory.category,
|
||||||
|
subcategoryId: subcategory.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const namedCategory = categories.find(
|
||||||
|
(category) =>
|
||||||
|
normalizeCategoryName(category.name) === normalizeCategoryName(trimmed),
|
||||||
|
);
|
||||||
|
return namedCategory ? { categoryId: namedCategory.id } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCategoryMappingTarget(
|
||||||
|
target: CategoryMappingTarget,
|
||||||
|
categories: Category[],
|
||||||
|
subcategories: Subcategory[],
|
||||||
|
): ResolvedCategoryMappingTarget {
|
||||||
|
if (typeof target === "string") {
|
||||||
|
return resolveCategoryTargetValue(target, categories, subcategories);
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryValue = resolveCategoryTargetValue(
|
||||||
|
target.category ?? "",
|
||||||
|
categories,
|
||||||
|
subcategories,
|
||||||
|
);
|
||||||
|
const subcategoryValue = resolveCategoryTargetValue(
|
||||||
|
target.subcategory ?? "",
|
||||||
|
categories,
|
||||||
|
subcategories,
|
||||||
|
);
|
||||||
|
if (subcategoryValue.subcategoryId) {
|
||||||
|
return subcategoryValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.subcategory?.trim() && categoryValue.categoryId) {
|
||||||
|
const subcategory = subcategories.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.category === categoryValue.categoryId &&
|
||||||
|
normalizeCategoryName(candidate.name) ===
|
||||||
|
normalizeCategoryName(target.subcategory ?? ""),
|
||||||
|
);
|
||||||
|
if (subcategory) {
|
||||||
|
return {
|
||||||
|
categoryId: subcategory.category,
|
||||||
|
subcategoryId: subcategory.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return categoryValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function categoryMappingTargetToPickerValue(
|
||||||
|
target: CategoryMappingTarget,
|
||||||
|
categories: Category[],
|
||||||
|
subcategories: Subcategory[],
|
||||||
|
): string {
|
||||||
|
const resolved = resolveCategoryMappingTarget(target, categories, subcategories);
|
||||||
|
if (resolved.subcategoryId) {
|
||||||
|
return `subcategory:${resolved.subcategoryId}`;
|
||||||
|
}
|
||||||
|
if (resolved.categoryId) {
|
||||||
|
return `category:${resolved.categoryId}`;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function localeCandidates(locale?: string | null): string[] {
|
||||||
|
if (!locale) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = locale.trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const lower = normalized.toLowerCase();
|
||||||
|
const base = lower.split("-")[0];
|
||||||
|
|
||||||
|
return [...new Set([normalized, lower, base])];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function displayCategoryName(
|
||||||
|
category?: CategoryDisplayEntity | null,
|
||||||
|
locale?: string | null,
|
||||||
|
): string {
|
||||||
|
if (!category) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of localeCandidates(locale)) {
|
||||||
|
const translatedName = category.translations?.[candidate]?.name;
|
||||||
|
if (translatedName) {
|
||||||
|
return translatedName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to the English translation before the raw canonical name.
|
||||||
|
return category.translations?.["en"]?.name || category.name || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function displayCategoryShortName(
|
||||||
|
category?: CategoryShortNameEntity | null,
|
||||||
|
locale?: string | null,
|
||||||
|
): string {
|
||||||
|
if (!category) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of localeCandidates(locale)) {
|
||||||
|
const translatedShortName = category.translations?.[candidate]?.short_name;
|
||||||
|
if (translatedShortName?.trim()) {
|
||||||
|
return translatedShortName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return category.short_name?.trim() || displayCategoryName(category, locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function displayCategoryIcon(
|
||||||
|
category?: Pick<Category | Subcategory, "icon"> | null,
|
||||||
|
): string {
|
||||||
|
const icon = category?.icon?.trim().replace(/^fa-/, "");
|
||||||
|
return icon ? `fa-${icon}` : "fa-shapes";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function displaySubcategoryIcon(
|
||||||
|
subcategory?: Pick<Subcategory, "icon"> | null,
|
||||||
|
parentCategory?: Pick<Category, "icon"> | null,
|
||||||
|
): string {
|
||||||
|
return displayCategoryIcon(subcategory?.icon ? subcategory : parentCategory);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function displaySubcategoryBadgeIcon(
|
||||||
|
subcategory?: Pick<Subcategory, "badge_icon"> | null,
|
||||||
|
): string {
|
||||||
|
const icon = subcategory?.badge_icon?.trim().replace(/^fa-/, "");
|
||||||
|
return icon ? `fa-${icon}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrailCategoryIconEntity = {
|
||||||
|
expand?: {
|
||||||
|
category?: Pick<Category, "icon"> | null;
|
||||||
|
subcategory?: Pick<Subcategory, "icon" | "badge_icon"> | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function displayTrailCategoryIcon(
|
||||||
|
trail?: TrailCategoryIconEntity | null,
|
||||||
|
): string {
|
||||||
|
const subcategory = trail?.expand?.subcategory;
|
||||||
|
if (subcategory) {
|
||||||
|
return displaySubcategoryIcon(subcategory, trail?.expand?.category);
|
||||||
|
}
|
||||||
|
return displayCategoryIcon(trail?.expand?.category);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function displayTrailCategoryBadgeIcon(
|
||||||
|
trail?: TrailCategoryIconEntity | null,
|
||||||
|
): string {
|
||||||
|
return displaySubcategoryBadgeIcon(trail?.expand?.subcategory);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function displaySubcategoryName(
|
||||||
|
subcategory?: Subcategory | null,
|
||||||
|
locale?: string | null,
|
||||||
|
): string {
|
||||||
|
return displayCategoryName(subcategory, locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function displaySubcategoryLabel(
|
||||||
|
subcategory?: Subcategory | null,
|
||||||
|
locale?: string | null,
|
||||||
|
): string {
|
||||||
|
return displaySubcategoryName(subcategory, locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function displaySubcategoryShortBadge(
|
||||||
|
subcategory?: Subcategory | null,
|
||||||
|
locale?: string | null,
|
||||||
|
): string {
|
||||||
|
if (!subcategory) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortName = displayCategoryShortName(subcategory, locale);
|
||||||
|
const label = displaySubcategoryLabel(subcategory, locale);
|
||||||
|
|
||||||
|
if (shortName && shortName !== label) {
|
||||||
|
return shortName.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcategory.short_name?.trim()) {
|
||||||
|
return subcategory.short_name.trim().toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedLabel = label.trim();
|
||||||
|
if (normalizedLabel.length <= 5) {
|
||||||
|
return normalizedLabel.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const words = normalizedLabel.match(/[\p{L}\p{N}]+/gu) ?? [];
|
||||||
|
if (words.length > 1) {
|
||||||
|
return words
|
||||||
|
.map((word) => word.at(0))
|
||||||
|
.join("")
|
||||||
|
.slice(0, 5)
|
||||||
|
.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedLabel.slice(0, 4).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function preferenceForCategory(
|
||||||
|
preferences: UserCategoryPreference[],
|
||||||
|
categoryId?: string | null,
|
||||||
|
): UserCategoryPreference | undefined {
|
||||||
|
return preferences.find((preference) => preference.category === categoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function preferenceForSubcategory(
|
||||||
|
preferences: UserSubcategoryPreference[],
|
||||||
|
subcategoryId?: string | null,
|
||||||
|
): UserSubcategoryPreference | undefined {
|
||||||
|
return preferences.find(
|
||||||
|
(preference) => preference.subcategory === subcategoryId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subcategoryVisible(
|
||||||
|
subcategoryId: string | undefined | null,
|
||||||
|
preferences: UserSubcategoryPreference[],
|
||||||
|
): boolean {
|
||||||
|
return preferenceForSubcategory(preferences, subcategoryId)?.visible !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortedCategoriesByPreference(
|
||||||
|
categories: Category[],
|
||||||
|
preferences: UserCategoryPreference[],
|
||||||
|
locale?: string | null,
|
||||||
|
): Category[] {
|
||||||
|
return [...categories].sort((a, b) => {
|
||||||
|
const aPriority = preferenceForCategory(preferences, a.id)?.priority;
|
||||||
|
const bPriority = preferenceForCategory(preferences, b.id)?.priority;
|
||||||
|
const aPrioritized = typeof aPriority === "number" && aPriority > 0;
|
||||||
|
const bPrioritized = typeof bPriority === "number" && bPriority > 0;
|
||||||
|
|
||||||
|
if (aPrioritized && bPrioritized) {
|
||||||
|
return aPriority - bPriority;
|
||||||
|
}
|
||||||
|
if (aPrioritized) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (bPrioritized) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return displayCategoryName(a, locale).localeCompare(
|
||||||
|
displayCategoryName(b, locale),
|
||||||
|
locale ?? undefined,
|
||||||
|
{ sensitivity: "base" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortedSubcategoriesByPreference(
|
||||||
|
subcategories: Subcategory[],
|
||||||
|
preferences: UserSubcategoryPreference[],
|
||||||
|
locale?: string | null,
|
||||||
|
): Subcategory[] {
|
||||||
|
return [...subcategories].sort((a, b) => {
|
||||||
|
const aPriority = preferenceForSubcategory(preferences, a.id)?.priority;
|
||||||
|
const bPriority = preferenceForSubcategory(preferences, b.id)?.priority;
|
||||||
|
const aPrioritized = typeof aPriority === "number" && aPriority > 0;
|
||||||
|
const bPrioritized = typeof bPriority === "number" && bPriority > 0;
|
||||||
|
|
||||||
|
if (aPrioritized && bPrioritized && aPriority !== bPriority) {
|
||||||
|
return aPriority - bPriority;
|
||||||
|
}
|
||||||
|
if (aPrioritized && !bPrioritized) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (bPrioritized && !aPrioritized) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return displaySubcategoryLabel(a, locale).localeCompare(
|
||||||
|
displaySubcategoryLabel(b, locale),
|
||||||
|
locale ?? undefined,
|
||||||
|
{ sensitivity: "base" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function categoryVisibleInDesign(
|
||||||
|
category: Category,
|
||||||
|
preferences: UserCategoryPreference[],
|
||||||
|
currentCategoryId?: string | null,
|
||||||
|
): boolean {
|
||||||
|
if (category.id === currentCategoryId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return preferenceForCategory(preferences, category.id)?.visible !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function designSelectableCategories(
|
||||||
|
categories: Category[],
|
||||||
|
preferences: UserCategoryPreference[],
|
||||||
|
locale?: string | null,
|
||||||
|
currentCategoryId?: string | null,
|
||||||
|
): Category[] {
|
||||||
|
return sortedCategoriesByPreference(categories, preferences, locale).filter(
|
||||||
|
(category) =>
|
||||||
|
categoryVisibleInDesign(category, preferences, currentCategoryId),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,17 @@
|
|||||||
import type { TrailFilter } from "$lib/models/trail";
|
import type { TrailFilter } from "$lib/models/trail";
|
||||||
|
|
||||||
|
const NO_SUBCATEGORY_FILTER_PREFIX = "__no_subcategory__:";
|
||||||
|
|
||||||
|
export function noSubcategoryFilterValue(categoryId: string): string {
|
||||||
|
return `${NO_SUBCATEGORY_FILTER_PREFIX}${categoryId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function noSubcategoryFilterCategory(value: string): string | undefined {
|
||||||
|
return value.startsWith(NO_SUBCATEGORY_FILTER_PREFIX)
|
||||||
|
? value.substring(NO_SUBCATEGORY_FILTER_PREFIX.length)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const TRAIL_SORT_OPTIONS = new Set([
|
const TRAIL_SORT_OPTIONS = new Set([
|
||||||
"name",
|
"name",
|
||||||
"distance",
|
"distance",
|
||||||
@@ -95,6 +107,7 @@ export function sanitizeTrailFilter(
|
|||||||
...defaultFilter,
|
...defaultFilter,
|
||||||
q: getString(source.q, defaultFilter.q),
|
q: getString(source.q, defaultFilter.q),
|
||||||
category: getStringArray(source.category, defaultFilter.category),
|
category: getStringArray(source.category, defaultFilter.category),
|
||||||
|
subcategory: getStringArray(source.subcategory, defaultFilter.subcategory),
|
||||||
tags: getStringArray(source.tags, defaultFilter.tags),
|
tags: getStringArray(source.tags, defaultFilter.tags),
|
||||||
difficulty: parseDifficulty(source.difficulty, defaultFilter.difficulty),
|
difficulty: parseDifficulty(source.difficulty, defaultFilter.difficulty),
|
||||||
author: getString(source.author, defaultFilter.author),
|
author: getString(source.author, defaultFilter.author),
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import type { Trail } from "$lib/models/trail";
|
import type { Trail } from "$lib/models/trail";
|
||||||
import { categories_index } from "$lib/stores/category_store";
|
import { categories_index } from "$lib/stores/category_store";
|
||||||
import { feed_index } from "$lib/stores/feed_store";
|
import { feed_index } from "$lib/stores/feed_store";
|
||||||
|
import { subcategories_index } from "$lib/stores/subcategory_store";
|
||||||
import { trails_recommend } from "$lib/stores/trail_store";
|
import { trails_recommend } from "$lib/stores/trail_store";
|
||||||
import type { Load } from "@sveltejs/kit";
|
import type { Load } from "@sveltejs/kit";
|
||||||
|
|
||||||
export const load: Load = async ({ fetch }) => {
|
export const load: Load = async ({ fetch }) => {
|
||||||
try {
|
try {
|
||||||
await categories_index(fetch)
|
await categories_index(fetch)
|
||||||
|
await subcategories_index(fetch)
|
||||||
|
|
||||||
const feed = await feed_index(1, 10, fetch);
|
const feed = await feed_index(1, 10, fetch);
|
||||||
|
|
||||||
|
|||||||