From 04844a64dd22b16e7d096ac2ec3d2b5664bd4395 Mon Sep 17 00:00:00 2001 From: slothful-vassal <89943360+slothful-vassal@users.noreply.github.com> Date: Tue, 26 May 2026 09:24:45 +0200 Subject: [PATCH 01/26] fix waypoint creation from photo (#1006) --- .../routes/api/v1/waypoint/cluster/+server.ts | 33 ++++++++ web/src/routes/trail/edit/[id]/+page.svelte | 80 +++++++++++-------- 2 files changed, 81 insertions(+), 32 deletions(-) create mode 100644 web/src/routes/api/v1/waypoint/cluster/+server.ts diff --git a/web/src/routes/api/v1/waypoint/cluster/+server.ts b/web/src/routes/api/v1/waypoint/cluster/+server.ts new file mode 100644 index 00000000..91288f88 --- /dev/null +++ b/web/src/routes/api/v1/waypoint/cluster/+server.ts @@ -0,0 +1,33 @@ +import { handleError } from "$lib/util/api_util"; +import { json, type RequestEvent } from "@sveltejs/kit"; +import { z } from "zod"; + +const WaypointClusterPointSchema = z.object({ + id: z.string().min(1), + lat: z.number().min(-90).max(90), + lon: z.number().min(-180).max(180), +}); + +const WaypointClusterSchema = z.object({ + category: z.string().length(15).or(z.literal("")).optional(), + photos: z.array(WaypointClusterPointSchema), + waypoints: z.array(WaypointClusterPointSchema), +}); + +export async function POST(event: RequestEvent) { + try { + const data = WaypointClusterSchema.parse(await event.request.json()); + const response = await event.locals.pb.send("/waypoint/cluster", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify(data), + fetch: event.fetch, + }); + + return json(response); + } catch (e) { + return handleError(e); + } +} diff --git a/web/src/routes/trail/edit/[id]/+page.svelte b/web/src/routes/trail/edit/[id]/+page.svelte index 8e36a65c..c2decf89 100644 --- a/web/src/routes/trail/edit/[id]/+page.svelte +++ b/web/src/routes/trail/edit/[id]/+page.svelte @@ -77,7 +77,6 @@ import RouteEditor from "$lib/components/trail/route_editor.svelte"; import { TagCreateSchema } from "$lib/models/api/tag_schema.js"; import { convertDMSToDD } from "$lib/models/gpx/utils.js"; - import { getPb } from "$lib/pocketbase"; import { Tag } from "$lib/models/tag.js"; import { searchLocationReverse, @@ -541,24 +540,17 @@ } try { - const clusterResponse: WaypointPhotoClusterResponse = - await getPb().send("/waypoint/cluster", { - method: "POST", - headers: { - "content-type": "application/json", + const clusterResponse = await clusterWaypointPhotos({ + category: $formData.category, + photos: [ + { + id: waypointMergeCheckPhotoId, + lat: savedWaypoint.lat, + lon: savedWaypoint.lon, }, - body: JSON.stringify({ - category: $formData.category, - photos: [ - { - id: waypointMergeCheckPhotoId, - lat: savedWaypoint.lat, - lon: savedWaypoint.lon, - }, - ], - waypoints: existingWaypoints, - }), - }); + ], + waypoints: existingWaypoints, + }); const matchingCluster = clusterResponse.clusters.find( (cluster) => @@ -1323,6 +1315,36 @@ clusters: WaypointPhotoCluster[]; } + interface WaypointClusterPoint { + id: string; + lat: number; + lon: number; + } + + interface WaypointPhotoClusterRequest { + category?: string; + photos: WaypointClusterPoint[]; + waypoints: WaypointClusterPoint[]; + } + + async function clusterWaypointPhotos( + data: WaypointPhotoClusterRequest, + ): Promise { + const response = await fetch("/api/v1/waypoint/cluster", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + throw await response.json(); + } + + return (await response.json()) as WaypointPhotoClusterResponse; + } + const waypointMergeCheckPhotoId = "__waypoint_merge_check__"; async function handleWaypointPhotoSelection() { @@ -1374,20 +1396,14 @@ let clusterResponse: WaypointPhotoClusterResponse; try { - clusterResponse = await getPb().send("/waypoint/cluster", { - method: "POST", - headers: { - "content-type": "application/json", - }, - body: JSON.stringify({ - category: $formData.category, - photos: photoCoords.map((coords) => ({ - id: coords.id, - lat: coords.latitude, - lon: coords.longitude, - })), - waypoints: getExistingWaypointClusterInputs(), - }), + clusterResponse = await clusterWaypointPhotos({ + category: $formData.category, + photos: photoCoords.map((coords) => ({ + id: coords.id, + lat: coords.latitude, + lon: coords.longitude, + })), + waypoints: getExistingWaypointClusterInputs(), }); } catch (e) { show_toast( From 903013823e49c7b6784f2e7c5d53cb951637c831 Mon Sep 17 00:00:00 2001 From: slothful-vassal <89943360+slothful-vassal@users.noreply.github.com> Date: Sun, 31 May 2026 14:31:21 +0200 Subject: [PATCH 02/26] add contributing guidelines (#1029) --- CONTRIBUTING.md | 97 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..13716f04 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,97 @@ +# Contributing + +Thank you for your interest in contributing to `wanderer`. We appreciate all contributions, whether they are bug fixes, documentation updates, translations, or new features. + +## Before you start + +Before working on a significant change, please: + +- Open an issue, +- start a discussion +- or contact us via our [Discord server](https://discord.gg/USSEBY98CP) on the `#dev` channel. + +This helps us avoid duplicate work and ensures that your changes align with the project's direction. + +For small bug or typo fixes, documentation updates, translations, or clearly isolated improvements, opening a pull request directly is usually fine. + +Please follow our [local development guide](https://wanderer.to/develop/local-development/). + +## Pull Request Target Branch + +Please open pull requests only against the `main` branch. + +Pull requests targeting release branches, development branches, or unrelated branches may be closed without review. + +## Keep pull requests atomic + +Please keep pull requests as small and focused as possible. + +A good pull request should address one specific topic, such as: + +- One bug fix +- One isolated feature +- One documentation improvement +- One dependency update +- One refactoring + +Please avoid combining unrelated changes in the same pull request. For example, do not combine a bug fix with formatting changes, dependency updates, or other refactorings. + +Smaller pull requests are easier to review, test, and merge. + +## Describe the change clearly + +Every pull request should include a clear description of the change. + +- What was changed +- Why the change was needed +- How the change was tested +- Any known limitations or side effects. + +For UI changes, please include screenshots when helpful. + +## Bug fixes and reproduction steps + +When fixing a bug, please describe how it can be reproduced from an end-user perspective. + +Useful reproduction steps explain the actions a user takes in the application and the problem they observe. + +Avoid relying solely on artificial or highly technical steps, such as direct API calls to internal endpoints, unless the issue is specifically related to the API or cannot be reasonably reproduced through the user interface. + +## AI-assisted contributions + +Using AI tools for assistance is allowed. While AI tools can be helpful, contributors are expected to treat AI-assisted changes like any other code they submit. They should understand, carefully review, and test the changes before opening a pull request. + +If you used AI to implement a feature, improve existing functionality, or fix an issue, your pull request should clearly explain the problem or improvement, the intended user-facing behavior, and how the change was tested. + +To keep review work manageable, we may close pull requests that appear to be mainly AI-generated or submitted in large numbers without clear evidence that the contributor has reviewed, tested, and understood the changes. + +## Testing + +Please test your changes before opening a pull request. + +If automated tests exist for the affected area, please run them. If no automated tests exist, describe the manual testing you performed. + +A useful test description can include the following: + +- Operating system/browser/environment +- Relevant configuration +- Exact steps tested +- Expected and actual results + +## Breaking changes and migrations + +If your PR introduces breaking changes or requires migration, clearly state this in the pull request description. + +## Security Issues + +Please do not report security vulnerabilities through public issues or pull requests. + +If you believe you have found one, please contact the maintainers privately first. You can reach us via our [Discord server](https://discord.gg/USSEBY98CP). + +## Reviews + +Maintainers may request changes, additional tests, a smaller scope, or a different implementation approach. + +Please keep discussions constructive and focused on the code and the user-facing behavior. + +Thank you for helping improve the project. \ No newline at end of file From 64b39685ed74f98b382a21ac850c7950ebd5fa85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A5l=20H=C3=A5land?= <4449863+palhaland@users.noreply.github.com> Date: Sun, 31 May 2026 16:25:42 +0200 Subject: [PATCH 03/26] fix: use ActivityPub actor ID for shared trails/lists search token filtering (#1014) * fix: use ActivityPub actor ID for shared trails/lists search token filtering * fix deprecated meili token storage --------- Co-authored-by: Flomp Co-authored-by: Christian Beutel <> --- db/hooks/users.go | 22 +--------------------- db/routes/search_token.go | 5 ++--- 2 files changed, 3 insertions(+), 24 deletions(-) diff --git a/db/hooks/users.go b/db/hooks/users.go index 6753c7e5..638167b1 100644 --- a/db/hooks/users.go +++ b/db/hooks/users.go @@ -12,36 +12,16 @@ import ( func CreateUserHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error { return func(e *core.RecordEvent) error { - userId := e.Record.Id - err := createDefaultUserSettings(e.App, e.Record.Id) if err != nil { return err } - actor, err := util.ActorFromUser(e.App, e.Record) + _, err = util.ActorFromUser(e.App, e.Record) if err != nil { return err } - searchRules := map[string]interface{}{ - "lists": map[string]string{ - "filter": "public = true OR author = " + actor.Id + " OR shares = " + userId, - }, - "trails": map[string]string{ - "filter": "public = true OR author = " + actor.Id + " OR shares = " + userId, - }, - } - - token, err := util.GenerateMeilisearchToken(searchRules, client) - if err != nil { - return err - } - e.Record.Set("token", token) - if err := e.App.Save(e.Record); err != nil { - return err - } - return e.Next() } } diff --git a/db/routes/search_token.go b/db/routes/search_token.go index e5cd9709..228c2dc8 100644 --- a/db/routes/search_token.go +++ b/db/routes/search_token.go @@ -16,7 +16,6 @@ func SearchToken(client meilisearch.ServiceManager) func(e *core.RequestEvent) e } if e.Auth != nil { - userId := e.Auth.Id userActor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id) if err != nil { return err @@ -24,10 +23,10 @@ func SearchToken(client meilisearch.ServiceManager) func(e *core.RequestEvent) e searchRules = map[string]any{ "lists": map[string]string{ - "filter": "public = true OR author = " + userActor.Id + " OR shares = " + userId, + "filter": "public = true OR author = " + userActor.Id + " OR shares = " + userActor.Id, }, "trails": map[string]string{ - "filter": "public = true OR author = " + userActor.Id + " OR shares = " + userId, + "filter": "public = true OR author = " + userActor.Id + " OR shares = " + userActor.Id, }, } } From 77cf8a08197ce854d93f4a9ca16f09ca69a8beec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A5l=20H=C3=A5land?= <4449863+palhaland@users.noreply.github.com> Date: Sun, 31 May 2026 16:46:43 +0200 Subject: [PATCH 04/26] feat(strava): sync all activity photos instead of only primary photo (#1015) Co-authored-by: Flomp --- db/integrations/strava/models.go | 5 +++ db/integrations/strava/strava.go | 72 +++++++++++++++++++++++++++----- 2 files changed, 67 insertions(+), 10 deletions(-) diff --git a/db/integrations/strava/models.go b/db/integrations/strava/models.go index 41ccb665..c26f1012 100644 --- a/db/integrations/strava/models.go +++ b/db/integrations/strava/models.go @@ -351,6 +351,11 @@ type Photos struct { Count int `json:"count"` } +type StravaActivityPhoto struct { + UniqueID string `json:"unique_id"` + Urls Urls `json:"urls"` +} + type HighlightedKudosers struct { DestinationURL string `json:"destination_url"` DisplayName string `json:"display_name"` diff --git a/db/integrations/strava/strava.go b/db/integrations/strava/strava.go index e4bee208..fcfc468b 100644 --- a/db/integrations/strava/strava.go +++ b/db/integrations/strava/strava.go @@ -452,7 +452,7 @@ func syncTrailsWithActivities(app core.App, client meilisearch.ServiceManager, c app.Logger().Warn(fmt.Sprintf("Unable to fetch GPX for activity '%s': %v", activity.Name, err)) continue } - trailID, err := createTrailFromActivity(app, detailedActivity, gpx, user, actor.Id, i.Privacy) + trailID, err := createTrailFromActivity(app, detailedActivity, gpx, user, actor.Id, i.Privacy, accessToken) if err != nil { app.Logger().Warn(fmt.Sprintf("Unable to create trail from activity '%s': %v", activity.Name, err)) continue @@ -492,7 +492,7 @@ func fetchDetailedActivity(activity StravaActivity, accessToken string) (*Detail return &detailedActivity, nil } -func createTrailFromActivity(app core.App, activity *DetailedStravaActivity, gpx *filesystem.File, user string, actor string, privacy string) (string, error) { +func createTrailFromActivity(app core.App, activity *DetailedStravaActivity, gpx *filesystem.File, user string, actor string, privacy string, accessToken string) (string, error) { if len(activity.StartLatlng) < 2 { return "", nil } @@ -502,11 +502,19 @@ func createTrailFromActivity(app core.App, activity *DetailedStravaActivity, gpx return "", err } - var photo *filesystem.File - if len(activity.Photos.Primary.Urls.Num600) > 0 { - photo, err = fetchActivityPhoto(activity) + var photos []*filesystem.File + if activity.Photos.Count > 0 { + photos, err = fetchActivityPhotos(activity.ID, accessToken) if err != nil { - return "", err + app.Logger().Warn(fmt.Sprintf("Failed to fetch activity photos for activity %d: %v", activity.ID, err)) + } + } + + // Fallback to primary photo if no photos were fetched but primary URL is available + if len(photos) == 0 && len(activity.Photos.Primary.Urls.Num600) > 0 { + photo, err := fetchPhotoFromURL(activity.Photos.Primary.Urls.Num600) + if err == nil { + photos = []*filesystem.File{photo} } } @@ -589,8 +597,8 @@ func createTrailFromActivity(app core.App, activity *DetailedStravaActivity, gpx "author": actor, }) - if photo != nil { - record.Set("photos", photo) + if len(photos) > 0 { + record.Set("photos", photos) } if gpx != nil { @@ -607,8 +615,8 @@ func createTrailFromActivity(app core.App, activity *DetailedStravaActivity, gpx return record.Id, nil } -func fetchActivityPhoto(activity *DetailedStravaActivity) (*filesystem.File, error) { - req, err := http.NewRequest("GET", activity.Photos.Primary.Urls.Num600, nil) +func fetchPhotoFromURL(url string) (*filesystem.File, error) { + req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, err } @@ -638,6 +646,50 @@ func fetchActivityPhoto(activity *DetailedStravaActivity) (*filesystem.File, err return photo, nil } +func fetchActivityPhotos(activityID int64, accessToken string) ([]*filesystem.File, error) { + url := fmt.Sprintf("https://www.strava.com/api/v3/activities/%d/photos?size=600", activityID) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch activity photos: received status %d", resp.StatusCode) + } + + var apiPhotos []StravaActivityPhoto + if err := json.NewDecoder(resp.Body).Decode(&apiPhotos); err != nil { + return nil, err + } + + photos := make([]*filesystem.File, 0, len(apiPhotos)) + for _, apiPhoto := range apiPhotos { + photoURL := apiPhoto.Urls.Num600 + if photoURL == "" { + photoURL = apiPhoto.Urls.Num100 + } + if photoURL == "" { + continue + } + + photo, err := fetchPhotoFromURL(photoURL) + if err != nil { + continue + } + photos = append(photos, photo) + } + + return photos, nil +} + func generateActivityGPX(activity *DetailedStravaActivity, accessToken string) (*filesystem.File, error) { url := fmt.Sprintf("https://www.strava.com/api/v3/activities/%d/streams?keys=latlng,time,altitude&key_by_type=true", activity.ID) From 1718fe67549d97aa33a57d117909a7b6fca60f0f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 16:54:04 +0200 Subject: [PATCH 05/26] Bump @sveltejs/kit from 2.57.1 to 2.60.1 in /web (#1010) Bumps [@sveltejs/kit](https://github.com/sveltejs/kit/tree/HEAD/packages/kit) from 2.57.1 to 2.60.1. - [Release notes](https://github.com/sveltejs/kit/releases) - [Changelog](https://github.com/sveltejs/kit/blob/main/packages/kit/CHANGELOG.md) - [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/kit@2.60.1/packages/kit) --- updated-dependencies: - dependency-name: "@sveltejs/kit" dependency-version: 2.60.1 dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Flomp --- web/package-lock.json | 501 +----------------------------------------- web/package.json | 2 +- 2 files changed, 9 insertions(+), 494 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index a71608d5..8cae5564 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -63,7 +63,7 @@ "@playwright/test": "^1.58.2", "@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/enhanced-img": "^0.10.4", - "@sveltejs/kit": "^2.57.1", + "@sveltejs/kit": "^2.60.1", "@sveltejs/vite-plugin-svelte": "^7.0.0", "@tailwindcss/typography": "^0.5.19", "@types/canvas-confetti": "^1.9.0", @@ -171,448 +171,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", - "cpu": [ - "mips64el" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, "node_modules/@felte/common": { "version": "1.1.9", "resolved": "https://registry.npmjs.org/@felte/common/-/common-1.1.9.tgz", @@ -2159,9 +1717,9 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.57.1", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.57.1.tgz", - "integrity": "sha512-VRdSbB96cI1EnRh09CqmnQqP/YJvET5buj8S6k7CxaJqBJD4bw4fRKDjcarAj/eX9k2eHifQfDH8NtOh+ZxxPw==", + "version": "2.60.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.60.1.tgz", + "integrity": "sha512-mQjlkNo+rJvpln7V2IGY2j99BqhcFbS4UN0AQNKNYfhBAFZTuCDAdW3a1sgf330mvtNvsBXn3HpAhcmvdJTcIQ==", "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -2169,7 +1727,7 @@ "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", - "devalue": "^5.6.4", + "devalue": "^5.8.1", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", @@ -4054,9 +3612,9 @@ } }, "node_modules/devalue": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.7.1.tgz", - "integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==", + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", "license": "MIT" }, "node_modules/dfa": { @@ -4218,49 +3776,6 @@ "es6-symbol": "^3.1.1" } }, - "node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", diff --git a/web/package.json b/web/package.json index dc5c071d..66c766a2 100644 --- a/web/package.json +++ b/web/package.json @@ -18,7 +18,7 @@ "@playwright/test": "^1.58.2", "@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/enhanced-img": "^0.10.4", - "@sveltejs/kit": "^2.57.1", + "@sveltejs/kit": "^2.60.1", "@sveltejs/vite-plugin-svelte": "^7.0.0", "@tailwindcss/typography": "^0.5.19", "@types/canvas-confetti": "^1.9.0", From 79b86b62198ae2267eec4591af5fa99bc0ce2859 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 16:59:10 +0200 Subject: [PATCH 06/26] Bump svelte from 5.55.4 to 5.55.7 in /web (#999) Bumps [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) from 5.55.4 to 5.55.7. - [Release notes](https://github.com/sveltejs/svelte/releases) - [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md) - [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.55.7/packages/svelte) --- updated-dependencies: - dependency-name: svelte dependency-version: 5.55.7 dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Flomp --- web/package-lock.json | 10 +++++----- web/package.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 8cae5564..8ceb99b4 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -69,7 +69,7 @@ "@types/canvas-confetti": "^1.9.0", "@types/node": "^25.3.3", "postcss": "^8.5.6", - "svelte": "^5.53.6", + "svelte": "^5.55.7", "svelte-check": "^4.3.6", "sveltekit-openapi-generator": "^0.1.5", "tslib": "^2.4.1", @@ -6139,9 +6139,9 @@ } }, "node_modules/svelte": { - "version": "5.55.4", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.4.tgz", - "integrity": "sha512-q8DFohk6vUswSng95IZb9nzWJnbINZsK7OiM1snAa3qCjJBL0ZQpvMyAaVXjUukdM75J/m8UE8xwqat8Ors/zQ==", + "version": "5.55.7", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.7.tgz", + "integrity": "sha512-ymI5ykLPwIHW839E053FQbI1G+jnRFJEw3Kv5Y4njixVWywQBx+NUFpkkKyk5LIb36Fg9DVXSYpqiGekLD0hyw==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", @@ -6153,7 +6153,7 @@ "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", - "devalue": "^5.6.4", + "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.4", "is-reference": "^3.0.3", diff --git a/web/package.json b/web/package.json index 66c766a2..ac4767ab 100644 --- a/web/package.json +++ b/web/package.json @@ -24,7 +24,7 @@ "@types/canvas-confetti": "^1.9.0", "@types/node": "^25.3.3", "postcss": "^8.5.6", - "svelte": "^5.53.6", + "svelte": "^5.55.7", "svelte-check": "^4.3.6", "sveltekit-openapi-generator": "^0.1.5", "tslib": "^2.4.1", From 65c2e3d932e73da37693bcd2eec8ee11ee0bc84b Mon Sep 17 00:00:00 2001 From: slothful-vassal <89943360+slothful-vassal@users.noreply.github.com> Date: Mon, 1 Jun 2026 16:01:59 +0200 Subject: [PATCH 07/26] fix: reduce Meilisearch load, debounce federation sync (#1012) * optimize meili trail index * several fixes --------- Co-authored-by: Flomp --- db/go.mod | 2 +- db/hooks/trails.go | 10 +++ db/main.go | 48 +++++++++++++- .../1748000000_updated_trails_polyline.go | 35 ++++++++++ db/routes/remote_list.go | 13 +++- db/routes/remote_trail.go | 31 ++++++++- db/util/meilisearch.go | 59 +---------------- db/util/polyline.go | 64 +++++++++++++++++++ 8 files changed, 199 insertions(+), 63 deletions(-) create mode 100644 db/migrations/1748000000_updated_trails_polyline.go create mode 100644 db/util/polyline.go diff --git a/db/go.mod b/db/go.mod index df444bd7..8efc8e6e 100644 --- a/db/go.mod +++ b/db/go.mod @@ -32,7 +32,7 @@ require ( github.com/ganigeorgiev/fexpr v0.5.0 // indirect github.com/go-ap/activitypub v0.0.0-20250905102448-e9df599e4528 github.com/go-fed/httpsig v1.1.0 - github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect + github.com/go-ozzo/ozzo-validation/v4 v4.3.0 github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect diff --git a/db/hooks/trails.go b/db/hooks/trails.go index a3045b46..40bcf4ce 100644 --- a/db/hooks/trails.go +++ b/db/hooks/trails.go @@ -20,6 +20,9 @@ func CreateTrailHandler(client meilisearch.ServiceManager) func(e *core.RecordEv if err != nil { return err } + if err := util.SavePolyline(e.App, record); err != nil { + log.Printf("failed to save polyline for trail %s: %v", record.Id, err) + } if err := util.IndexTrails(e.App, []*core.Record{record}, client); err != nil { return err } @@ -62,6 +65,13 @@ func UpdateTrailHandler(client meilisearch.ServiceManager) func(e *core.RecordEv if err != nil { return err } + + if record.GetString("gpx") != record.Original().GetString("gpx") { + if err := util.SavePolyline(e.App, record); err != nil { + log.Printf("failed to save polyline for trail %s: %v", record.Id, err) + } + } + err = util.UpdateTrail(e.App, record, userActor, client) if err != nil { return err diff --git a/db/main.go b/db/main.go index aa1a1800..046455e7 100644 --- a/db/main.go +++ b/db/main.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/meilisearch/meilisearch-go" + "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/plugins/migratecmd" @@ -213,10 +214,55 @@ func registerCronJobs(app core.App, client meilisearch.ServiceManager) { func initData(app core.App, client meilisearch.ServiceManager) error { initCategories(app) initMeilisearchConfig(client) - go initMeilisearchDocuments(app, client) + go func() { + backfillPolylines(app) + initMeilisearchDocuments(app, client) + }() return nil } +func backfillPolylines(app core.App) { + const pageSize int64 = 100 + var lastID string + var processed int + var failed int + + log.Printf("backfill polyline started") + defer func() { + log.Printf("backfill polyline completed: processed=%d failed=%d", processed, failed) + }() + + for { + trails := []*core.Record{} + query := app.RecordQuery("trails"). + AndWhere(dbx.NewExp("(polyline IS NULL OR polyline = '') AND gpx != ''")). + OrderBy("id ASC"). + Limit(pageSize) + + if lastID != "" { + query = query.AndWhere(dbx.NewExp("id > {:lastID}", dbx.Params{"lastID": lastID})) + } + + err := query.All(&trails) + if err != nil { + log.Printf("backfill polyline query failed after trail %q: %v", lastID, err) + break + } + if len(trails) == 0 { + break + } + for _, r := range trails { + if err := util.SavePolyline(app, r); err != nil { + failed++ + log.Printf("backfill polyline failed for trail %s (%q), gpx=%q: %v", r.Id, r.GetString("name"), r.GetString("gpx"), err) + } else { + processed++ + } + lastID = r.Id + } + } +} + func initCategories(app core.App) error { query := app.RecordQuery("categories") records := []*core.Record{} diff --git a/db/migrations/1748000000_updated_trails_polyline.go b/db/migrations/1748000000_updated_trails_polyline.go new file mode 100644 index 00000000..d82d6e9c --- /dev/null +++ b/db/migrations/1748000000_updated_trails_polyline.go @@ -0,0 +1,35 @@ +package migrations + +import ( + "github.com/pocketbase/pocketbase/core" + m "github.com/pocketbase/pocketbase/migrations" +) + +func init() { + m.Register(func(app core.App) error { + collection, err := app.FindCollectionByNameOrId("e864strfxo14pm4") + if err != nil { + return err + } + + if collection.Fields.GetByName("polyline") != nil { + return nil + } + + field := &core.TextField{} + field.Name = "polyline" + field.Required = false + collection.Fields.Add(field) + + return app.Save(collection) + }, func(app core.App) error { + collection, err := app.FindCollectionByNameOrId("e864strfxo14pm4") + if err != nil { + return err + } + + collection.Fields.RemoveByName("polyline") + + return app.Save(collection) + }) +} diff --git a/db/routes/remote_list.go b/db/routes/remote_list.go index 464bdb60..2e08aba7 100644 --- a/db/routes/remote_list.go +++ b/db/routes/remote_list.go @@ -51,8 +51,17 @@ func RemoteListGet(e *core.RequestEvent) error { } } else { updatedAt := record.GetDateTime("updated").Time() - if time.Now().UTC().Sub(updatedAt) > 60*time.Minute { - go performFullListSync(e.App, ctx, e.Request.URL, record) + + iri := record.GetString("iri") + if time.Now().UTC().Sub(updatedAt) > remoteSyncThreshold { + if _, alreadySyncing := listSyncing.LoadOrStore(iri, struct{}{}); !alreadySyncing { + urlCopy := *e.Request.URL + bgCtx := context.WithValue(context.Background(), "actor", ctx.Value("actor")) + go func() { + defer listSyncing.Delete(iri) + performFullListSync(e.App, bgCtx, &urlCopy, record) + }() + } } } } else { diff --git a/db/routes/remote_trail.go b/db/routes/remote_trail.go index c98dc496..01bd8f90 100644 --- a/db/routes/remote_trail.go +++ b/db/routes/remote_trail.go @@ -8,10 +8,13 @@ import ( "io" "net/http" "net/url" + "os" "path" "pocketbase/federation" "pocketbase/util" + "strconv" "strings" + "sync" "time" "github.com/pocketbase/dbx" @@ -19,6 +22,21 @@ import ( "github.com/pocketbase/pocketbase/tools/filesystem" ) +// remoteSyncThreshold is the minimum age of a remote record before a background sync is triggered. +// Configurable via POCKETBASE_FEDERATION_SYNC_INTERVAL (minutes). Default: 60. +var remoteSyncThreshold = func() time.Duration { + if v := os.Getenv("POCKETBASE_FEDERATION_SYNC_INTERVAL"); v != "" { + if minutes, err := strconv.Atoi(v); err == nil && minutes > 0 { + return time.Duration(minutes) * time.Minute + } + } + return 60 * time.Minute +}() + +// trailSyncing and listSyncing track IRIs currently being synced to prevent concurrent duplicate syncs. +var trailSyncing sync.Map +var listSyncing sync.Map + // --- Main Handler --- func RemoteTrailGet(e *core.RequestEvent) error { @@ -64,8 +82,17 @@ func RemoteTrailGet(e *core.RequestEvent) error { } else { // We already have it locally. Show and update background. updatedAt := record.GetDateTime("updated").Time() - if time.Now().UTC().Sub(updatedAt) > 60*time.Minute { - go performFullSync(e.App, ctx, e.Request.URL, record) + + iri := record.GetString("iri") + if time.Now().UTC().Sub(updatedAt) > remoteSyncThreshold { + if _, alreadySyncing := trailSyncing.LoadOrStore(iri, struct{}{}); !alreadySyncing { + urlCopy := *e.Request.URL + bgCtx := context.WithValue(context.Background(), "actor", ctx.Value("actor")) + go func() { + defer trailSyncing.Delete(iri) + performFullSync(e.App, bgCtx, &urlCopy, record) + }() + } } } } else { diff --git a/db/util/meilisearch.go b/db/util/meilisearch.go index f57a0750..92e2a86e 100644 --- a/db/util/meilisearch.go +++ b/db/util/meilisearch.go @@ -13,8 +13,6 @@ import ( "github.com/meilisearch/meilisearch-go" "github.com/pocketbase/pocketbase/core" - "github.com/tkrajina/gpxgo/gpx" - "github.com/twpayne/go-polyline" ) func documentFromTrailRecord(app core.App, r *core.Record, author *core.Record, includeShares bool) (map[string]interface{}, error) { @@ -41,11 +39,6 @@ func documentFromTrailRecord(app core.App, r *core.Record, author *core.Record, category = trailCategory.GetString("name") } - polyline, err := getPolyline(app, r) - if err != nil { - polyline = "" - } - domain := "" if !author.GetBool("isLocal") { domain = author.GetString("domain") @@ -72,7 +65,7 @@ func documentFromTrailRecord(app core.App, r *core.Record, author *core.Record, "thumbnail": thumbnail, "gpx": r.GetString("gpx"), "tags": tags, - "polyline": polyline, + "polyline": r.GetString("polyline"), "domain": domain, "iri": r.GetString("iri"), "_geo": map[string]float64{ @@ -128,46 +121,6 @@ func difficultyToNumber(difficulty string) int32 { return 0 } -func getPolyline(app core.App, r *core.Record) (string, error) { - gpxPath := r.GetString("gpx") - if len(gpxPath) == 0 { - return "", nil - } - avatarKey := r.BaseFilesPath() + "/" + gpxPath - fsys, err := app.NewFilesystem() - if err != nil { - return "", err - } - defer fsys.Close() - - gpxFile, err := fsys.GetReader(avatarKey) - if err != nil { - return "", err - } - defer gpxFile.Close() - - content := new(bytes.Buffer) - _, err = io.Copy(content, gpxFile) - if err != nil { - return "", err - } - gpxData, err := gpx.Parse(content) - if err != nil { - return "", err - } - - gpxData.SimplifyTracks(50) - coordinates := make([][]float64, 4) - for _, trk := range gpxData.Tracks { - for _, seg := range trk.Segments { - for _, pt := range seg.Points { - coordinates = append(coordinates, []float64{pt.Latitude, pt.Longitude}) - } - } - } - return string(polyline.EncodeCoords(coordinates)), nil -} - func documentFromListRecord(r *core.Record, author *core.Record, includeShares bool) (map[string]any, error) { totalElevationGain := 0.0 @@ -359,18 +312,10 @@ func UpdateTrail(app core.App, r *core.Record, author *core.Record, client meili } documents := []map[string]interface{}{doc} - task, err := client.Index("trails").UpdateDocuments(documents, nil) - - if err != nil { + if _, err = client.Index("trails").UpdateDocuments(documents, nil); err != nil { return err } - interval := 500 * time.Millisecond - _, err = client.WaitForTask(task.TaskUID, interval) - if err != nil { - return fmt.Errorf("meilisearch update trail: error waiting for task completion: %v", err) - } - return nil } diff --git a/db/util/polyline.go b/db/util/polyline.go new file mode 100644 index 00000000..4b782909 --- /dev/null +++ b/db/util/polyline.go @@ -0,0 +1,64 @@ +package util + +import ( + "bytes" + "fmt" + "io" + + "github.com/pocketbase/pocketbase/core" + "github.com/tkrajina/gpxgo/gpx" + "github.com/twpayne/go-polyline" +) + +func ComputePolyline(app core.App, r *core.Record) (string, error) { + gpxPath := r.GetString("gpx") + if len(gpxPath) == 0 { + return "", nil + } + + fsys, err := app.NewFilesystem() + if err != nil { + return "", fmt.Errorf("open filesystem: %w", err) + } + defer fsys.Close() + + gpxFilePath := r.BaseFilesPath() + "/" + gpxPath + gpxFile, err := fsys.GetReader(gpxFilePath) + if err != nil { + return "", fmt.Errorf("open gpx file %q: %w", gpxFilePath, err) + } + defer gpxFile.Close() + + content := new(bytes.Buffer) + if _, err = io.Copy(content, gpxFile); err != nil { + return "", fmt.Errorf("read gpx file %q: %w", gpxFilePath, err) + } + + gpxData, err := gpx.Parse(content) + if err != nil { + return "", fmt.Errorf("parse gpx file %q: %w", gpxFilePath, err) + } + + gpxData.SimplifyTracks(50) + coordinates := make([][]float64, 0) + for _, trk := range gpxData.Tracks { + for _, seg := range trk.Segments { + for _, pt := range seg.Points { + coordinates = append(coordinates, []float64{pt.Latitude, pt.Longitude}) + } + } + } + return string(polyline.EncodeCoords(coordinates)), nil +} + +func SavePolyline(app core.App, r *core.Record) error { + encoded, err := ComputePolyline(app, r) + if err != nil { + return err + } + r.Set("polyline", encoded) + if err := app.UnsafeWithoutHooks().Save(r); err != nil { + return fmt.Errorf("save trail polyline: %w", err) + } + return nil +} From 5710531585b63c998088ccee2325b8222f4bfea0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 16:14:48 +0200 Subject: [PATCH 08/26] Bump svelte from 5.55.5 to 5.56.0 in /docs (#1032) Bumps [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) from 5.55.5 to 5.56.0. - [Release notes](https://github.com/sveltejs/svelte/releases) - [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md) - [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.56.0/packages/svelte) --- updated-dependencies: - dependency-name: svelte dependency-version: 5.56.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Flomp --- docs/package-lock.json | 54 +++++++++++++++++++----------------------- docs/package.json | 2 +- 2 files changed, 25 insertions(+), 31 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index aff7768f..7710e522 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -19,7 +19,7 @@ "astro": "^6.3.2", "sharp": "^0.34.5", "starlight-openapi": "^0.25.0", - "svelte": "^5.55.5", + "svelte": "^5.56.0", "tailwindcss": "^4.1.10", "typescript": "^5.9.3" } @@ -2188,9 +2188,9 @@ "license": "MIT" }, "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", - "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", + "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" @@ -2653,19 +2653,6 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, - "node_modules/@typescript-eslint/types": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", - "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -3534,9 +3521,9 @@ } }, "node_modules/devalue": { - "version": "5.6.4", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", - "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", "license": "MIT" }, "node_modules/devlop": { @@ -3890,13 +3877,20 @@ "license": "MIT" }, "node_modules/esrap": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.4.tgz", - "integrity": "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==", + "version": "2.2.9", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.9.tgz", + "integrity": "sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A==", "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15", + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } } }, "node_modules/estree-util-attach-comments": { @@ -7698,23 +7692,23 @@ } }, "node_modules/svelte": { - "version": "5.55.5", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.5.tgz", - "integrity": "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw==", + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.0.tgz", + "integrity": "sha512-kTXr26t1bchFp28ROrb957LtbujpBmBDibmqMGziVpUs7awBi96TGgX6SovrA8BNoEUDVRK2Fb9FkeYlGspoVg==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", - "@sveltejs/acorn-typescript": "^1.0.5", + "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", - "devalue": "^5.6.4", + "devalue": "^5.8.1", "esm-env": "^1.2.1", - "esrap": "^2.2.4", + "esrap": "^2.2.9", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", diff --git a/docs/package.json b/docs/package.json index d79e86e6..d0bb8872 100644 --- a/docs/package.json +++ b/docs/package.json @@ -22,7 +22,7 @@ "astro": "^6.3.2", "sharp": "^0.34.5", "starlight-openapi": "^0.25.0", - "svelte": "^5.55.5", + "svelte": "^5.56.0", "tailwindcss": "^4.1.10", "typescript": "^5.9.3" } From 7ad129b84197a6dae51afb7cd6e9ff660a68614b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 19:30:44 +0200 Subject: [PATCH 09/26] Release v0.19.2 (#1035) * chore: release v0.19.2 * add changelog --------- Co-authored-by: Flomp <26000991+Flomp@users.noreply.github.com> Co-authored-by: Christian Beutel <> --- CHANGELOG.md | 11 +++++++++++ docs/package-lock.json | 4 ++-- docs/package.json | 2 +- docs/src/content/docs/changelog.md | 11 +++++++++++ web/package-lock.json | 4 ++-- web/package.json | 2 +- 6 files changed, 28 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c815dd6..d03bd95b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +# v0.19.2 +## Documentation +- Add CONTRIBUTING guidelines + +## Bug Fixes +- All photos from strava activities are now synced, instead of just the first one +- Shared trails are now displayed correctly in search results +- Fixes bug that caused trails to be indexed multiple times causing high server load +- Remaining likes are no correctly calculated when unliking a trail +- Fix waypoint creation from photos + # v0.19.1 ## Features diff --git a/docs/package-lock.json b/docs/package-lock.json index 7710e522..ec0980e6 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -1,12 +1,12 @@ { "name": "docs", - "version": "0.19.1", + "version": "0.19.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "docs", - "version": "0.19.1", + "version": "0.19.2", "dependencies": { "@astrojs/check": "^0.9.8", "@astrojs/node": "^10.1.1", diff --git a/docs/package.json b/docs/package.json index d0bb8872..bd2c93f8 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,7 +1,7 @@ { "name": "docs", "type": "module", - "version": "0.19.1", + "version": "0.19.2", "scripts": { "dev": "astro dev", "start": "astro dev", diff --git a/docs/src/content/docs/changelog.md b/docs/src/content/docs/changelog.md index 9c0ef912..b9a672a2 100644 --- a/docs/src/content/docs/changelog.md +++ b/docs/src/content/docs/changelog.md @@ -2,6 +2,17 @@ title: Changelog description: What changed in the last patch? --- +## v0.19.2 +### Documentation +- Add CONTRIBUTING guidelines + +### Bug Fixes +- All photos from strava activities are now synced, instead of just the first one +- Shared trails are now displayed correctly in search results +- Fixes bug that caused trails to be indexed multiple times causing high server load +- Remaining likes are no correctly calculated when unliking a trail +- Fix waypoint creation from photos + ## v0.19.1 ### Features diff --git a/web/package-lock.json b/web/package-lock.json index 8ceb99b4..0ac5403c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "wanderer", - "version": "0.19.1", + "version": "0.19.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wanderer", - "version": "0.19.1", + "version": "0.19.2", "dependencies": { "@felte/validator-zod": "^1.0.18", "@fortawesome/fontawesome-free": "^7.1.0", diff --git a/web/package.json b/web/package.json index ac4767ab..c49f8417 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "wanderer", - "version": "0.19.1", + "version": "0.19.2", "private": true, "scripts": { "dev": "vite dev", From 8153cfe14e9eb443f51acd3ab8efc805af96a8bc Mon Sep 17 00:00:00 2001 From: slothful-vassal <89943360+slothful-vassal@users.noreply.github.com> Date: Thu, 4 Jun 2026 10:52:15 +0200 Subject: [PATCH 10/26] fix self-federation (#1044) --- db/routes/remote_list.go | 30 +++++++++++++++++++--- db/routes/remote_trail.go | 34 ++++++++++++++++++++++--- db/util/activitypub.go | 53 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 8 deletions(-) diff --git a/db/routes/remote_list.go b/db/routes/remote_list.go index 2e08aba7..3069947b 100644 --- a/db/routes/remote_list.go +++ b/db/routes/remote_list.go @@ -49,6 +49,12 @@ func RemoteListGet(e *core.RequestEvent) error { } return e.InternalServerError("Sync failed", err) } + if record.Id == "" { + // Local content that does not exist: performFullListSync + // short-circuits local IRIs and returns the unsaved shell — + // surface a real 404 instead of access/expand on a missing record. + return e.NotFoundError("List not found", nil) + } } else { updatedAt := record.GetDateTime("updated").Time() @@ -111,11 +117,24 @@ func findLocalListByRemoteInfo(e *core.RequestEvent, ctx context.Context, handle } func performFullListSync(app core.App, ctx context.Context, reqURL *url.URL, localList *core.Record) (*core.Record, error) { - client := util.SafeHTTPClient() - iri := localList.GetString("iri") + + // Never federate with ourselves (see performFullSync in remote_trail.go). + if iri == "" || util.IsLocalIRI(iri) { + if localList.GetBool("needs_full_sync") { + localList.Set("needs_full_sync", false) + if err := app.Save(localList); err != nil { + return localList, err + } + } + return localList, nil + } + + client := util.SafeHTTPClient() remoteUrl, _ := url.Parse(iri) - remoteUrl.RawQuery = reqURL.RawQuery + query := reqURL.Query() + query.Del("handle") + remoteUrl.RawQuery = query.Encode() origin := fmt.Sprintf("%s://%s", remoteUrl.Scheme, remoteUrl.Host) req, err := http.NewRequestWithContext(ctx, http.MethodGet, remoteUrl.String(), nil) @@ -124,10 +143,13 @@ func performFullListSync(app core.App, ctx context.Context, reqURL *url.URL, loc } res, err := client.Do(req) - if err != nil || res.StatusCode != 200 { + if err != nil { return localList, err } defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return localList, fmt.Errorf("remote list fetch %s returned: %d", remoteUrl.String(), res.StatusCode) + } var remoteMap map[string]any if err := json.NewDecoder(res.Body).Decode(&remoteMap); err != nil { diff --git a/db/routes/remote_trail.go b/db/routes/remote_trail.go index 01bd8f90..c9a0fed6 100644 --- a/db/routes/remote_trail.go +++ b/db/routes/remote_trail.go @@ -79,6 +79,13 @@ func RemoteTrailGet(e *core.RequestEvent) error { } return e.InternalServerError("Sync failed", err) } + if record.Id == "" { + // Local content that does not exist (e.g. a stale URL to a + // missing local trail): performFullSync short-circuits local + // IRIs and returns the unsaved shell — surface a real 404 + // instead of running access/expand on a non-existent record. + return e.NotFoundError("Trail not found", nil) + } } else { // We already have it locally. Show and update background. updatedAt := record.GetDateTime("updated").Time() @@ -146,19 +153,38 @@ func findLocalTrailByRemoteInfo(e *core.RequestEvent, ctx context.Context, handl // --- Core Sync Logic --- func performFullSync(app core.App, ctx context.Context, reqURL *url.URL, localTrail *core.Record) (*core.Record, error) { - client := util.SafeHTTPClient() - iri := localTrail.GetString("iri") + + // Never federate with ourselves: a trail whose IRI is empty or points back + // to this instance is local content (we are the source of truth). Syncing it + // would fetch our own origin (or fail on an empty URL); just clear the stale + // flag so the record is no longer stuck in a permanent re-sync loop. + if iri == "" || util.IsLocalIRI(iri) { + if localTrail.GetBool("needs_full_sync") { + localTrail.Set("needs_full_sync", false) + if err := app.Save(localTrail); err != nil { + return localTrail, err + } + } + return localTrail, nil + } + + client := util.SafeHTTPClient() remoteUrl, _ := url.Parse(iri) - remoteUrl.RawQuery = reqURL.RawQuery // Forward params + query := reqURL.Query() + query.Del("handle") + remoteUrl.RawQuery = query.Encode() origin := fmt.Sprintf("%s://%s", remoteUrl.Scheme, remoteUrl.Host) req, _ := http.NewRequestWithContext(ctx, "GET", remoteUrl.String(), nil) res, err := client.Do(req) - if err != nil || res.StatusCode != 200 { + if err != nil { return localTrail, err } defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return localTrail, fmt.Errorf("remote trail fetch %s returned: %d", remoteUrl.String(), res.StatusCode) + } var remoteMap map[string]any if err := json.NewDecoder(res.Body).Decode(&remoteMap); err != nil { diff --git a/db/util/activitypub.go b/db/util/activitypub.go index a52cd1c1..7a44466f 100644 --- a/db/util/activitypub.go +++ b/db/util/activitypub.go @@ -111,6 +111,28 @@ func generateKeyPair() (*rsa.PrivateKey, *rsa.PublicKey, error) { return priv, pub, nil } +// IsLocalIRI reports whether iri belongs to this instance's own ORIGIN. +// It is used to prevent the instance from federating with itself, i.e. treating +// its own content as if it were remote. +func IsLocalIRI(iri string) bool { + if iri == "" { + return false + } + origin := os.Getenv("ORIGIN") + if origin == "" { + return false + } + o, err := url.Parse(origin) + if err != nil { + return false + } + u, err := url.Parse(iri) + if err != nil { + return false + } + return strings.EqualFold(u.Host, o.Host) +} + func TrailFromActivity(activity pub.Activity, app core.App, actor *core.Record) (*core.Record, error) { t, err := pub.ToObject(activity.Object) if err != nil { @@ -118,6 +140,24 @@ func TrailFromActivity(activity pub.Activity, app core.App, actor *core.Record) } iri := t.ID.String() + + // Own content must never be ingested as if it were remote. An inbound + // activity referencing one of our own trails (e.g. an announce echoed back) + // would otherwise flag the local trail for a full sync and later make the + // instance fetch itself. Only a local actor may resolve a local object id to + // the local record; a remote actor must not be able to reference or attach + // side effects (feeds/shares/notifications) to local content by id. + if IsLocalIRI(iri) { + if !actor.GetBool("isLocal") { + return nil, fmt.Errorf("refusing remote activity referencing local trail %q", iri) + } + trailUrl, parseErr := url.Parse(iri) + if parseErr != nil { + return nil, parseErr + } + return app.FindRecordById("trails", path.Base(trailUrl.Path)) + } + var record *core.Record if actor.GetBool(("isLocal")) { trailUrl, parseErr := url.Parse(iri) @@ -409,6 +449,19 @@ func ListFromActivity(activity pub.Activity, app core.App, actor *core.Record) ( } iri := l.ID.String() + + // Own content must never be ingested as if it were remote (see TrailFromActivity). + if IsLocalIRI(iri) { + if !actor.GetBool("isLocal") { + return nil, fmt.Errorf("refusing remote activity referencing local list %q", iri) + } + listURL, parseErr := url.Parse(iri) + if parseErr != nil { + return nil, parseErr + } + return app.FindRecordById("lists", path.Base(listURL.Path)) + } + var record *core.Record if actor.GetBool(("isLocal")) { listURL, parseErr := url.Parse(iri) From b304e9975a993815f22d2bb4ab1e09f9fee0210d Mon Sep 17 00:00:00 2001 From: slothful-vassal <89943360+slothful-vassal@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:47:04 +0200 Subject: [PATCH 11/26] fix polyline db field size (#1047) --- .../1778583700_updated_trails_polyline_max.go | 36 +++++++++++++++++++ db/util/polyline.go | 6 ++++ 2 files changed, 42 insertions(+) create mode 100644 db/migrations/1778583700_updated_trails_polyline_max.go diff --git a/db/migrations/1778583700_updated_trails_polyline_max.go b/db/migrations/1778583700_updated_trails_polyline_max.go new file mode 100644 index 00000000..dc077eb4 --- /dev/null +++ b/db/migrations/1778583700_updated_trails_polyline_max.go @@ -0,0 +1,36 @@ +package migrations + +import ( + "pocketbase/util" + + "github.com/pocketbase/pocketbase/core" + m "github.com/pocketbase/pocketbase/migrations" +) + +func init() { + m.Register(func(app core.App) error { + collection, err := app.FindCollectionByNameOrId("e864strfxo14pm4") + if err != nil { + return err + } + + field, ok := collection.Fields.GetByName("polyline").(*core.TextField) + if ok { + field.Max = util.PolylineMaxLength + } + + return app.Save(collection) + }, func(app core.App) error { + collection, err := app.FindCollectionByNameOrId("e864strfxo14pm4") + if err != nil { + return err + } + + field, ok := collection.Fields.GetByName("polyline").(*core.TextField) + if ok { + field.Max = 0 + } + + return app.Save(collection) + }) +} diff --git a/db/util/polyline.go b/db/util/polyline.go index 4b782909..291a740e 100644 --- a/db/util/polyline.go +++ b/db/util/polyline.go @@ -10,6 +10,8 @@ import ( "github.com/twpayne/go-polyline" ) +const PolylineMaxLength = 5 * 1024 * 1024 + func ComputePolyline(app core.App, r *core.Record) (string, error) { gpxPath := r.GetString("gpx") if len(gpxPath) == 0 { @@ -56,6 +58,10 @@ func SavePolyline(app core.App, r *core.Record) error { if err != nil { return err } + // Encoded polylines are ASCII-only, so byte length matches character length. + if len(encoded) > PolylineMaxLength { + return fmt.Errorf("polyline exceeds maximum length of %d characters", PolylineMaxLength) + } r.Set("polyline", encoded) if err := app.UnsafeWithoutHooks().Save(r); err != nil { return fmt.Errorf("save trail polyline: %w", err) From 14ebaa0d047db805b283c9f8f3dde79fe43c1d44 Mon Sep 17 00:00:00 2001 From: slothful-vassal <89943360+slothful-vassal@users.noreply.github.com> Date: Thu, 4 Jun 2026 20:47:16 +0200 Subject: [PATCH 12/26] fix waypoint actor in ingetrations (#1049) --- db/integrations/komoot/komoot.go | 6 +++--- db/integrations/strava/strava.go | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/db/integrations/komoot/komoot.go b/db/integrations/komoot/komoot.go index afed1803..6c0e0cb1 100644 --- a/db/integrations/komoot/komoot.go +++ b/db/integrations/komoot/komoot.go @@ -228,7 +228,7 @@ func syncTrailWithTours(app core.App, client meilisearch.ServiceManager, ctx con app.Logger().Warn(fmt.Sprintf("Unable to create trail for tour '%s': %v", tour.Name, err)) continue } - err = createWaypointsFromTour(app, detailedTour, user, trailid) + err = createWaypointsFromTour(app, detailedTour, actor.Id, trailid) if err != nil { app.Logger().Warn(fmt.Sprintf("Unable to create waypoints for tour '%s': %v", tour.Name, err)) continue @@ -358,7 +358,7 @@ func createTrailFromTour(app core.App, k *KomootApi, detailedTour *DetailedKomoo return trailid, nil } -func createWaypointsFromTour(app core.App, tour *DetailedKomootTour, user string, trailid string) error { +func createWaypointsFromTour(app core.App, tour *DetailedKomootTour, actor string, trailid string) error { collection, err := app.FindCollectionByNameOrId("waypoints") if err != nil { return err @@ -392,7 +392,7 @@ func createWaypointsFromTour(app core.App, tour *DetailedKomootTour, user string "lat": wpLat, "lon": wpLon, "icon": "circle", - "author": user, + "author": actor, "distance_from_start": 0, "trail": trailid, }) diff --git a/db/integrations/strava/strava.go b/db/integrations/strava/strava.go index fcfc468b..25605f7a 100644 --- a/db/integrations/strava/strava.go +++ b/db/integrations/strava/strava.go @@ -276,7 +276,7 @@ func syncTrailsWithRoutes(app core.App, client meilisearch.ServiceManager, ctx c app.Logger().Warn(fmt.Sprintf("Unable to create trail for route '%s': %v", route.Name, err)) continue } - err = createWaypointsFromRoute(app, route, user, trailid) + err = createWaypointsFromRoute(app, route, actor.Id, trailid) if err != nil { app.Logger().Warn(fmt.Sprintf("Unable to create waypoints for route '%s': %v", route.Name, err)) continue @@ -406,7 +406,7 @@ func createTrailFromRoute(app core.App, route StravaRoute, gpx *filesystem.File, return trailid, err } -func createWaypointsFromRoute(app core.App, route StravaRoute, user string, trailid string) error { +func createWaypointsFromRoute(app core.App, route StravaRoute, actor string, trailid string) error { collection, err := app.FindCollectionByNameOrId("waypoints") if err != nil { return err @@ -420,7 +420,7 @@ func createWaypointsFromRoute(app core.App, route StravaRoute, user string, trai record.Set("lat", wp.Latlng[0]) record.Set("lon", wp.Latlng[1]) record.Set("icon", "circle") - record.Set("author", user) + record.Set("author", actor) record.Set("distance_from_start", wp.DistanceIntoRoute) record.Set("trail", trailid) From 44cd092065b04ae3db66e4e6387823178bf813ec Mon Sep 17 00:00:00 2001 From: slothful-vassal <89943360+slothful-vassal@users.noreply.github.com> Date: Sat, 6 Jun 2026 11:56:12 +0200 Subject: [PATCH 13/26] Improve trail planning: option to re-order route anchors (#1007) * trail anchor list added * add search location card to extend route endpoint in drawing mode * add POI popup endpoint action in route drawing mode * fix anchor stats, improve anchor list entries and refine route marker behavior * fix map marker spinner * docs updated * fix spinner when delete an anchor * further improvements * fix adding first anchor from POI * optimize drag handle area --------- Co-authored-by: Flomp --- docs/src/content/docs/use/create-a-trail.md | 4 +- web/src/lib/components/base/dropdown.svelte | 3 + .../trail/map_with_elevation_maplibre.svelte | 5 +- .../lib/components/trail/route_editor.svelte | 18 +- .../components/trail/trail_anchor_list.svelte | 702 ++++++++++++++++++ .../components/trail/trail_dropdown.svelte | 2 + .../components/trail/trail_info_panel.svelte | 319 +++++++- web/src/lib/i18n/locales/cs.json | 1 + web/src/lib/i18n/locales/de.json | 6 + web/src/lib/i18n/locales/en.json | 6 + web/src/lib/i18n/locales/es.json | 1 + web/src/lib/i18n/locales/eu.json | 1 + web/src/lib/i18n/locales/fr.json | 1 + web/src/lib/i18n/locales/hu.json | 1 + web/src/lib/i18n/locales/it.json | 1 + web/src/lib/i18n/locales/nl.json | 1 + web/src/lib/i18n/locales/no.json | 1 + web/src/lib/i18n/locales/pl.json | 1 + web/src/lib/i18n/locales/pt.json | 1 + web/src/lib/i18n/locales/ru.json | 1 + web/src/lib/i18n/locales/zh.json | 1 + web/src/lib/models/api/trail_schema.ts | 2 +- web/src/lib/stores/search_store.ts | 94 ++- web/src/lib/stores/trail_store.ts | 54 +- web/src/lib/stores/valhalla_store.svelte.ts | 35 +- web/src/lib/util/format_util.ts | 16 +- web/src/lib/util/maplibre_util.ts | 61 +- web/src/lib/util/valhalla_anchor_util.ts | 60 ++ .../maplibre-layer-manager.ts | 8 +- .../maplibre-layer-manager/overpass-layer.ts | 14 +- web/src/routes/api/v1/trail/upload/+server.ts | 7 +- web/src/routes/trail/edit/[id]/+page.svelte | 556 +++++++++++--- 32 files changed, 1792 insertions(+), 192 deletions(-) create mode 100644 web/src/lib/components/trail/trail_anchor_list.svelte create mode 100644 web/src/lib/util/valhalla_anchor_util.ts diff --git a/docs/src/content/docs/use/create-a-trail.md b/docs/src/content/docs/use/create-a-trail.md index f9a7d5c1..ca4fd441 100644 --- a/docs/src/content/docs/use/create-a-trail.md +++ b/docs/src/content/docs/use/create-a-trail.md @@ -37,6 +37,9 @@ Click the **Draw a route** button to manually define a route on the map. While i - Click on the map to place waypoints - wanderer will automatically route between points using the [Valhalla routing engine](https://github.com/valhalla/valhalla) - You can drag points to reposition them +- The anchor list next to the map shows start, intermediate, and finish points with segment distance and elevation stats +- Hover an item in the anchor list to highlight its marker on the map +- Reorder intermediate anchors from the list to adjust the route sequence - Use the top-left menu to change routing mode (e.g. walking, cycling) - To remove a point, click on it and then click the red trash icon @@ -105,4 +108,3 @@ To learn more about summit logs visit the [dedicated section](/use/summit-logs) ## Step 6: Save the trail When you're done, click to persist your trail to the database. This will also re-index it for search and display it in your trail list. - diff --git a/web/src/lib/components/base/dropdown.svelte b/web/src/lib/components/base/dropdown.svelte index 3b64c82d..6de69796 100644 --- a/web/src/lib/components/base/dropdown.svelte +++ b/web/src/lib/components/base/dropdown.svelte @@ -4,6 +4,7 @@ value: any; icon?: string; separator?: boolean; + danger?: boolean; }; @@ -138,6 +139,8 @@ {:else} + {/each} + + + diff --git a/web/src/lib/components/trail/trail_dropdown.svelte b/web/src/lib/components/trail/trail_dropdown.svelte index 3e711f63..8bfb051c 100644 --- a/web/src/lib/components/trail/trail_dropdown.svelte +++ b/web/src/lib/components/trail/trail_dropdown.svelte @@ -289,6 +289,7 @@ text: $_("delete"), value: "delete", icon: "trash", + danger: true, }, ] : []), @@ -413,6 +414,7 @@ text: $_("delete"), value: "delete", icon: "trash", + danger: true, }, ] : []), diff --git a/web/src/lib/components/trail/trail_info_panel.svelte b/web/src/lib/components/trail/trail_info_panel.svelte index 2e203190..60e339ec 100644 --- a/web/src/lib/components/trail/trail_info_panel.svelte +++ b/web/src/lib/components/trail/trail_info_panel.svelte @@ -4,6 +4,7 @@ import Tabs from "$lib/components/base/tabs.svelte"; import TrailDropdown, { type MergeResult } from "$lib/components/trail/trail_dropdown.svelte"; import { Comment } from "$lib/models/comment"; + import { Tag } from "$lib/models/tag"; import type { Trail } from "$lib/models/trail"; import { @@ -57,7 +58,12 @@ import { handleFromRecordWithIRI } from "$lib/util/activitypub_util"; import LikeButton from "./like_button.svelte"; import Editor from "../base/editor.svelte"; - import { trails_update } from "$lib/stores/trail_store"; + import { + trails_update, + trails_update_metadata, + } from "$lib/stores/trail_store"; + import Combobox, { type ComboboxItem } from "../base/combobox.svelte"; + import { tags_index } from "$lib/stores/tag_store"; interface Props { initTrail: Trail; @@ -87,15 +93,16 @@ ...($currentUser ? [$_("comment", { values: { n: 2 } })] : []), ]; - const trailIsShared = - (trail.expand?.trail_share_via_trail?.length ?? 0) > 0; + const trailIsShared = $derived( + (trail.expand?.trail_share_via_trail?.length ?? 0) > 0, + ); let gallery: PhotoGallery; let newComment: Comment = $state({ text: "", author: "", - trail: untrack(() => handle) + "/" + (trail.id ?? ""), + trail: untrack(() => `${handle}/${trail.id ?? ""}`), }); let commentsLoading: boolean = $state(untrack(() => activeTab == 2)); @@ -106,6 +113,26 @@ let summitLogCreateLoading: boolean = $state(false); let fullDescription: boolean = $state(false); + let metadataSaving: boolean = $state(false); + let editingName: boolean = $state(false); + let editingDescription: boolean = $state(false); + let editingTags: boolean = $state(false); + let nameDraft: string = $state(""); + let descriptionDraft: string = $state(""); + let tagDraftItems: ComboboxItem[] = $state([]); + let tagItems: ComboboxItem[] = $state([]); + + const canEditTrail = $derived( + Boolean( + $currentUser && + (trail.author === $currentUser.actor || + trail.expand?.trail_share_via_trail?.some( + (share) => + share.permission === "edit" && + share.actor === $currentUser.actor, + )), + ), + ); onMount(async () => {}); @@ -289,6 +316,133 @@ const updatedTrail: Trail = { ...trail }; await trails_update(trail, updatedTrail); } + + function cloneTrail(value: Trail): Trail { + return JSON.parse(JSON.stringify(value)); + } + + function mergeTrailUpdate(previousTrail: Trail, updatedTrail: Trail): Trail { + return { + ...previousTrail, + ...updatedTrail, + expand: { + ...previousTrail.expand, + ...updatedTrail.expand, + author: previousTrail.expand?.author, + trail_like_via_trail: + previousTrail.expand?.trail_like_via_trail, + }, + }; + } + + async function saveTrailMetadata(update: (nextTrail: Trail) => void) { + if (!canEditTrail || metadataSaving) { + return false; + } + + metadataSaving = true; + const oldTrail = cloneTrail(trail); + const nextTrail = cloneTrail(trail); + nextTrail.expand ??= {}; + nextTrail.tags = [...(trail.tags ?? [])]; + update(nextTrail); + const tagsChanged = + JSON.stringify(nextTrail.expand?.tags ?? []) !== + JSON.stringify(oldTrail.expand?.tags ?? []); + + try { + const updatedTrail = await trails_update_metadata(oldTrail, { + name: + nextTrail.name !== oldTrail.name + ? nextTrail.name + : undefined, + description: + nextTrail.description !== oldTrail.description + ? nextTrail.description + : undefined, + expand: tagsChanged ? { tags: nextTrail.expand?.tags } : undefined, + }); + trail = mergeTrailUpdate(trail, updatedTrail); + show_toast({ + icon: "check", + type: "success", + text: $_("trail-saved-successfully"), + }); + return true; + } catch (e) { + console.error(e); + show_toast({ + icon: "close", + type: "error", + text: $_("error-saving-trail"), + }); + return false; + } finally { + metadataSaving = false; + } + } + + function startNameEdit() { + nameDraft = trail.name; + editingName = true; + } + + async function saveNameEdit() { + const name = nameDraft.trim(); + if (!name) { + return; + } + const saved = await saveTrailMetadata((nextTrail) => { + nextTrail.name = name; + }); + editingName = !saved; + } + + function startDescriptionEdit() { + descriptionDraft = trail.description ?? ""; + editingDescription = true; + } + + async function saveDescriptionEdit() { + const saved = await saveTrailMetadata((nextTrail) => { + nextTrail.description = descriptionDraft; + }); + editingDescription = !saved; + if (saved) { + fullDescription = true; + } + } + + function getTrailTagItems() { + return ( + trail.expand?.tags?.map((tag) => ({ + text: tag.name, + value: tag, + })) ?? [] + ); + } + + function startTagsEdit() { + tagDraftItems = getTrailTagItems(); + editingTags = true; + } + + async function searchTags(q: string) { + const result = await tags_index(q); + tagItems = result.items.map((tag) => ({ + text: tag.name, + value: tag, + })); + } + + async function saveTagsEdit() { + const saved = await saveTrailMetadata((nextTrail) => { + nextTrail.expand!.tags = tagDraftItems.map((item) => + item.value ? item.value : new Tag(item.text), + ); + }); + editingTags = !saved; + }
- {#if trail.expand?.tags && trail.expand.tags.length > 0} -
+ {#if editingTags} +
+ +
+ + +
+
+ {:else if trail.expand?.tags && trail.expand.tags.length > 0} +
{#each trail.expand.tags as tag} {/each} + {#if canEditTrail} + + {/if}
+ {:else if canEditTrail} + {/if} {#if (trail.public || trailIsShared) && $currentUser}
-

- {trail.name} -

+ {#if editingName} +
+ { + if (e.key === "Enter") { + void saveNameEdit(); + } else if (e.key === "Escape") { + editingName = false; + } + }} + /> +
+ + +
+
+ {:else} +
+

+ {trail.name} +

+ {#if canEditTrail} + + {/if} +
+ {/if} {#if trail.date}
{new Date(trail.date).toLocaleDateString( @@ -514,10 +761,44 @@ class:xl:grid-cols-[1fr_18rem]={mode == "overview"} >
-

- {$_("description")} -

- {#if trail.description?.length} +
+

+ {$_("description")} +

+ {#if canEditTrail && !editingDescription} + + {/if} +
+ {#if editingDescription} +
+ +
+ + +
+
+ {:else if trail.description?.length}
diff --git a/web/src/lib/i18n/locales/cs.json b/web/src/lib/i18n/locales/cs.json index 6ddfbd92..7cf90ee5 100644 --- a/web/src/lib/i18n/locales/cs.json +++ b/web/src/lib/i18n/locales/cs.json @@ -373,6 +373,7 @@ "road": "Silnice", "route": "{n, plural, =1 {Trasa} few {Trasy} other {Tras}}", "route-point": "Bod trasy", + "add-as-endpoint": "Add as endpoint", "russian": "Ruština", "save": "Uložit", "save-list": "Uložit seznam", diff --git a/web/src/lib/i18n/locales/de.json b/web/src/lib/i18n/locales/de.json index 6cc6ea82..3a312a83 100644 --- a/web/src/lib/i18n/locales/de.json +++ b/web/src/lib/i18n/locales/de.json @@ -102,6 +102,7 @@ "creation-date": "Erstellungsdatum", "crop": "Zuschneiden", "cross": "Querfeldein", + "cumulative": "Kumulativ", "current-password": "Aktuelles Passwort", "cycling": "Radfahren", "cycling-speed": "Radfahrgeschwindigkeit", @@ -116,6 +117,7 @@ "delete-linked-trails": "Verknüpfte Routen löschen", "delete-list-confirm": "Möchtest Du diese Liste wirklich löschen? Die Routen in der Liste sind danach weiterhin verfügbar.", "delete-summit-log-confirm": "Möchtest du diesen Gipfelbuch-Eintrag wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.", + "delete-route-point": "Routenpunkt löschen", "delete-trail-confirm": "Möchtest Du diese Route wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.", "describe-your-trail": "Beschreibe deine Route", "description": "Beschreibung", @@ -292,6 +294,7 @@ "moderate": "Mittel", "more": "weitere", "more-route-settings": "Weitere Routen-Einstellungen", + "move-route-point": "Routenpunkt verschieben", "mountain": "Berg", "mountain-pass": "Bergpass", "must-be-at-least-n-characters-long": "Muss mindestens {n} Zeichen lang sein", @@ -391,10 +394,13 @@ "required": "Pflichtfeld", "reset": "Zurücksetzen", "reset-password": "Passwort zurücksetzen", + "reset-route": "Route zurücksetzen", "reverse-direction": "Richtung umkehren", "road": "Straße", "route": "{n, plural, =1 {Route} other {Routen}}", "route-point": "Punkt auf Route", + "reset-route-confirm": "Die aktuelle Route wird entfernt. Trail-Details, Fotos, Listen und andere Metadaten bleiben erhalten.", + "add-as-endpoint": "Als Endpunkt hinzufügen", "russian": "Russisch", "save": "Speichern", "save-list": "Liste speichern", diff --git a/web/src/lib/i18n/locales/en.json b/web/src/lib/i18n/locales/en.json index 34f3186e..52d26b58 100644 --- a/web/src/lib/i18n/locales/en.json +++ b/web/src/lib/i18n/locales/en.json @@ -102,6 +102,7 @@ "creation-date": "Creation date", "crop": "Crop", "cross": "Cross", + "cumulative": "Cumulative", "current-password": "Current password", "cycling": "Cycling", "cycling-speed": "Cycling Speed", @@ -116,6 +117,7 @@ "delete-linked-trails": "Delete linked trails", "delete-list-confirm": "Do you really want to delete this list? The trails in the list will still be available.", "delete-summit-log-confirm": "Do you really want to delete this summit log? This action cannot be undone.", + "delete-route-point": "Delete route point", "delete-trail-confirm": "Do you really want to delete this trail? This action cannot be undone.", "describe-your-trail": "Describe your trail", "description": "Description", @@ -292,6 +294,7 @@ "moderate": "Moderate", "more": "More", "more-route-settings": "More route settings", + "move-route-point": "Move route point", "mountain": "Mountain", "mountain-pass": "Mountain pass", "must-be-at-least-n-characters-long": "Must be at least {n} characters long", @@ -391,10 +394,13 @@ "required": "Required", "reset": "Reset", "reset-password": "Reset Password", + "reset-route": "Reset route", "reverse-direction": "Reverse direction", "road": "Road", "route": "{n, plural, =1 {Route} other {Routes}}", "route-point": "Route Point", + "reset-route-confirm": "The current route will be removed. Trail details, photos, lists and other metadata will be kept.", + "add-as-endpoint": "Add as endpoint", "russian": "Russian", "save": "Save", "save-list": "Save List", diff --git a/web/src/lib/i18n/locales/es.json b/web/src/lib/i18n/locales/es.json index 17401acf..fb3675bd 100644 --- a/web/src/lib/i18n/locales/es.json +++ b/web/src/lib/i18n/locales/es.json @@ -373,6 +373,7 @@ "road": "Carretera", "route": "{n, plural, one {}=1 {Ruta} other {Rutas}}", "route-point": "Punto de ruta", + "add-as-endpoint": "Add as endpoint", "russian": "Ruso", "save": "Guardar", "save-list": "Guardar Lista", diff --git a/web/src/lib/i18n/locales/eu.json b/web/src/lib/i18n/locales/eu.json index e95dcd0e..065448aa 100644 --- a/web/src/lib/i18n/locales/eu.json +++ b/web/src/lib/i18n/locales/eu.json @@ -373,6 +373,7 @@ "road": "Errepidea", "route": "{n, plural, one {}=1 {ibilbide} other {ibilbide}}", "route-point": "Ibilbideko puntua", + "add-as-endpoint": "Add as endpoint", "russian": "Errusiera", "save": "Gorde", "save-list": "Gorde zerrenda", diff --git a/web/src/lib/i18n/locales/fr.json b/web/src/lib/i18n/locales/fr.json index 4bcb3ff1..1449efa3 100644 --- a/web/src/lib/i18n/locales/fr.json +++ b/web/src/lib/i18n/locales/fr.json @@ -373,6 +373,7 @@ "road": "Route", "route": "{n, plural, =1 {Itinéraire} other {Itinéraires}}", "route-point": "Étape", + "add-as-endpoint": "Add as endpoint", "russian": "Russe", "save": "Sauvegarder", "save-list": "Sauvegarder la liste", diff --git a/web/src/lib/i18n/locales/hu.json b/web/src/lib/i18n/locales/hu.json index 9c98d71b..24f37bb0 100644 --- a/web/src/lib/i18n/locales/hu.json +++ b/web/src/lib/i18n/locales/hu.json @@ -373,6 +373,7 @@ "road": "Road", "route": "{n, plural, =1 {Route} other {Routes}}", "route-point": "Route Point", + "add-as-endpoint": "Add as endpoint", "russian": "Russian", "save": "Mentés", "save-list": "Save List", diff --git a/web/src/lib/i18n/locales/it.json b/web/src/lib/i18n/locales/it.json index 8593d836..544140cb 100644 --- a/web/src/lib/i18n/locales/it.json +++ b/web/src/lib/i18n/locales/it.json @@ -373,6 +373,7 @@ "road": "Road", "route": "{n, plural, =1 {Route} other {Routes}}", "route-point": "Route Point", + "add-as-endpoint": "Add as endpoint", "russian": "Russian", "save": "Salva", "save-list": "Salta Lista", diff --git a/web/src/lib/i18n/locales/nl.json b/web/src/lib/i18n/locales/nl.json index 463e5d22..05bd37f0 100644 --- a/web/src/lib/i18n/locales/nl.json +++ b/web/src/lib/i18n/locales/nl.json @@ -373,6 +373,7 @@ "road": "Weg", "route": "{n, plural,=1 {Tocht} other {Tochten}}", "route-point": "Routepunt", + "add-as-endpoint": "Add as endpoint", "russian": "Russisch", "save": "Bewaren", "save-list": "Bewaar lijst", diff --git a/web/src/lib/i18n/locales/no.json b/web/src/lib/i18n/locales/no.json index 3423732a..73c498a3 100644 --- a/web/src/lib/i18n/locales/no.json +++ b/web/src/lib/i18n/locales/no.json @@ -373,6 +373,7 @@ "road": "Vei", "route": "{n, plural, =1 {Rute} other {Ruter}}", "route-point": "Rutepunkt", + "add-as-endpoint": "Add as endpoint", "russian": "Russisk", "save": "Lagre", "save-list": "Lagre liste", diff --git a/web/src/lib/i18n/locales/pl.json b/web/src/lib/i18n/locales/pl.json index 28193240..151143f5 100644 --- a/web/src/lib/i18n/locales/pl.json +++ b/web/src/lib/i18n/locales/pl.json @@ -373,6 +373,7 @@ "road": "Droga", "route": "{n, plural,=1 {Trasa} other {Trasy}}", "route-point": "Punkt trasy", + "add-as-endpoint": "Add as endpoint", "russian": "Russian", "save": "Zapisz", "save-list": "Zapisz listę", diff --git a/web/src/lib/i18n/locales/pt.json b/web/src/lib/i18n/locales/pt.json index 76817687..7cfb3723 100644 --- a/web/src/lib/i18n/locales/pt.json +++ b/web/src/lib/i18n/locales/pt.json @@ -373,6 +373,7 @@ "road": "Road", "route": "{n, plural, =1 {Route} other {Routes}}", "route-point": "Route Point", + "add-as-endpoint": "Add as endpoint", "russian": "Russian", "save": "Guardar", "save-list": "Gravar lista", diff --git a/web/src/lib/i18n/locales/ru.json b/web/src/lib/i18n/locales/ru.json index f1621fa4..a8e37d1f 100644 --- a/web/src/lib/i18n/locales/ru.json +++ b/web/src/lib/i18n/locales/ru.json @@ -373,6 +373,7 @@ "road": "Шоссе", "route": "{n, plural, =1 {Маршрут} other {Маршрутов}}", "route-point": "Точка маршрута", + "add-as-endpoint": "Add as endpoint", "russian": "Русский", "save": "Сохранить", "save-list": "Сохранить список", diff --git a/web/src/lib/i18n/locales/zh.json b/web/src/lib/i18n/locales/zh.json index 4d589fee..81e6f181 100644 --- a/web/src/lib/i18n/locales/zh.json +++ b/web/src/lib/i18n/locales/zh.json @@ -373,6 +373,7 @@ "road": "道路", "route": "{n, plural, =1 {Route} other {Routes}}", "route-point": "路线点", + "add-as-endpoint": "Add as endpoint", "russian": "Russian", "save": "保存", "save-list": "保存列表", diff --git a/web/src/lib/models/api/trail_schema.ts b/web/src/lib/models/api/trail_schema.ts index 30d52a8e..609b6380 100644 --- a/web/src/lib/models/api/trail_schema.ts +++ b/web/src/lib/models/api/trail_schema.ts @@ -45,7 +45,7 @@ const TrailUpdateSchema = z.object({ "photos-": z.string().optional(), "photos+": z.string().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(), category: z.string().optional(), tags: z.array(z.string()).optional(), gpx: z.string().optional(), diff --git a/web/src/lib/stores/search_store.ts b/web/src/lib/stores/search_store.ts index a25a1971..c583422b 100644 --- a/web/src/lib/stores/search_store.ts +++ b/web/src/lib/stores/search_store.ts @@ -128,20 +128,46 @@ export async function searchLocations(q: string, limit?: number, f: (url: Reques })) } -async function fetchGeocoding(path: string, params: URLSearchParams, f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch): Promise { +async function fetchGeocoding(path: string, params: URLSearchParams, f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch, signal?: AbortSignal): Promise { const query = params.toString(); const url = query.length ? `/api/v1/geocoding/${path}?${query}` : `/api/v1/geocoding/${path}`; - return await f(url); + return await f(url, signal ? { signal } : undefined); } -export async function searchLocationReverse(lat: number, lon: number, f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch) { +type ReverseGeocodingOptions = { + includeRoad?: boolean; + signal?: AbortSignal; +} + +export type ReverseLocationResult = { + label: string; + fullLabel: string; + country: string; +} + +export type FetchFunction = (url: RequestInfo | URL, config?: RequestInit) => Promise; + +export async function searchLocationReverse( + lat: number, + lon: number, + options: ReverseGeocodingOptions = {}, + f: FetchFunction = fetch, +) { + const location = await searchLocationReverseStructured(lat, lon, options, f); + return location?.fullLabel ?? ""; +} + +export async function searchLocationReverseStructured( + lat: number, + lon: number, + options: ReverseGeocodingOptions = {}, + f: FetchFunction = fetch, +): Promise { const params = new URLSearchParams({ - lat: String(lat), - lon: String(lon), - format: "geojson", - addressdetails: "1", - }); - const r = await fetchGeocoding("reverse", params, f); + lat: String(lat), + lon: String(lon), + }); + const r = await fetchGeocoding("reverse", params, f, options.signal); if (!r.ok) { const response = await r.json(); throw new APIError(r.status, response.message, response.detail) @@ -149,30 +175,52 @@ export async function searchLocationReverse(lat: number, lon: number, f: (url: R const response: NominatimResponse = await r.json(); if (response.features?.at(0)?.properties.address) { - return getLocationDescription(response.features[0].properties.address) + return getReverseLocationResult(response.features[0].properties.address, options); } - return "" + return null } -function getLocationDescription(address: Address) { - let description = "" +function getReverseLocationResult( + address: Address, + options: ReverseGeocodingOptions = {}, +): ReverseLocationResult { + const country = address.country ?? ""; + const label = getLocationDescription(address, { ...options, includeCountry: false }); + const fullLabel = getLocationDescription(address, options); - if (address.country) { - description += address.country; - } - if (address.state) { - description = `${address.state}, ` + description + return { + label: label || fullLabel, + fullLabel, + country, + }; +} + +function getLocationDescription( + address: Address, + options: ReverseGeocodingOptions & { includeCountry?: boolean } = {}, +) { + const parts = []; + + if (options.includeRoad && address.road) { + parts.push(address.road); } if (address.city) { - description = `${address.city}, ` + description + parts.push(address.city); } else if (address.town) { - description = `${address.town}, ` + description + parts.push(address.town); } else if (address.hamlet) { - description = `${address.hamlet}, ` + description + parts.push(address.hamlet); } else if (address.village) { - description = `${address.village}, ` + description + parts.push(address.village); } - return description; + if (address.state) { + parts.push(address.state); + } + if (options.includeCountry !== false && address.country) { + parts.push(address.country); + } + + return parts.join(", "); } export async function searchMulti(options: MultiSearchParams): Promise[]> { diff --git a/web/src/lib/stores/trail_store.ts b/web/src/lib/stores/trail_store.ts index ca4278dd..628236db 100644 --- a/web/src/lib/stores/trail_store.ts +++ b/web/src/lib/stores/trail_store.ts @@ -334,9 +334,11 @@ export async function trails_update(oldTrail: Trail, newTrail: Trail, photos?: F } - let r = await fetch(`/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", - }), { + }); + + let r = await fetch(updateUrl, { method: 'POST', body: formData, }) @@ -360,6 +362,54 @@ export async function trails_update(oldTrail: Trail, newTrail: Trail, photos?: F return model; } +export async function trails_update_metadata( + currentTrail: Trail, + patch: Pick, "name" | "description" | "tags"> & { + expand?: Pick, "tags">; + }, +) { + const tagIds: string[] | undefined = patch.expand?.tags + ? [] + : patch.tags; + + for (const tag of patch.expand?.tags ?? []) { + if (!tag.id) { + const model = await tags_create(tag); + tagIds!.push(model.id!); + } else { + tagIds!.push(tag.id); + } + } + + const searchParams = new URLSearchParams( + tagIds !== undefined ? { expand: "tags" } : {}, + ); + const query = searchParams.toString(); + const url = `/api/v1/trail/${currentTrail.id}${query ? `?${query}` : ""}`; + const payload = { + name: patch.name ?? currentTrail.name, + ...(patch.description !== undefined + ? { description: patch.description } + : {}), + ...(tagIds !== undefined ? { tags: tagIds } : {}), + }; + + const r = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (!r.ok) { + const response = await r.json(); + throw new APIError(r.status, response.message, response.detail); + } + + const model: Trail = await r.json(); + trail.set(model); + + return model; +} export async function trails_delete(trail: Trail) { const r = await fetch('/api/v1/trail/' + trail.id, { diff --git a/web/src/lib/stores/valhalla_store.svelte.ts b/web/src/lib/stores/valhalla_store.svelte.ts index ca1453a6..567b0b28 100644 --- a/web/src/lib/stores/valhalla_store.svelte.ts +++ b/web/src/lib/stores/valhalla_store.svelte.ts @@ -6,6 +6,7 @@ import Waypoint from "$lib/models/gpx/waypoint"; import { type RoutingOptions, type ValhallaAnchor, type ValhallaHeightResponse, type ValhallaRouteResponse } from "$lib/models/valhalla"; import { APIError } from "$lib/util/api_util"; import { decodePolyline, encodePolyline } from "$lib/util/polyline_util"; +import { renderValhallaAnchorMarker, valhallaAnchorTitle } from "$lib/util/valhalla_anchor_util"; import { applyChangeset, diff, revertChangeset, type Changeset } from 'json-diff-ts'; import type { LngLat } from "maplibre-gl"; import { _ } from "svelte-i18n"; @@ -16,8 +17,8 @@ const emtpyTrack = new Track({ trkseg: [] }) class ValhallaStore { route: GPX = $state(new GPX({ trk: [emtpyTrack] })); anchors: ValhallaAnchor[] = $state([]); - undoStack: { delta: Changeset, reverseDelta: Changeset }[] = $state([]); - redoStack: { delta: Changeset, reverseDelta: Changeset }[] = $state([]); + undoStack: { delta: Changeset, reverseDelta: Changeset, anchorsBefore?: ValhallaAnchor[], anchorsAfter?: ValhallaAnchor[] }[] = $state([]); + redoStack: { delta: Changeset, reverseDelta: Changeset, anchorsBefore?: ValhallaAnchor[], anchorsAfter?: ValhallaAnchor[] }[] = $state([]); } export const valhallaStore = new ValhallaStore(); @@ -179,14 +180,21 @@ export function reverseRoute() { if (!a.marker) { return; } - a.marker.getElement().textContent = "" + (i + 1); + renderValhallaAnchorMarker( + a.marker.getElement(), + i, + valhallaStore.anchors.length, + ); const anchorPopupHeading = a.marker .getPopup() ._content.getElementsByTagName("h5")[0]; if (anchorPopupHeading) { - anchorPopupHeading.textContent = - get(_)("route-point") + " #" + (i + 1); + anchorPopupHeading.textContent = valhallaAnchorTitle( + i, + valhallaStore.anchors.length, + get(_), + ); } }); } @@ -235,8 +243,8 @@ export async function splitSegment(index: number, pos: LngLat) { const firstSegmentPoints = [...points.slice(0, bestSplitIndex), intersectionPoint]; const secondSegmentPoints = [intersectionPoint, ...points.slice(bestSplitIndex)]; - editRoute(index, firstSegmentPoints) - insertIntoRoute(secondSegmentPoints, index + 1) + await editRoute(index, firstSegmentPoints) + await insertIntoRoute(secondSegmentPoints, index + 1) } @@ -263,21 +271,30 @@ export function normalizeRouteTime() { export function undo() { const historyItem = valhallaStore.undoStack.pop() if (!historyItem) { - return + return undefined } valhallaStore.redoStack.push(historyItem) valhallaStore.route = applyChangeset(valhallaStore.route, historyItem.reverseDelta); valhallaStore.route.features = valhallaStore.route.getTotals(); + return historyItem; +} + +export function revertRouteChange() { + const historyItem = valhallaStore.undoStack.pop(); + if (!historyItem) return; + valhallaStore.route = applyChangeset(valhallaStore.route, historyItem.reverseDelta); + valhallaStore.route.features = valhallaStore.route.getTotals(); } export function redo() { const historyItem = valhallaStore.redoStack.pop() if (!historyItem) { - return + return undefined } valhallaStore.undoStack.push(historyItem) valhallaStore.route = applyChangeset(valhallaStore.route, historyItem.delta); valhallaStore.route.features = valhallaStore.route.getTotals(); + return historyItem; } diff --git a/web/src/lib/util/format_util.ts b/web/src/lib/util/format_util.ts index 2947ae9e..cec9ab3f 100644 --- a/web/src/lib/util/format_util.ts +++ b/web/src/lib/util/format_util.ts @@ -13,7 +13,10 @@ export function formatTimeHHMM(seconds?: number) { return (h < 10 ? "0" : "") + h.toString() + "h " + (m < 10 ? "0" : "") + m.toString() + "m"; } -export function formatDistance(meters?: number) { +export function formatDistance( + meters?: number, + options: { compact?: boolean } = {}, +) { if (meters === undefined) { return "-"; } @@ -22,7 +25,14 @@ export function formatDistance(meters?: number) { if (unit == "metric") { if (meters >= 1000) { - return `${(meters / 1000).toFixed(2)} km` + const kilometers = meters / 1000; + if (options.compact && kilometers >= 100) { + return `${kilometers.toFixed(0)} km`; + } + if (options.compact && kilometers >= 10) { + return `${kilometers.toFixed(1)} km`; + } + return `${kilometers.toFixed(2)} km` } else { return meters % 1 == 0 ? `${meters} m` : `${Math.round(meters)} m`; } @@ -148,4 +158,4 @@ export function formatHTMLAsText(html?: string) { // Trim the result return text.trim(); -} \ No newline at end of file +} diff --git a/web/src/lib/util/maplibre_util.ts b/web/src/lib/util/maplibre_util.ts index df5b20ef..90b2c3de 100644 --- a/web/src/lib/util/maplibre_util.ts +++ b/web/src/lib/util/maplibre_util.ts @@ -76,13 +76,12 @@ export function createMarkerFromWaypoint(waypoint: Waypoint, onDragEnd?: (marker return marker; } -export function createAnchorMarker(lat: number, lon: number, index: number, +export function createAnchorMarker(lat: number, lon: number, onDeleteClick: () => void, onLoopClick: () => void, onDragStart: (event: Event) => void, onDragEnd: (event: Event) => void): FontawesomeMarker { const anchorElement = document.createElement("span") - anchorElement.className = "route-anchor cursor-pointer rounded-full w-6 h-6 border border-black text-center bg-primary text-white" - anchorElement.textContent = "" + index + anchorElement.className = "route-anchor cursor-pointer flex items-center justify-center rounded-full w-6 h-6 border border-black bg-primary text-white" const marker = new M.Marker( { draggable: true, @@ -96,7 +95,7 @@ export function createAnchorMarker(lat: number, lon: number, index: number, popupContent.className = "py-3 pl-3" const anchorH = document.createElement("h5") anchorH.classList.add("text-base", "font-medium"); - anchorH.textContent = get(_)("route-point") + " #" + index; + anchorH.textContent = get(_)("route-point"); const deleteButton = document.createElement("button"); deleteButton.className = "btn-secondary w-full mt-2 text-sm"; @@ -260,19 +259,33 @@ export function createPopupFromTrail(trail: Trail) { return popup; } -export function createOverpassPopup(feature: GeoJSON.Feature, coordinates: GeoJSON.Position) { +export type OverpassPopupAction = { + label: string; + onClick: () => void; + disabled?: boolean; + helperText?: string; + icon?: string; +}; + +export function createOverpassPopup( + feature: GeoJSON.Feature, + coordinates: GeoJSON.Position, + action?: OverpassPopupAction, +) { const tags: Record = JSON.parse(feature.properties?.tags); const name = tags.name ?? get(_)(feature.properties?.query) ?? "?" const popupContainer = document.createElement("div"); - popupContainer.className = "p-4" + popupContainer.className = "p-4 relative" + + const indent = action ? "pl-12 " : ""; const popupHeading = document.createElement("h1"); - popupHeading.className = "font-medium text-lg" + popupHeading.className = indent + "font-medium text-lg" popupHeading.textContent = name; const coordinateSubtitle = document.createElement("p") - coordinateSubtitle.className = "text-gray-500" + coordinateSubtitle.className = indent + "text-gray-500" coordinateSubtitle.textContent = `${coordinates[0].toFixed(6)}, ${coordinates[1].toFixed(6)}` popupContainer.appendChild(popupHeading) @@ -295,6 +308,36 @@ export function createOverpassPopup(feature: GeoJSON.Feature, coordinates: GeoJS popupContainer.appendChild(tagsGrid) + if (action) { + const actionButton = document.createElement("button"); + actionButton.type = "button"; + actionButton.className = + "flex h-9 w-9 items-center justify-center absolute top-4 left-4 rounded-full p-0 text-xl text-content hover:bg-secondary-hover disabled:opacity-40 disabled:cursor-not-allowed"; + actionButton.disabled = action.disabled ?? false; + actionButton.setAttribute("aria-label", action.label); + actionButton.setAttribute("title", action.label); + + const iconElement = document.createElement("i"); + iconElement.className = (action.icon ?? "fa fa-flag-checkered"); + iconElement.setAttribute("aria-hidden", "true"); + actionButton.appendChild(iconElement); + + actionButton.addEventListener("click", () => { + if (!actionButton.disabled) { + action.onClick(); + } + }); + + popupContainer.appendChild(actionButton); + + if (action.helperText) { + const helper = document.createElement("p"); + helper.className = "text-xs text-gray-500 mt-2"; + helper.textContent = action.helperText; + popupContainer.appendChild(helper); + } + } + return popupContainer; } @@ -336,4 +379,4 @@ export function calculateScaleFactor(map: M.Map) { const scaleFactor = realWorldMetersPer100Pixels / screenMetersPer100Pixels return scaleFactor -} \ No newline at end of file +} diff --git a/web/src/lib/util/valhalla_anchor_util.ts b/web/src/lib/util/valhalla_anchor_util.ts new file mode 100644 index 00000000..4d9437a9 --- /dev/null +++ b/web/src/lib/util/valhalla_anchor_util.ts @@ -0,0 +1,60 @@ +interface ValhallaAnchorDisplay { + icon: string; + number: number | null; + titleKey: "start" | "finish" | "route-point"; +} + +export function valhallaAnchorDisplay(index: number, total: number): ValhallaAnchorDisplay { + if (index === 0) { + return { + icon: "fa-bullseye", + number: null, + titleKey: "start", + }; + } + + if (index === total - 1) { + return { + icon: "fa-flag-checkered", + number: null, + titleKey: "finish", + }; + } + + return { + icon: "fa-location-dot", + number: index, + titleKey: "route-point", + }; +} + +export function valhallaAnchorTitle( + index: number, + total: number, + translate: (key: string) => string, +) { + const display = valhallaAnchorDisplay(index, total); + if (display.number === null) { + return translate(display.titleKey); + } + + return `${translate(display.titleKey)} #${display.number}`; +} + +export function renderValhallaAnchorMarker( + element: HTMLElement, + index: number, + total: number, +) { + const display = valhallaAnchorDisplay(index, total); + element.replaceChildren(); + + if (display.number !== null) { + element.textContent = `${display.number}`; + return; + } + + const icon = document.createElement("i"); + icon.classList.add("fa", display.icon); + element.appendChild(icon); +} diff --git a/web/src/lib/vendor/maplibre-layer-manager/maplibre-layer-manager.ts b/web/src/lib/vendor/maplibre-layer-manager/maplibre-layer-manager.ts index c34aadf4..11f8b8a8 100644 --- a/web/src/lib/vendor/maplibre-layer-manager/maplibre-layer-manager.ts +++ b/web/src/lib/vendor/maplibre-layer-manager/maplibre-layer-manager.ts @@ -2,7 +2,7 @@ import * as M from "maplibre-gl"; import { DebugLayer } from "./debug-layer"; import { baseMapStyles, defaultMapState, type BaseLayer, type MapState } from "./layers"; import { OverlayLayer } from "./overlay-layer"; -import { OverpassLayer } from "./overpass-layer"; +import { OverpassLayer, type OverpassPopupActionFactory } from "./overpass-layer"; @@ -11,9 +11,11 @@ export class LayerManager { state!: MapState; layers: Record = {}; private addedListeners: Set = new Set(); + private overpassActionFactory?: OverpassPopupActionFactory; - constructor(map: M.Map) { + constructor(map: M.Map, options?: { overpassActionFactory?: OverpassPopupActionFactory }) { this.map = map; + this.overpassActionFactory = options?.overpassActionFactory; const storedMapState = localStorage.getItem("map-state") if (storedMapState) { @@ -40,7 +42,7 @@ export class LayerManager { try { this.update(this.state, true); - const overpassLayer = new OverpassLayer(this.map) + const overpassLayer = new OverpassLayer(this.map, this.overpassActionFactory) const debugLayer = new DebugLayer() this.addLayer("overpass", overpassLayer) diff --git a/web/src/lib/vendor/maplibre-layer-manager/overpass-layer.ts b/web/src/lib/vendor/maplibre-layer-manager/overpass-layer.ts index d7e7e805..49704a80 100644 --- a/web/src/lib/vendor/maplibre-layer-manager/overpass-layer.ts +++ b/web/src/lib/vendor/maplibre-layer-manager/overpass-layer.ts @@ -4,13 +4,18 @@ * License: MIT */ -import { createOverpassPopup } from "$lib/util/maplibre_util"; +import { createOverpassPopup, type OverpassPopupAction } from "$lib/util/maplibre_util"; import * as M from "maplibre-gl"; import { type LngLatBounds, type MapMouseEvent, type StyleSpecification } from "maplibre-gl"; import { pois, type BaseLayer, type MapState } from "./layers"; import type { OverpassResponse } from "./types"; import { env } from '$env/dynamic/public' +export type OverpassPopupActionFactory = ( + feature: GeoJSON.Feature, + coordinates: GeoJSON.Position, +) => OverpassPopupAction | null | undefined; + export class OverpassLayer implements BaseLayer { private overpassApiURL: string = "/api/v1/overpass/interpreter"; @@ -61,9 +66,11 @@ export class OverpassLayer implements BaseLayer { private popup: M.Popup; private map: M.Map; private currentPopupCoordinates: GeoJSON.Position | null = null + private popupActionFactory?: OverpassPopupActionFactory; - constructor(map: M.Map) { + constructor(map: M.Map, popupActionFactory?: OverpassPopupActionFactory) { this.map = map; + this.popupActionFactory = popupActionFactory; this.popup = new M.Popup() .setMaxWidth("420px") } @@ -71,7 +78,8 @@ export class OverpassLayer implements BaseLayer { private openPopup(e: MapMouseEvent) { const features = (e as any).features as GeoJSON.Feature[]; const point = features[0].geometry as GeoJSON.Point; - const content = createOverpassPopup(features[0], point.coordinates); + const action = this.popupActionFactory?.(features[0], point.coordinates); + const content = createOverpassPopup(features[0], point.coordinates, action ?? undefined); this.currentPopupCoordinates = point.coordinates; this.popup diff --git a/web/src/routes/api/v1/trail/upload/+server.ts b/web/src/routes/api/v1/trail/upload/+server.ts index 46702a1e..623aa16d 100644 --- a/web/src/routes/api/v1/trail/upload/+server.ts +++ b/web/src/routes/api/v1/trail/upload/+server.ts @@ -76,7 +76,12 @@ export async function PUT(event: RequestEvent) { if (trail.lat && trail.lon) { try { - const location = await searchLocationReverse(trail.lat, trail.lon, event.fetch) + const location = await searchLocationReverse( + trail.lat, + trail.lon, + {}, + event.fetch, + ) trail.location ??= location; } catch (e: any) { console.warn("Reverse geocoding failed during upload", e); diff --git a/web/src/routes/trail/edit/[id]/+page.svelte b/web/src/routes/trail/edit/[id]/+page.svelte index c2decf89..ab69fe26 100644 --- a/web/src/routes/trail/edit/[id]/+page.svelte +++ b/web/src/routes/trail/edit/[id]/+page.svelte @@ -9,6 +9,7 @@ import SummitLogModal from "$lib/components/summit_log/summit_log_modal.svelte"; import MapWithElevationMaplibre from "$lib/components/trail/map_with_elevation_maplibre.svelte"; import PhotoPicker from "$lib/components/trail/photo_picker.svelte"; + import TrailAnchorList from "$lib/components/trail/trail_anchor_list.svelte"; import WaypointCard from "$lib/components/waypoint/waypoint_card.svelte"; import WaypointMergeModal, { type WaypointMergeOptions, @@ -23,6 +24,8 @@ import { SummitLog } from "$lib/models/summit_log"; import { Trail } from "$lib/models/trail"; import type { RoutingOptions, ValhallaAnchor } from "$lib/models/valhalla"; + import type { OverpassPopupActionFactory } from "$lib/vendor/maplibre-layer-manager/overpass-layer"; + import { type OverpassPopupAction } from "$lib/util/maplibre_util"; import { Waypoint } from "$lib/models/waypoint"; import { categories } from "$lib/stores/category_store"; import { @@ -52,6 +55,7 @@ splitSegment, undo, redo, + revertRouteChange, clearUndoRedoStack, } from "$lib/stores/valhalla_store.svelte.js"; import { waypoint } from "$lib/stores/waypoint_store"; @@ -91,6 +95,10 @@ createEditTrailMapPopup, FontawesomeMarker, } from "$lib/util/maplibre_util"; + import { + renderValhallaAnchorMarker, + valhallaAnchorTitle, + } from "$lib/util/valhalla_anchor_util"; import EXIF from "$lib/vendor/exif-js/exif.js"; import { validator } from "@felte/validator-zod"; import cryptoRandomString from "crypto-random-string"; @@ -117,6 +125,7 @@ let summitLogModal: SummitLogModal; let listSelectModal: ListSearchModal; let markTrailAsCompletedModal: ConfirmModal; + let replaceRouteModal: ConfirmModal; let loading = $state(false); @@ -127,6 +136,8 @@ let gpxFile: File | Blob | null = null; let drawingActive = $state(false); + let replacingRoute = $state(false); + let isNewTrail = $derived(page.params.id === "new"); function routeCalculationErrorText(error: unknown) { if (error instanceof Error && error.message) { @@ -142,6 +153,7 @@ | undefined = $state(); let searchDropdownItems: SearchItem[] = $state([]); + let selectedSearchLocation: SearchItem | null = $state(null); let cropStartMarker: FontawesomeMarker; let cropEndMarker: FontawesomeMarker; @@ -171,6 +183,8 @@ autoRouting: true, modeOfTransport: "pedestrian", }); + let routeAnchorListUpdating = $state(false); + let routeSegments = $state([]); let savedAtLeastOnce = $state(false); @@ -308,10 +322,42 @@ initRouteAnchors(gpx); updateTrailOnMap(); + + if (!isNewTrail) { + startDrawing(); + } } } }); + function fitCurrentRoute(initializedMap: M.Map) { + const bounds = valhallaStore.route.toGeoJSON().bbox; + if (!bounds) { + return; + } + + initializedMap.fitBounds(bounds as M.LngLatBoundsLike, { + animate: false, + padding: { + top: 16, + left: 16, + right: 16, + bottom: 16, + }, + }); + } + + function handleMapInit(initializedMap: M.Map) { + if (drawingActive) { + for (const anchor of valhallaStore.anchors) { + anchor.marker?.addTo(initializedMap); + } + } + if (!isNewTrail) { + fitCurrentRoute(initializedMap); + } + } + function openFileBrowser() { document.getElementById("fileInput")!.click(); } @@ -325,7 +371,10 @@ return; } - clearWaypoints(); + const replaceExistingRoute = replacingRoute && !isNewTrail; + if (!replaceExistingRoute) { + clearWaypoints(); + } clearAnchors(); clearUndoRedoStack(); clearRoute(); @@ -339,18 +388,29 @@ try { const prevId = $formData.id; const parseResult = await gpx2trail(gpxData, selectedFile.name); - setFields(parseResult.trail); + if (replaceExistingRoute) { + setFields("lat", parseResult.trail.lat); + setFields("lon", parseResult.trail.lon); + setFields("distance", parseResult.trail.distance); + setFields("duration", parseResult.trail.duration); + setFields("elevation_gain", parseResult.trail.elevation_gain); + setFields("elevation_loss", parseResult.trail.elevation_loss); + } else { + setFields(parseResult.trail); + } $formData.id = prevId ?? cryptoRandomString({ length: 15 }); $formData.expand!.gpx_data = gpxData; - setFields( - "category", - page.data.settings.category || $categories[0].id, - ); - setFields( - "public", - page.data.settings?.privacy?.trails === "public", - ); + if (!replaceExistingRoute) { + setFields( + "category", + page.data.settings.category || $categories[0].id, + ); + setFields( + "public", + page.data.settings?.privacy?.trails === "public", + ); + } // const log = new SummitLog(parseResult.trail.date as string, { // distance: $formData.distance, @@ -383,6 +443,13 @@ } setRoute(parseResult.gpx); initRouteAnchors(parseResult.gpx); + replacingRoute = false; + if (!isNewTrail) { + startDrawing(); + if (map) { + fitCurrentRoute(map); + } + } updateTrailOnMap(); } catch (e) { @@ -745,17 +812,23 @@ } function startDrawing() { + drawingActive = true; + routeSegments = [...(valhallaStore.route.trk?.at(0)?.trkseg ?? [])]; + if (!map) { return; } - drawingActive = true; - if (!valhallaStore.route.trk?.at(0)?.trkseg?.at(0)?.trkpt?.length) { - } + for (const anchor of valhallaStore.anchors) { anchor.marker?.addTo(map); } } + function startReplacementDrawing() { + replacingRoute = false; + startDrawing(); + } + async function stopDrawing() { drawingActive = false; for (const anchor of valhallaStore.anchors) { @@ -816,8 +889,13 @@ async function addAnchorAndRecalculate(lat: number, lon: number) { const previousAnchor = valhallaStore.anchors[valhallaStore.anchors.length - 1]; + if (!previousAnchor) { + addAnchor(lat, lon, 0); + return; + } + const anchor = addAnchor(lat, lon, valhallaStore.anchors.length); - const markerText = startAnchorLoading(anchor); + startAnchorLoading(anchor); try { const routeWaypoints = await calculateRouteBetween( previousAnchor.lat, @@ -826,9 +904,9 @@ lon, routingOptions, ); - insertIntoRoute(routeWaypoints); - updateTrailWithRouteData(); + await insertIntoRoute(routeWaypoints); normalizeRouteTime(); + updateTrailWithRouteData(); } catch (e) { console.error(e); show_toast({ @@ -837,7 +915,7 @@ type: "error", }); } finally { - stopAnchorLoading(anchor, markerText); + stopAnchorLoading(anchor); } } @@ -855,7 +933,6 @@ const marker = createAnchorMarker( lat, lon, - index + 1, () => { removeAnchor( valhallaStore.anchors.findIndex((a) => a.id == anchor.id), @@ -896,6 +973,7 @@ } anchor.marker = marker; valhallaStore.anchors.splice(index, 0, anchor); + refreshAnchorLabels(Math.max(0, index - 1)); return anchor; } @@ -903,18 +981,15 @@ function startAnchorLoading(anchor: ValhallaAnchor) { const markerIcon = anchor.marker?.getElement(); if (!markerIcon) { - return null; + return; } markerIcon.classList.add("spinner", "spinner-light", "spinner-small"); - const savedMarkerNumber = markerIcon.textContent; - markerIcon.textContent = ""; - - return savedMarkerNumber; + markerIcon.replaceChildren(); } - function stopAnchorLoading(anchor: ValhallaAnchor, index: string | null) { + function stopAnchorLoading(anchor: ValhallaAnchor) { const markerIcon = anchor.marker?.getElement(); - if (!markerIcon || !index) { + if (!markerIcon) { return; } markerIcon.classList.remove( @@ -922,7 +997,47 @@ "spinner-light", "spinner-small", ); - markerIcon.textContent = index; + refreshAnchorLabel(valhallaStore.anchors.findIndex((a) => a.id === anchor.id)); + } + + function refreshAnchorLabel(index: number) { + if (index < 0) { + return; + } + + const anchor = valhallaStore.anchors[index]; + const markerIcon = anchor.marker?.getElement(); + if (markerIcon) { + renderValhallaAnchorMarker( + markerIcon, + index, + valhallaStore.anchors.length, + ); + anchor + .marker!.getPopup() + ._content.getElementsByTagName("h5")[0].textContent = + valhallaAnchorTitle(index, valhallaStore.anchors.length, $_); + } + } + + function refreshAnchorLabels(startIndex: number = 0) { + for (let i = startIndex; i < valhallaStore.anchors.length; i++) { + refreshAnchorLabel(i); + } + } + + function highlightAnchorMarker(index: number | null) { + for (const anchor of valhallaStore.anchors) { + anchor.marker?.getElement().classList.remove("anchor-list-highlight"); + } + + if (index === null) { + return; + } + + valhallaStore.anchors[index]?.marker + ?.getElement() + .classList.add("anchor-list-highlight"); } async function removeAnchor(anchorIndex: number) { @@ -931,20 +1046,7 @@ } valhallaStore.anchors[anchorIndex]?.marker?.remove(); valhallaStore.anchors.splice(anchorIndex, 1); - for (let i = anchorIndex; i < valhallaStore.anchors.length; i++) { - const anchor = valhallaStore.anchors[i]; - const markerIcon = anchor.marker?.getElement(); - if (markerIcon) { - const markerText = markerIcon.textContent ?? "0"; - const markerIndex = parseInt(markerText); - const newIndex = markerIndex - 1; - markerIcon.textContent = newIndex + ""; - anchor - .marker!.getPopup() - ._content.getElementsByTagName("h5")[0].textContent = - $_("route-point") + " #" + newIndex; - } - } + refreshAnchorLabels(anchorIndex); if (anchorIndex == 0) { deleteFromRoute(anchorIndex); if ($formData.expand?.gpx_data) { @@ -955,24 +1057,141 @@ updateTrailWithRouteData(); } else { deleteFromRoute(anchorIndex - 1); - await recalculateRoute(anchorIndex); + await recalculateRoute(anchorIndex, [anchorIndex - 1, anchorIndex]); } } - async function recalculateRoute(anchorIndex: number) { - const markerText = startAnchorLoading( - valhallaStore.anchors[anchorIndex], - ); + async function recalculateRouteFromAnchors(fromIndex: number, toIndex: number) { + const anchors = valhallaStore.anchors; + const N = anchors.length; + if (N < 2) { + setRoute(new GPX({ trk: [new Track({ trkseg: [] })] }), true); + updateTrailWithRouteData(); + return; + } + + // Segments not touching the moved anchor are reused (shifted by ±1); only the 2–3 boundary segments are recalculated. + const oldSegments = valhallaStore.route.trk?.at(0)?.trkseg ?? []; + const newSegments: (TrackSegment | null)[] = new Array(N - 1).fill(null); + const toRecalc: number[] = []; + + if (fromIndex < toIndex) { + for (let i = 0; i < fromIndex - 1; i++) newSegments[i] = oldSegments[i] ?? null; + for (let i = fromIndex; i <= toIndex - 2; i++) newSegments[i] = oldSegments[i + 1] ?? null; + for (let i = toIndex + 1; i < N - 1; i++) newSegments[i] = oldSegments[i] ?? null; + if (fromIndex > 0) toRecalc.push(fromIndex - 1); + toRecalc.push(toIndex - 1); + if (toIndex < N - 1) toRecalc.push(toIndex); + } else { + for (let i = 0; i < toIndex - 1; i++) newSegments[i] = oldSegments[i] ?? null; + for (let i = toIndex + 1; i <= fromIndex - 1; i++) newSegments[i] = oldSegments[i - 1] ?? null; + for (let i = fromIndex + 1; i < N - 1; i++) newSegments[i] = oldSegments[i] ?? null; + if (toIndex > 0) toRecalc.push(toIndex - 1); + toRecalc.push(toIndex); + if (fromIndex < N - 1) toRecalc.push(fromIndex); + } + + const loadingAnchorIndexes = [...new Set(toRecalc.flatMap((i) => [i, i + 1]))]; + for (const index of loadingAnchorIndexes) { + startAnchorLoading(anchors[index]); + } + try { + const recalcResults = await Promise.all( + toRecalc.map((i) => + calculateRouteBetween( + anchors[i].lat, + anchors[i].lon, + anchors[i + 1].lat, + anchors[i + 1].lon, + routingOptions, + ).then((pts) => ({ i, segment: new TrackSegment({ trkpt: pts }) })), + ), + ); + + for (const { i, segment } of recalcResults) { + newSegments[i] = segment; + } + + setRoute( + new GPX({ trk: [new Track({ trkseg: newSegments.filter((s): s is TrackSegment => s !== null) })] }), + true, + ); + normalizeRouteTime(); + updateTrailWithRouteData(); + } finally { + for (const index of loadingAnchorIndexes) { + stopAnchorLoading(anchors[index]); + } + } + } + + async function moveAnchor(fromIndex: number, toIndex: number) { + if ( + routeAnchorListUpdating || + !drawingActive || + fromIndex === toIndex || + fromIndex < 0 || + toIndex < 0 || + fromIndex >= valhallaStore.anchors.length || + toIndex >= valhallaStore.anchors.length + ) { + return; + } + + const previousAnchors = [...valhallaStore.anchors]; + const previousUndoStackLength = valhallaStore.undoStack.length; + const [anchor] = valhallaStore.anchors.splice(fromIndex, 1); + valhallaStore.anchors.splice(toIndex, 0, anchor); + refreshAnchorLabels(Math.min(fromIndex, toIndex)); + + routeAnchorListUpdating = true; + try { + await recalculateRouteFromAnchors(fromIndex, toIndex); + const lastEntry = valhallaStore.undoStack.at(-1); + if (lastEntry && valhallaStore.undoStack.length > previousUndoStackLength) { + lastEntry.anchorsBefore = previousAnchors; + lastEntry.anchorsAfter = [...valhallaStore.anchors]; + } + } catch (e) { + while (valhallaStore.undoStack.length > previousUndoStackLength) { + revertRouteChange(); + } + routeSegments = [...(valhallaStore.route.trk?.at(0)?.trkseg ?? [])]; + valhallaStore.anchors = previousAnchors; + refreshAnchorLabels(Math.min(fromIndex, toIndex)); + console.error(e); + show_toast({ + text: routeCalculationErrorText(e), + icon: "close", + type: "error", + }); + } finally { + routeAnchorListUpdating = false; + } + } + + async function recalculateRoute(anchorIndex: number, loadingAnchorIndexes = [anchorIndex]) { const anchor = valhallaStore.anchors[anchorIndex]; if (!anchor) { return; } + const anchors = valhallaStore.anchors; + const loadingAnchors = [ + ...new Set( + loadingAnchorIndexes + .map((index) => anchors[index]) + .filter((anchor): anchor is ValhallaAnchor => Boolean(anchor)), + ), + ]; + for (const loadingAnchor of loadingAnchors) { + startAnchorLoading(loadingAnchor); + } let nextRouteSegment; let previousRouteSegment; try { - if (anchorIndex < valhallaStore.anchors.length - 1) { - const nextAnchor = valhallaStore.anchors[anchorIndex + 1]; + if (anchorIndex < anchors.length - 1) { + const nextAnchor = anchors[anchorIndex + 1]; nextRouteSegment = await calculateRouteBetween( anchor.lat, @@ -983,7 +1202,7 @@ ); } if (anchorIndex > 0) { - const previousAnchor = valhallaStore.anchors[anchorIndex - 1]; + const previousAnchor = anchors[anchorIndex - 1]; previousRouteSegment = await calculateRouteBetween( previousAnchor.lat, previousAnchor.lon, @@ -994,13 +1213,13 @@ } if (nextRouteSegment) { - editRoute(anchorIndex, nextRouteSegment); + await editRoute(anchorIndex, nextRouteSegment); } if (previousRouteSegment) { - editRoute(anchorIndex - 1, previousRouteSegment); + await editRoute(anchorIndex - 1, previousRouteSegment); } - updateTrailWithRouteData(); normalizeRouteTime(); + updateTrailWithRouteData(); } catch (e) { console.error(e); show_toast({ @@ -1009,7 +1228,9 @@ type: "error", }); } finally { - stopAnchorLoading(valhallaStore.anchors[anchorIndex], markerText); + for (const loadingAnchor of loadingAnchors) { + stopAnchorLoading(loadingAnchor); + } } } @@ -1025,8 +1246,7 @@ data.event.lngLat.lng, data.segment + 1, ); - const markerText = startAnchorLoading(anchor); - updateFollowingAnchors(data.segment); + startAnchorLoading(anchor); const previousAnchor = valhallaStore.anchors[data.segment]; const nextAnchor = valhallaStore.anchors[data.segment + 2]; @@ -1047,8 +1267,8 @@ routingOptions, ); - editRoute(data.segment, previousRouteSegment); - insertIntoRoute(nextRouteSegment, data.segment + 1); + await editRoute(data.segment, previousRouteSegment); + await insertIntoRoute(nextRouteSegment, data.segment + 1); normalizeRouteTime(); updateTrailWithRouteData(); } catch (e) { @@ -1059,24 +1279,7 @@ type: "error", }); } finally { - stopAnchorLoading(anchor, markerText); - } - } - - function updateFollowingAnchors(segment: number) { - for (let i = segment + 2; i < valhallaStore.anchors.length; i++) { - const anchor = valhallaStore.anchors[i]; - const markerIcon = anchor.marker?.getElement(); - if (markerIcon) { - const markerText = markerIcon.textContent ?? "0"; - const markerIndex = parseInt(markerText); - const newIndex = markerIndex + 1; - markerIcon.textContent = newIndex + ""; - anchor - .marker!.getPopup() - ._content.getElementsByTagName("h5")[0].textContent = - $_("route-point") + " #" + newIndex; - } + stopAnchorLoading(anchor); } } @@ -1090,8 +1293,7 @@ data.segment + 1, ); - splitSegment(data.segment, data.event.lngLat); - updateFollowingAnchors(data.segment); + await splitSegment(data.segment, data.event.lngLat); updateTrailWithRouteData(); } @@ -1107,6 +1309,22 @@ updateTrailWithRouteData(); } + function requestReplaceRoute() { + replaceRouteModal.openModal(); + } + + function replaceRoute() { + resetRoute(); + clearUndoRedoStack(); + gpxFile = null; + overwriteGPX = true; + replacingRoute = true; + drawingActive = false; + routeSegments = []; + $formData.expand!.gpx_data = undefined; + updateTrailWithRouteData(); + } + async function recalculateElevationData() { await recalculateHeight(); @@ -1228,6 +1446,7 @@ function updateTrailWithRouteData() { overwriteGPX = true; + routeSegments = [...(valhallaStore.route.trk?.at(0)?.trkseg ?? [])]; updateTotals(valhallaStore.route); if (!$formData.id) { @@ -1259,6 +1478,42 @@ zoom: 13, animate: false, }); + selectedSearchLocation = item; + } + + function clearSelectedSearchLocation() { + selectedSearchLocation = null; + } + + const buildPoiAnchorAction: OverpassPopupActionFactory = ( + _feature, + coordinates, + ) => { + const [lon, lat] = coordinates; + if (typeof lat !== "number" || typeof lon !== "number") { + return null; + } + if (!drawingActive) { + return null; + } + return { + label: $_("add-as-endpoint"), + icon: "fa fa-flag-checkered", + onClick: () => addAnchorAndRecalculate(lat, lon), + } satisfies OverpassPopupAction; + }; + + async function addSelectedLocationAsEndpoint() { + if (!selectedSearchLocation) { + return; + } + const { lat, lon } = selectedSearchLocation.value; + if (valhallaStore.anchors.length === 0) { + addAnchor(lat, lon, 0); + } else { + await addAnchorAndRecalculate(lat, lon); + } + selectedSearchLocation = null; } async function searchCities(q: string) { @@ -1460,16 +1715,26 @@ } function undoRouteEdit() { - undo(); - clearAnchors(); - initRouteAnchors(valhallaStore.route, true); + const entry = undo(); + if (entry?.anchorsBefore) { + valhallaStore.anchors = entry.anchorsBefore; + refreshAnchorLabels(); + } else { + clearAnchors(); + initRouteAnchors(valhallaStore.route, true); + } updateTrailWithRouteData(); } function redoRouteEdit() { - redo(); - clearAnchors(); - initRouteAnchors(valhallaStore.route, true); + const entry = redo(); + if (entry?.anchorsAfter) { + valhallaStore.anchors = entry.anchorsAfter; + refreshAnchorLabels(); + } else { + clearAnchors(); + initRouteAnchors(valhallaStore.route, true); + } updateTrailWithRouteData(); } @@ -1498,41 +1763,91 @@ placeholder="{$_('search-places')}..." items={searchDropdownItems} > + {#if selectedSearchLocation && drawingActive} +
+
+ +
+

+ {selectedSearchLocation.text} +

+ {#if selectedSearchLocation.description} +

+ {selectedSearchLocation.description} +

+ {/if} +
+ +
+
+ {/if}
-

{$_("pick-a-trail")}

- + {#if isNewTrail || replacingRoute} +

{$_("pick-a-trail")}

+ + {/if} + {#if drawingActive && valhallaStore.anchors.length} + + {/if} + {#if !drawingActive && (isNewTrail || replacingRoute)}

{$_("or")}
- {$formData.expand?.gpx_data + ? $_("upload-new-file") + : $_("upload-file")} + {/if} handleMapClick(target)} onsegmentclick={(data) => handleSegmentClick(data)} onsegmentdragend={(data) => handleSegmentDragEnd(data)} mapOptions={{ preserveDrawingBuffer: true }} + {buildPoiAnchorAction} >
@@ -1853,6 +2172,15 @@ bind:this={markTrailAsCompletedModal} onconfirm={markTrailAsCompleted} > + From f912a186a66824066ee557e7883c57d2c7147be5 Mon Sep 17 00:00:00 2001 From: Ole Date: Sat, 6 Jun 2026 12:08:13 +0200 Subject: [PATCH 14/26] Update remote_trail.go (#1002) Public Trails from users with private accounts currently show a 404. This fixes that Co-authored-by: slothful-vassal <89943360+slothful-vassal@users.noreply.github.com> --- db/routes/remote_trail.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/db/routes/remote_trail.go b/db/routes/remote_trail.go index c9a0fed6..07dc63a4 100644 --- a/db/routes/remote_trail.go +++ b/db/routes/remote_trail.go @@ -127,7 +127,10 @@ func RemoteTrailGet(e *core.RequestEvent) error { func findLocalTrailByRemoteInfo(e *core.RequestEvent, ctx context.Context, handle, trailID string) (*core.Record, error) { // 1. Get Actor to build the IRI actor, err := federation.GetActorByHandle(e.App, ctx, handle, false) - if err != nil { + if err != nil && !errors.Is(err, federation.ErrProfilePrivate) { + return nil, err + } + if actor == nil { return nil, err } From fc735130cce80ab8f46c86c2a279545b0cc1fd57 Mon Sep 17 00:00:00 2001 From: Flomp Date: Sun, 7 Jun 2026 12:20:13 +0200 Subject: [PATCH 15/26] Add IRI to local resources (#1046) * initial commit * add remote trail/list * fix errors * update to use new IRIs * use origin in migration instead * use UnsafeWithoutHooks * fix trail/list order of operations * fix remote trail/list fetch * fixes list document update * fix errors --------- Co-authored-by: Christian Beutel <> --- .gitignore | 5 +- db/federation/announce.go | 16 +-- db/federation/create.go | 27 +--- db/federation/delete.go | 8 +- db/federation/like.go | 9 +- db/federation/undo.go | 8 +- db/hooks/comments.go | 20 ++- db/hooks/list.go | 26 +++- db/hooks/summit_logs.go | 16 ++- db/hooks/trails.go | 28 +++- db/hooks/waypoint.go | 28 ++++ db/main.go | 2 + .../1780566579_add_iri_to_local_resources.go | 126 ++++++++++++++++++ db/routes/remote_trail.go | 8 +- db/routes/remote_trail_comment.go | 12 +- db/util/activitypub.go | 59 ++------ db/util/meilisearch.go | 2 +- web/src/lib/components/list/list_card.svelte | 2 +- web/src/lib/util/activitypub_util.ts | 2 +- 19 files changed, 273 insertions(+), 131 deletions(-) create mode 100644 db/hooks/waypoint.go create mode 100644 db/migrations/1780566579_add_iri_to_local_resources.go diff --git a/.gitignore b/.gitignore index 2f8389e2..276d58d5 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,7 @@ run.sh build*.sh start*.* -data*/ \ No newline at end of file +data*/ +.planning/ +.claude +CLAUDE.md \ No newline at end of file diff --git a/db/federation/announce.go b/db/federation/announce.go index 8a921c09..5188c518 100644 --- a/db/federation/announce.go +++ b/db/federation/announce.go @@ -3,9 +3,7 @@ package federation import ( "database/sql" "fmt" - "net/url" "os" - "path" "pocketbase/util" "strings" "time" @@ -180,12 +178,7 @@ func processTrailAnnounceActivity(app core.App, actor *core.Record, activity pub return err } } else { - trailUrl, err := url.Parse(activity.Object.GetID().String()) - if err != nil { - return err - } - trailId := path.Base(trailUrl.Path) - trail, err = app.FindRecordById("trails", trailId) + trail, err = app.FindFirstRecordByData("trails", "iri", activity.Object.GetID().String()) if err != nil { return err } @@ -245,12 +238,7 @@ func processListAnnounceActivity(app core.App, actor *core.Record, activity pub. return err } } else { - listUrl, err := url.Parse(activity.Object.GetID().String()) - if err != nil { - return err - } - listId := path.Base(listUrl.Path) - list, err = app.FindRecordById("trails", listId) + list, err = app.FindFirstRecordByData("lists", "iri", activity.Object.GetID().String()) if err != nil { return err } diff --git a/db/federation/create.go b/db/federation/create.go index 126349db..3751bec7 100644 --- a/db/federation/create.go +++ b/db/federation/create.go @@ -4,9 +4,7 @@ import ( "context" "database/sql" "fmt" - "net/url" "os" - "path" "strconv" "strings" "time" @@ -228,12 +226,7 @@ func CreateSummitLogActivity(app core.App, ctx context.Context, summitLog *core. } var trailIRI pub.IRI - if summitLogTrailAuthor.GetBool("isLocal") { - trailId := summitLog.GetString("trail") - trailIRI = pub.IRI(fmt.Sprintf("%s/api/v1/trail/%s", origin, trailId)) - } else { - trailIRI = pub.IRI(summitLogTrail.GetString("iri")) - } + trailIRI = pub.IRI(summitLogTrail.GetString("iri")) recordId := security.RandomStringWithAlphabet(core.DefaultIdLength, core.DefaultIdAlphabet) @@ -321,7 +314,7 @@ func CreateSummitLogActivity(app core.App, ctx context.Context, summitLog *core. logObject.Content = pub.NaturalLanguageValuesNew(pub.LangRefValueNew(pub.NilLangRef, summitLog.GetString("text"))) logObject.AttributedTo = pub.IRI(summitLogAuthor.GetString("iri")) logObject.Published = summitLog.GetDateTime("created").Time() - logObject.ID = pub.IRI(fmt.Sprintf("%s/api/v1/summit-log/%s", origin, summitLog.Id)) + logObject.ID = pub.IRI(summitLog.GetString("iri")) logObject.URL = pub.IRI(fmt.Sprintf("%s/trail/view/@%s/%s", origin, summitLogTrailAuthor.GetString("preferred_username"), summitLog.GetString("trail"))) logObject.InReplyTo = trailIRI logObject.Tag = tags @@ -512,14 +505,8 @@ func processCreateOrUpdateCommentActivity(activity pub.Activity, app core.App, a return fmt.Errorf("error processing comment: InReplyTo empty") } - trailUrl, err := url.Parse(commentObject.InReplyTo.GetLink().String()) - if err != nil { - return err - } - trailId := path.Base(trailUrl.Path) - var trail *core.Record - trail, err = app.FindFirstRecordByFilter("trails", "iri={:iri} || id={:id}", dbx.Params{"id": trailId, "iri": commentObject.InReplyTo.GetID().String()}) + trail, err = app.FindFirstRecordByData("trails", "iri", commentObject.InReplyTo.GetLink().String()) // if the trail is not present on this instance fetch it if err != nil { @@ -619,13 +606,7 @@ func processCreateOrUpdateSummitLogActivity(activity pub.Activity, app core.App, return err } - trailIRI, err := url.Parse(logObject.InReplyTo.GetID().String()) - if err != nil { - return err - } - trailId := path.Base(trailIRI.Path) - - trail, err := app.FindFirstRecordByFilter("trails", "iri={:iri} || id={:id}", dbx.Params{"id": trailId, "iri": logObject.InReplyTo.GetID().String()}) + trail, err := app.FindFirstRecordByData("trails", "iri", logObject.InReplyTo.GetID().String()) // if the trail is not present on this instance fetch it if err != nil { if err == sql.ErrNoRows { diff --git a/db/federation/delete.go b/db/federation/delete.go index 0c2283ae..f5d78f9c 100644 --- a/db/federation/delete.go +++ b/db/federation/delete.go @@ -39,7 +39,7 @@ func CreateTrailDeleteActivity(app core.App, r *core.Record) error { id := fmt.Sprintf("%s/api/v1/activitypub/activity/%s", origin, recordId) to := "https://www.w3.org/ns/activitystreams#Public" cc := author.GetString("iri") + "/followers" - object := fmt.Sprintf("%s/api/v1/trail/%s", origin, r.Id) + object := r.GetString("iri") record := core.NewRecord(collection) record.Set("id", recordId) @@ -118,7 +118,7 @@ func CreateCommentDeleteActivity(app core.App, client meilisearch.ServiceManager id := fmt.Sprintf("%s/api/v1/activitypub/activity/%s", origin, recordId) to := commentTrailAuthor.GetString("iri") - object := fmt.Sprintf("%s/api/v1/comment/%s", origin, r.Id) + object := r.GetString("iri") activity := pub.DeleteNew(pub.IRI(id), pub.IRI(object)) activity.Actor = pub.IRI(author.GetString("iri")) @@ -176,7 +176,7 @@ func CreateSummitLogDeleteActivity(app core.App, r *core.Record) error { id := fmt.Sprintf("%s/api/v1/activitypub/activity/%s", origin, recordId) to := summitLogTrailAuthor.GetString("iri") - object := fmt.Sprintf("%s/api/v1/summit-log/%s", origin, r.Id) + object := r.GetString("iri") cc := pub.ItemCollection{pub.IRI(author.GetString("iri") + "/followers")} activity := pub.DeleteNew(pub.IRI(id), pub.IRI(object)) @@ -248,7 +248,7 @@ func CreateListDeleteActivity(app core.App, r *core.Record) error { id := fmt.Sprintf("%s/api/v1/activitypub/activity/%s", origin, recordId) to := "https://www.w3.org/ns/activitystreams#Public" cc := author.GetString("iri") + "/followers" - object := fmt.Sprintf("%s/api/v1/list/%s", origin, r.Id) + object := r.GetString("iri") activity := pub.DeleteNew(pub.IRI(id), pub.IRI(object)) activity.Actor = pub.IRI(author.GetString("iri")) diff --git a/db/federation/like.go b/db/federation/like.go index cadd2409..2ae55fd0 100644 --- a/db/federation/like.go +++ b/db/federation/like.go @@ -3,7 +3,6 @@ package federation import ( "fmt" "os" - "path" "pocketbase/util" "time" @@ -36,11 +35,6 @@ func CreateLikeActivity(app core.App, like *core.Record) error { object := trail.GetString("iri") - if object == "" { - // trail is local - object = fmt.Sprintf("%s/api/v1/trail/%s", origin, trail.Id) - } - collection, err := app.FindCollectionByNameOrId("activitypub_activities") if err != nil { return err @@ -76,8 +70,7 @@ func ProcessLikeActivity(app core.App, actor *core.Record, activity pub.Activity return fmt.Errorf("ORIGIN not set") } - trailId := path.Base(activity.Object.GetID().String()) - trail, err := app.FindRecordById("trails", trailId) + trail, err := app.FindFirstRecordByData("trails", "iri", activity.Object.GetID().String()) if err != nil { return err } diff --git a/db/federation/undo.go b/db/federation/undo.go index d9d6f386..d7c3d35f 100644 --- a/db/federation/undo.go +++ b/db/federation/undo.go @@ -3,7 +3,6 @@ package federation import ( "fmt" "os" - "path" "time" pub "github.com/go-ap/activitypub" @@ -91,10 +90,6 @@ func CreateUnlikeActivity(app core.App, like *core.Record) error { } object := trail.GetString("iri") - if object == "" { - // trail is local - object = fmt.Sprintf("%s/api/v1/trail/%s", origin, trail.Id) - } // find the original follow activity likeActivityRecord, err := app.FindFirstRecordByFilter("activitypub_activities", "actor={:actor}&&object={:object}&&type={:type}", dbx.Params{"actor": actor.GetString("iri"), "object": object, "type": string(pub.LikeType)}) @@ -175,8 +170,7 @@ func processUnlikeActivity(app core.App, actor *core.Record, activity pub.Activi likeActivity := activity.Object.(*pub.Activity) - trailId := path.Base(likeActivity.Object.GetID().String()) - trail, err := app.FindRecordById("trails", trailId) + trail, err := app.FindFirstRecordByData("trails", "iri", likeActivity.Object.GetID().String()) if err != nil { return err } diff --git a/db/hooks/comments.go b/db/hooks/comments.go index ebd77353..8e544ae2 100644 --- a/db/hooks/comments.go +++ b/db/hooks/comments.go @@ -1,6 +1,8 @@ package hooks import ( + "fmt" + "os" "pocketbase/federation" "pocketbase/util" @@ -12,7 +14,23 @@ import ( func CreateCommentHandler() func(e *core.RecordRequestEvent) error { return func(e *core.RecordRequestEvent) error { - e.Next() + err := e.Next() + if err != nil { + return err + } + + // add local iri + origin := os.Getenv("ORIGIN") + if origin == "" { + return fmt.Errorf("ORIGIN not set") + } + if e.Record.GetString("iri") == "" { + e.Record.Set("iri", fmt.Sprintf("%s/api/v1/comment/%s", origin, e.Record.Id)) + } + err = e.App.UnsafeWithoutHooks().Save(e.Record) + if err != nil { + return err + } userActor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id) if err != nil { diff --git a/db/hooks/list.go b/db/hooks/list.go index 91eabd0c..b2651871 100644 --- a/db/hooks/list.go +++ b/db/hooks/list.go @@ -1,6 +1,8 @@ package hooks import ( + "fmt" + "os" "pocketbase/federation" "pocketbase/util" @@ -18,20 +20,32 @@ func CreateListHandler(client meilisearch.ServiceManager) func(e *core.RecordEve return err } + // add local iri + origin := os.Getenv("ORIGIN") + if origin == "" { + return fmt.Errorf("ORIGIN not set") + } + if e.Record.GetString("iri") == "" { + e.Record.Set("iri", fmt.Sprintf("%s/api/v1/list/%s", origin, e.Record.Id)) + if err = e.App.UnsafeWithoutHooks().Save(e.Record); err != nil { + return err + } + } + if err := util.IndexLists(e.App, []*core.Record{record}, client); err != nil { return err } + err = e.Next() + if err != nil { + return err + } + if !author.GetBool("isLocal") { // this happens if someone fetches a remote list // we create a stub list record for later reference // no need to create an activity for that - return e.Next() - } - - err = e.Next() - if err != nil { - return err + return nil } err = federation.CreateListActivity(e.App, e.Record, pub.CreateType) diff --git a/db/hooks/summit_logs.go b/db/hooks/summit_logs.go index a703f150..f830cc24 100644 --- a/db/hooks/summit_logs.go +++ b/db/hooks/summit_logs.go @@ -1,6 +1,8 @@ package hooks import ( + "fmt" + "os" "pocketbase/federation" "pocketbase/util" @@ -11,12 +13,24 @@ import ( func CreateSummitLogHandler(client meilisearch.ServiceManager) func(e *core.RecordRequestEvent) error { return func(e *core.RecordRequestEvent) error { - err := e.Next() if err != nil { return err } + // add local iri + origin := os.Getenv("ORIGIN") + if origin == "" { + return fmt.Errorf("ORIGIN not set") + } + if e.Record.GetString("iri") == "" { + e.Record.Set("iri", fmt.Sprintf("%s/api/v1/summit-log/%s", origin, e.Record.Id)) + } + err = e.App.UnsafeWithoutHooks().Save(e.Record) + if err != nil { + return err + } + userActor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id) if err != nil { return err diff --git a/db/hooks/trails.go b/db/hooks/trails.go index 40bcf4ce..2e6eb956 100644 --- a/db/hooks/trails.go +++ b/db/hooks/trails.go @@ -1,7 +1,9 @@ package hooks import ( + "fmt" "log" + "os" "pocketbase/federation" "pocketbase/util" "time" @@ -23,21 +25,35 @@ func CreateTrailHandler(client meilisearch.ServiceManager) func(e *core.RecordEv if err := util.SavePolyline(e.App, record); err != nil { log.Printf("failed to save polyline for trail %s: %v", record.Id, err) } + + // add local iri + origin := os.Getenv("ORIGIN") + if origin == "" { + return fmt.Errorf("ORIGIN not set") + } + if e.Record.GetString("iri") == "" { + e.Record.Set("iri", fmt.Sprintf("%s/api/v1/trail/%s", origin, e.Record.Id)) + if err = e.App.UnsafeWithoutHooks().Save(e.Record); err != nil { + return err + } + } + if err := util.IndexTrails(e.App, []*core.Record{record}, client); err != nil { return err } - if !userActor.GetBool("isLocal") { - // this happens if someone fetches a remote trail - // we create a stub trail record for later reference - // no need to create an activity for that - return e.Next() - } err = e.Next() if err != nil { return err } + if !userActor.GetBool("isLocal") { + // this happens if someone fetches a remote list + // we create a stub list record for later reference + // no need to create an activity for that + return nil + } + ctx, err := util.GetSafeActorContext(nil, userActor) if err != nil { diff --git a/db/hooks/waypoint.go b/db/hooks/waypoint.go new file mode 100644 index 00000000..8c2a7539 --- /dev/null +++ b/db/hooks/waypoint.go @@ -0,0 +1,28 @@ +package hooks + +import ( + "fmt" + "os" + + "github.com/pocketbase/pocketbase/core" +) + +func CreateWaypointHandler() func(e *core.RecordRequestEvent) error { + return func(e *core.RecordRequestEvent) error { + err := e.Next() + if err != nil { + return err + } + + // add local iri + origin := os.Getenv("ORIGIN") + if origin == "" { + return fmt.Errorf("ORIGIN not set") + } + if e.Record.GetString("iri") == "" { + e.Record.Set("iri", fmt.Sprintf("%s/api/v1/waypoint/%s", origin, e.Record.Id)) + } + + return e.App.UnsafeWithoutHooks().Save(e.Record) + } +} diff --git a/db/main.go b/db/main.go index 046455e7..0e201851 100644 --- a/db/main.go +++ b/db/main.go @@ -98,6 +98,8 @@ func setupEventHandlers(app *pocketbase.PocketBase, client meilisearch.ServiceMa app.OnRecordUpdateRequest("summit_logs").BindFunc(hooks.UpdateSummitLogHandler()) app.OnRecordDeleteRequest("summit_logs").BindFunc(hooks.DeleteSummitLogHandler(client)) + app.OnRecordCreateRequest("waypoints").BindFunc(hooks.CreateWaypointHandler()) + app.OnRecordCreateRequest("comments").BindFunc(hooks.CreateCommentHandler()) app.OnRecordUpdateRequest("comments").BindFunc(hooks.UpdateCommentHandler()) app.OnRecordDeleteRequest("comments").BindFunc(hooks.DeleteCommentHandler(client)) diff --git a/db/migrations/1780566579_add_iri_to_local_resources.go b/db/migrations/1780566579_add_iri_to_local_resources.go new file mode 100644 index 00000000..3711a6e0 --- /dev/null +++ b/db/migrations/1780566579_add_iri_to_local_resources.go @@ -0,0 +1,126 @@ +package migrations + +import ( + "fmt" + "os" + + "github.com/pocketbase/pocketbase/core" + m "github.com/pocketbase/pocketbase/migrations" +) + +func init() { + m.Register(func(app core.App) error { + origin := os.Getenv("ORIGIN") + if origin == "" { + return fmt.Errorf("ORIGIN not set") + } + + // Comments + comments, err := app.FindAllRecords("comments") + if err != nil { + return err + } + + for _, c := range comments { + iri := c.GetString("iri") + if iri != "" { + continue + } + iri = fmt.Sprintf("%s/api/v1/comment/%s", origin, c.Id) + c.Set("iri", iri) + + if err := app.Save(c); err != nil { + return err + } + } + // --- + + // Lists + lists, err := app.FindAllRecords("lists") + if err != nil { + return err + } + + for _, l := range lists { + iri := l.GetString("iri") + if iri != "" { + continue + } + + iri = fmt.Sprintf("%s/api/v1/list/%s", origin, l.Id) + l.Set("iri", iri) + + if err := app.UnsafeWithoutHooks().Save(l); err != nil { + return err + } + } + // --- + + // Summit Logs + summitLogs, err := app.FindAllRecords("summit_logs") + if err != nil { + return err + } + + for _, sl := range summitLogs { + iri := sl.GetString("iri") + if iri != "" { + continue + } + iri = fmt.Sprintf("%s/api/v1/summit-log/%s", origin, sl.Id) + sl.Set("iri", iri) + + if err := app.Save(sl); err != nil { + return err + } + } + // --- + + // Trails + trails, err := app.FindAllRecords("trails") + if err != nil { + return err + } + + for _, t := range trails { + iri := t.GetString("iri") + if iri != "" { + continue + } + + iri = fmt.Sprintf("%s/api/v1/trail/%s", origin, t.Id) + t.Set("iri", iri) + + if err := app.UnsafeWithoutHooks().Save(t); err != nil { + return err + } + } + // --- + + // Waypoints + + wps, err := app.FindAllRecords("waypoints") + if err != nil { + return err + } + + for _, wp := range wps { + iri := wp.GetString("iri") + if iri != "" { + continue + } + + iri = fmt.Sprintf("%s/api/v1/waypoint/%s", origin, wp.Id) + wp.Set("iri", iri) + + if err := app.Save(wp); err != nil { + return err + } + } + + return nil + }, func(app core.App) error { + + return nil + }) +} diff --git a/db/routes/remote_trail.go b/db/routes/remote_trail.go index 07dc63a4..3f9eeb84 100644 --- a/db/routes/remote_trail.go +++ b/db/routes/remote_trail.go @@ -9,7 +9,6 @@ import ( "net/http" "net/url" "os" - "path" "pocketbase/federation" "pocketbase/util" "strconv" @@ -350,13 +349,10 @@ func syncSummitLogs(txApp core.App, ctx context.Context, trail *core.Record, ori slID, _ := raw["id"].(string) iri, _ := raw["iri"].(string) if iri == "" { - iri = fmt.Sprintf("%s/api/v1/summit_logs/%s", origin, slID) + iri = fmt.Sprintf("%s/api/v1/summit-log/%s", origin, slID) } - remoteSummitLogUrl, _ := url.Parse(iri) - possibleLocalId := path.Base(remoteSummitLogUrl.Path) - - sl, _ := txApp.FindFirstRecordByFilter("summit_logs", "iri={:iri} || id={:id}", dbx.Params{"id": possibleLocalId, "iri": iri}) + sl, _ := txApp.FindFirstRecordByData("summit_logs", "iri", iri) if sl == nil { sl = core.NewRecord(col) } diff --git a/db/routes/remote_trail_comment.go b/db/routes/remote_trail_comment.go index f49c5cd6..1df4c468 100644 --- a/db/routes/remote_trail_comment.go +++ b/db/routes/remote_trail_comment.go @@ -39,8 +39,13 @@ func RemoteTrailCommentsList(e *core.RequestEvent) error { return err } + trailAuthor, err := e.App.FindRecordById("activitypub_actors", trail.GetString("author")) + if err != nil { + return err + } + // Sync remote data first (Fetch + Save) - if trail.GetString("iri") != "" { + if trail.GetString("iri") != "" && !trailAuthor.GetBool("isLocal") { _ = syncRemoteComments(e, trail) } @@ -149,11 +154,8 @@ func syncRemoteComments(e *core.RequestEvent, trail *core.Record) error { remoteIRI = fmt.Sprintf("%s://%s/api/v1/comment/%s", u.Scheme, u.Host, remoteID) } - remoteCommentUrl, _ := url.Parse(remoteIRI) - possibleLocalId := path.Base(remoteCommentUrl.Path) - // Find existing record by IRI or ID to avoid duplicates - commentRecord, _ := txApp.FindFirstRecordByFilter("comments", "iri={:iri} || id={:id}", dbx.Params{"id": possibleLocalId, "iri": remoteIRI}) + commentRecord, _ := txApp.FindFirstRecordByData("comments", "iri", remoteIRI) if commentRecord == nil { commentRecord = core.NewRecord(collection) commentRecord.Set("iri", remoteIRI) diff --git a/db/util/activitypub.go b/db/util/activitypub.go index 7a44466f..2f1387ed 100644 --- a/db/util/activitypub.go +++ b/db/util/activitypub.go @@ -13,7 +13,6 @@ import ( "net/http" "net/url" "os" - "path" "strconv" "strings" "time" @@ -151,24 +150,13 @@ func TrailFromActivity(activity pub.Activity, app core.App, actor *core.Record) if !actor.GetBool("isLocal") { return nil, fmt.Errorf("refusing remote activity referencing local trail %q", iri) } - trailUrl, parseErr := url.Parse(iri) - if parseErr != nil { - return nil, parseErr - } - return app.FindRecordById("trails", path.Base(trailUrl.Path)) + + return app.FindFirstRecordByData("trails", "iri", iri) } var record *core.Record - if actor.GetBool(("isLocal")) { - trailUrl, parseErr := url.Parse(iri) - if parseErr != nil { - return nil, parseErr - } - trailId := path.Base(trailUrl.Path) - record, err = app.FindRecordById("trails", trailId) - } else { - record, err = app.FindFirstRecordByData("trails", "iri", iri) - } + + record, err = app.FindFirstRecordByData("trails", "iri", iri) if err != nil { if err == sql.ErrNoRows { @@ -432,7 +420,7 @@ func ObjectFromTrail(app core.App, trail *core.Record, mentions *pub.ItemCollect } trailObject.AttributedTo = pub.IRI(trailAuthor.GetString("iri")) trailObject.Published = trail.GetDateTime("created").Time() - trailObject.ID = pub.IRI(fmt.Sprintf("%s/api/v1/trail/%s", origin, trail.Id)) + trailObject.ID = pub.IRI(trail.GetString("iri")) trailObject.URL = pub.IRI(activityURL) trailObject.StartTime = trail.GetDateTime("date").Time() @@ -455,24 +443,14 @@ func ListFromActivity(activity pub.Activity, app core.App, actor *core.Record) ( if !actor.GetBool("isLocal") { return nil, fmt.Errorf("refusing remote activity referencing local list %q", iri) } - listURL, parseErr := url.Parse(iri) - if parseErr != nil { - return nil, parseErr - } - return app.FindRecordById("lists", path.Base(listURL.Path)) + + return app.FindFirstRecordByData("lists", "iri", iri) } var record *core.Record - if actor.GetBool(("isLocal")) { - listURL, parseErr := url.Parse(iri) - if parseErr != nil { - return nil, parseErr - } - listId := path.Base(listURL.Path) - record, err = app.FindRecordById("lists", listId) - } else { - record, err = app.FindFirstRecordByData("lists", "iri", iri) - } + + record, err = app.FindFirstRecordByData("lists", "iri", iri) + if err != nil { if err == sql.ErrNoRows { collection, err := app.FindCollectionByNameOrId("lists") @@ -568,7 +546,7 @@ func ObjectFromList(app core.App, list *core.Record) (*pub.Object, error) { listObject.AttributedTo = pub.IRI(listAuthor.GetString("iri")) listObject.Published = list.GetDateTime("created").Time() - listObject.ID = pub.IRI(fmt.Sprintf("%s/api/v1/list/%s", origin, list.Id)) + listObject.ID = pub.IRI(list.GetString("iri")) listObject.URL = pub.IRI(activityURL) listObject.Attachment = attachments return listObject, nil @@ -589,24 +567,13 @@ func ObjectFromComment(app core.App, comment *core.Record, mentions *pub.ItemCol if err != nil { return nil, err } - commentTrailAuthor, err := app.FindRecordById("activitypub_actors", commentTrail.GetString("author")) - if err != nil { - return nil, err - } - - trailURL := "" - if commentTrailAuthor.GetBool("isLocal") { - trailURL = fmt.Sprintf("https://%s/api/v1/trail/%s", commentTrailAuthor.GetString("domain"), comment.GetString("trail")) - } else { - trailURL = commentTrail.GetString("iri") - } commentObject := pub.ObjectNew(pub.NoteType) - commentObject.ID = pub.IRI(fmt.Sprintf("%s/api/v1/comment/%s", origin, comment.Id)) + commentObject.ID = pub.IRI(comment.GetString("iri")) commentObject.Content = pub.NaturalLanguageValuesNew(pub.LangRefValueNew(pub.NilLangRef, comment.GetString("text"))) commentObject.Published = comment.GetDateTime("created").Time() commentObject.AttributedTo = pub.IRI(commentAuthor.GetString("iri")) - commentObject.InReplyTo = pub.IRI(trailURL) + commentObject.InReplyTo = pub.IRI(commentTrail.GetString("iri")) if mentions != nil { commentObject.Tag = *mentions diff --git a/db/util/meilisearch.go b/db/util/meilisearch.go index 92e2a86e..5ae1f916 100644 --- a/db/util/meilisearch.go +++ b/db/util/meilisearch.go @@ -129,7 +129,7 @@ func documentFromListRecord(r *core.Record, author *core.Record, includeShares b totalDuration := 0.0 trails := len(r.GetStringSlice("trails")) - if r.GetString("iri") != "" { + if r.GetString("iri") != "" && !author.GetBool("isLocal") { doc, err := documentFromRemoteRecord(r, "lists") if err == nil { totalElevationGain = doc["elevation_gain"].(float64) diff --git a/web/src/lib/components/list/list_card.svelte b/web/src/lib/components/list/list_card.svelte index ff7fc6bd..0b8a2a37 100644 --- a/web/src/lib/components/list/list_card.svelte +++ b/web/src/lib/components/list/list_card.svelte @@ -43,7 +43,7 @@
-
+
{list.name}
diff --git a/web/src/lib/util/activitypub_util.ts b/web/src/lib/util/activitypub_util.ts index f72cabab..0afc79cb 100644 --- a/web/src/lib/util/activitypub_util.ts +++ b/web/src/lib/util/activitypub_util.ts @@ -38,7 +38,7 @@ export function handleFromRecordWithIRI(record: any) { throw new Error("object has no author info") } - if (!record.iri) { + if (!record.iri || record.iri.length == 0) { return `@${record.expand.author.preferred_username}` } const url = new URL(record.iri ?? "") From 197cd8d04e336b6a253b99cc11485d35694a89f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A5l=20H=C3=A5land?= <4449863+palhaland@users.noreply.github.com> Date: Sun, 7 Jun 2026 13:00:26 +0200 Subject: [PATCH 16/26] feat: implement server-side map clustering (#991) * feat: meilisearch bounding box intersection and map filtering * feat: tiered clustering and polyline filtering based on bounding box diagonal Addressing review comments with optimized performance and data accuracy: - Implemented a two-tiered search strategy (Summary vs. Detailed) to provide 100% accurate cluster counts while minimizing metadata traffic. - Added a client-side ID-based cache to eliminate redundant network requests for trails already in memory. - Introduced tiered 'detail shedding' that dynamically hides small trail lines when zooming out to maintain smooth panning performance. - Fixed Svelte 5 reactivity issues to ensure clusters reappear instantly when trail detail is shed. - Consolidated zoom thresholds into centralized constants for system-wide consistency. - Updated Meilisearch configuration to support efficient ID-based filtering. * feat: implement server-side map clustering using Meilisearch and Supercluster - Implemented SvelteKit server-side clustering route at `/api/v1/search/trails/cluster` using the `supercluster` library. - Integrated backend search results with the new clustering endpoint to improve performance for large trail datasets. - Fixed Svelte 5 reactivity in `MapWithElevationMaplibre.svelte` by making `mapLoaded` reactive and fixing destructuring of `` map data. - Removed obsolete client-side zoom constraints (`minzoom`/`maxzoom`) from `TrailLayer`, `PreviewLayer`, and `ClusterLayer` to allow dynamic visibility controlled by backend attributes. - Fixed a bug where unauthenticated users generated invalid Meilisearch filters. - Optimized map page performance by caching bounding box and filter values in the route loader. - Updated `ClusterLayer` font to `Noto Sans Regular` to match available tileserver resources. * feat: implement dynamic polyline visibility based on result density - Replaced static zoom-based diagonal thresholds with a dynamic "Top N" approach for polyline visibility. - Updated `/api/v1/search/trails/cluster` to mark only the top `MAP_MAX_POLYLINES` (default 100) trails by bounding box diagonal as "large". - Modified `trails_search_bounding_box` store function to always fetch polylines for trails marked as large, regardless of zoom level. - Removed obsolete `MAP_*_ZOOM_DIAGONAL_LIMIT` constants and simplified map layer constructors by removing tier-based filtering. - Updated environment configurations and documentation to use the new `PUBLIC_MAP_MAX_POLYLINES` variable. * refactor: remove obsolete zoom-based map clustering thresholds Following the transition to dynamic server-side clustering based on result density (Top N polylines), this commit removes all remaining zoom-based thresholds and logic. - Removed PUBLIC_MAP_LOW_ZOOM_THRESHOLD, PUBLIC_MAP_MEDIUM_ZOOM_THRESHOLD, and PUBLIC_MAP_HIGH_ZOOM_THRESHOLD environment variables. - Removed the 'mapClusterMinZoom' user setting from the schema, models, and settings UI. - Simplified the map component and MapLibre layer managers by removing unused 'clusterMinZoom', 'minZoom', and 'maxZoom' parameters. - Cleaned up obsolete 'map-cluster-zoom-level' translation keys across all locales. - Updated documentation to reflect the removal of these variables. * chore: remove map clustering debug logs * docs: update changelog and fix global zoom feature disappearance * fix: address PR reviews and refine map cluster/preview visuals * feat: implement configurable map cluster zoom and trail start marker settings * feat: render single unclustered shedded trails as start markers instead of cluster circles * minor fixes * minor ui changes * fix missing entries in map list * remove redundant map clustering zoom clamp * fix merge issues * fix merge issues * fixes * hide popup for trails without details * several improvements * prevent import of client trail_store in server cluster code * update changelog --------- Co-authored-by: slothful-vassal <89943360+slothful-vassal@users.noreply.github.com> --- CHANGELOG.md | 6 +- db/main.go | 4 +- .../1778583800_persist_trail_bounds.go | 218 +++++++++++++++ db/util/meilisearch.go | 74 +++-- db/util/polyline.go | 94 ++++++- docker-compose.yml | 1 + docker/docker-compose.dev.yml | 1 + docker/docker-compose.prod.yml | 1 + .../docs/run/environment-configuration.md | 1 + docs/src/content/docs/use/customize-map.md | 15 +- web/package-lock.json | 2 + web/package.json | 2 + .../trail/map_with_elevation_maplibre.svelte | 191 ++++++++----- web/src/lib/config/map.ts | 3 + web/src/lib/i18n/locales/cs.json | 2 + web/src/lib/i18n/locales/de.json | 2 + web/src/lib/i18n/locales/en.json | 2 + web/src/lib/i18n/locales/es.json | 2 + web/src/lib/i18n/locales/eu.json | 2 + web/src/lib/i18n/locales/fr.json | 2 + web/src/lib/i18n/locales/hu.json | 2 + web/src/lib/i18n/locales/it.json | 2 + web/src/lib/i18n/locales/nl.json | 2 + web/src/lib/i18n/locales/no.json | 2 + web/src/lib/i18n/locales/pl.json | 2 + web/src/lib/i18n/locales/pt.json | 2 + web/src/lib/i18n/locales/ru.json | 2 + web/src/lib/i18n/locales/zh.json | 2 + web/src/lib/models/api/settings_schema.ts | 7 +- web/src/lib/models/settings.ts | 2 + web/src/lib/models/trail.ts | 9 +- web/src/lib/stores/search_store.ts | 6 +- web/src/lib/stores/trail_store.ts | 254 +++++++++++++++--- .../maplibre-layer-manager/cluster-layer.ts | 83 +++--- .../maplibre-layer-manager.ts | 11 +- .../maplibre-layer-manager/preview-layer.ts | 13 +- .../maplibre-layer-manager/trail-layer.ts | 29 +- .../api/v1/search/trails/cluster/+server.ts | 128 +++++++++ web/src/routes/map/+page.svelte | 102 +++++-- web/src/routes/map/+page.ts | 2 +- web/src/routes/settings/map/+page.svelte | 91 +++++-- 41 files changed, 1133 insertions(+), 245 deletions(-) create mode 100644 db/migrations/1778583800_persist_trail_bounds.go create mode 100644 web/src/lib/config/map.ts create mode 100644 web/src/routes/api/v1/search/trails/cluster/+server.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d03bd95b..1ff2e6aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# [Unreleased] + +## Features +- Server-side map clustering and zoom-aware polyline filtering: The world map now performs trail clustering on the server to improve performance. At lower zoom levels, smaller trails are clustered, while at higher zoom levels the largest routes in the current view are shown as detailed polylines. The maximum number of simultaneously visible polylines can be configured via the PUBLIC_MAP_MAX_POLYLINES environment variable. + # v0.19.2 ## Documentation - Add CONTRIBUTING guidelines @@ -75,7 +80,6 @@ ## Maintenance - Meilisearch, PocketBase, Go, web/docs dependencies, CI actions, and Docker build setup updated. - # v0.18.5 ## Security - Fixes CVE-2022-39299 via xmldom upgrade (PR #820) diff --git a/db/main.go b/db/main.go index 0e201851..11f5b152 100644 --- a/db/main.go +++ b/db/main.go @@ -299,9 +299,9 @@ func initMeilisearchConfig(client meilisearch.ServiceManager) { "trails": { SearchableAttributes: []string{"author_name", "name", "description", "location", "tags"}, FilterableAttributes: []string{ - "_geo", "author", "category", "completed", "date", "difficulty", + "id", "_geo", "author", "category", "completed", "date", "difficulty", "distance", "elevation_gain", "elevation_loss", "likes", "public", - "shares", "tags", + "shares", "tags", "min_lat", "max_lat", "min_lon", "max_lon", "bounding_box_diagonal", }, SortableAttributes: []string{ "author", "created", "date", "difficulty", "distance", diff --git a/db/migrations/1778583800_persist_trail_bounds.go b/db/migrations/1778583800_persist_trail_bounds.go new file mode 100644 index 00000000..19e8f4b0 --- /dev/null +++ b/db/migrations/1778583800_persist_trail_bounds.go @@ -0,0 +1,218 @@ +package migrations + +import ( + "encoding/json" + "pocketbase/util" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/core" + m "github.com/pocketbase/pocketbase/migrations" +) + +const trailBoundsViewQuery = `SELECT + a.id, a.user, + COALESCE(MAX(t.max_lat), 0) AS max_lat, + COALESCE(MAX(t.max_lon), 0) AS max_lon, + COALESCE(MIN(t.min_lat), 0) AS min_lat, + COALESCE(MIN(t.min_lon), 0) AS min_lon +FROM activitypub_actors a +LEFT JOIN ( + SELECT author AS actor_id, + MAX(max_lat) AS max_lat, + MAX(max_lon) AS max_lon, + MIN(min_lat) AS min_lat, + MIN(min_lon) AS min_lon + FROM trails + GROUP BY author + UNION ALL + SELECT ts.actor AS actor_id, + MAX(t.max_lat) AS max_lat, + MAX(t.max_lon) AS max_lon, + MIN(t.min_lat) AS min_lat, + MIN(t.min_lon) AS min_lon + FROM trail_share ts + JOIN trails t ON t.id = ts.trail + GROUP BY ts.actor + UNION ALL + SELECT a2.id AS actor_id, + p.max_lat, p.max_lon, p.min_lat, p.min_lon + FROM activitypub_actors a2 + CROSS JOIN ( + SELECT + MAX(max_lat) AS max_lat, + MAX(max_lon) AS max_lon, + MIN(min_lat) AS min_lat, + MIN(min_lon) AS min_lon + FROM trails + WHERE public = TRUE + ) p +) t ON t.actor_id = a.id +WHERE a.user != "" +GROUP BY a.id;` + +const trailStartPointBoundsViewQuery = `SELECT + a.id, a.user, + COALESCE(MAX(t.max_lat), 0) AS max_lat, + COALESCE(MAX(t.max_lon), 0) AS max_lon, + COALESCE(MIN(t.min_lat), 0) AS min_lat, + COALESCE(MIN(t.min_lon), 0) AS min_lon +FROM activitypub_actors a +LEFT JOIN ( + SELECT author AS actor_id, + MAX(lat) AS max_lat, + MAX(lon) AS max_lon, + MIN(lat) AS min_lat, + MIN(lon) AS min_lon + FROM trails + GROUP BY author + UNION ALL + SELECT ts.actor AS actor_id, + MAX(t.lat) AS max_lat, + MAX(t.lon) AS max_lon, + MIN(t.lat) AS min_lat, + MIN(t.lon) AS min_lon + FROM trail_share ts + JOIN trails t ON t.id = ts.trail + GROUP BY ts.actor + UNION ALL + SELECT a2.id AS actor_id, + p.max_lat, p.max_lon, p.min_lat, p.min_lon + FROM activitypub_actors a2 + CROSS JOIN ( + SELECT + MAX(lat) AS max_lat, + MAX(lon) AS max_lon, + MIN(lat) AS min_lat, + MIN(lon) AS min_lon + FROM trails + WHERE public = TRUE + ) p +) t ON t.actor_id = a.id +WHERE a.user != "" +GROUP BY a.id;` + +func init() { + m.Register(func(app core.App) error { + trailsCollection, err := app.FindCollectionByNameOrId("trails") + if err != nil { + return err + } + + addTrailBoundsField(trailsCollection, "min_lat") + addTrailBoundsField(trailsCollection, "max_lat") + addTrailBoundsField(trailsCollection, "min_lon") + addTrailBoundsField(trailsCollection, "max_lon") + addTrailBoundsField(trailsCollection, "bounding_box_diagonal") + + if err := app.Save(trailsCollection); err != nil { + return err + } + + if err := backfillTrailBounds(app); err != nil { + return err + } + + boundingBoxCollection, err := app.FindCollectionByNameOrId("trails_bounding_box") + if err != nil { + return err + } + + if err := json.Unmarshal([]byte(`{"viewQuery":`+strconvQuote(trailBoundsViewQuery)+`}`), &boundingBoxCollection); err != nil { + return err + } + + if err := app.Save(boundingBoxCollection); err != nil { + return err + } + + return nil + }, func(app core.App) error { + boundingBoxCollection, err := app.FindCollectionByNameOrId("trails_bounding_box") + if err != nil { + return err + } + + if err := json.Unmarshal([]byte(`{"viewQuery":`+strconvQuote(trailStartPointBoundsViewQuery)+`}`), &boundingBoxCollection); err != nil { + return err + } + + if err := app.Save(boundingBoxCollection); err != nil { + return err + } + + trailsCollection, err := app.FindCollectionByNameOrId("trails") + if err != nil { + return err + } + + trailsCollection.Fields.RemoveByName("min_lat") + trailsCollection.Fields.RemoveByName("max_lat") + trailsCollection.Fields.RemoveByName("min_lon") + trailsCollection.Fields.RemoveByName("max_lon") + trailsCollection.Fields.RemoveByName("bounding_box_diagonal") + + if err := app.Save(trailsCollection); err != nil { + return err + } + + return nil + }) +} + +func addTrailBoundsField(collection *core.Collection, name string) { + if collection.Fields.GetByName(name) != nil { + return + } + + collection.Fields.Add(&core.NumberField{ + Name: name, + }) +} + +func backfillTrailBounds(app core.App) error { + const pageSize int64 = 50 + lastID := "" + + for { + trails := []*core.Record{} + query := app.RecordQuery("trails"). + OrderBy("id ASC"). + Limit(pageSize) + + if lastID != "" { + query = query.AndWhere(dbx.NewExp("id > {:lastID}", dbx.Params{"lastID": lastID})) + } + + if err := query.All(&trails); err != nil { + return err + } + if len(trails) == 0 { + return nil + } + + for _, trail := range trails { + if err := util.SavePolyline(app, trail); err != nil { + if err := saveDefaultTrailBounds(app, trail); err != nil { + return err + } + } + lastID = trail.Id + } + } +} + +func saveDefaultTrailBounds(app core.App, trail *core.Record) error { + lat := trail.GetFloat("lat") + lon := trail.GetFloat("lon") + trail.Set("min_lat", lat) + trail.Set("max_lat", lat) + trail.Set("min_lon", lon) + trail.Set("max_lon", lon) + trail.Set("bounding_box_diagonal", 0) + return app.UnsafeWithoutHooks().Save(trail) +} + +func strconvQuote(value string) string { + raw, _ := json.Marshal(value) + return string(raw) +} diff --git a/db/util/meilisearch.go b/db/util/meilisearch.go index 5ae1f916..6c4c4b58 100644 --- a/db/util/meilisearch.go +++ b/db/util/meilisearch.go @@ -39,35 +39,47 @@ func documentFromTrailRecord(app core.App, r *core.Record, author *core.Record, category = trailCategory.GetString("name") } + bounds := getStoredBounds(r) + domain := "" if !author.GetBool("isLocal") { domain = author.GetString("domain") } + diagonal := r.GetFloat("bounding_box_diagonal") + if diagonal == 0 && (bounds[0] != bounds[1] || bounds[2] != bounds[3]) { + diagonal = HaversineDistance(bounds[0], bounds[2], bounds[1], bounds[3]) + } + document := map[string]any{ - "id": r.Id, - "author": author.Id, - "author_name": author.GetString("preferred_username"), - "author_avatar": author.GetString("icon"), - "name": r.GetString("name"), - "description": r.GetString("description"), - "location": r.GetString("location"), - "distance": r.GetFloat("distance"), - "elevation_gain": r.GetFloat("elevation_gain"), - "elevation_loss": r.GetFloat("elevation_loss"), - "duration": r.GetFloat("duration"), - "difficulty": difficultyToNumber(r.GetString("difficulty")), - "category": category, - "completed": r.GetBool("completed"), - "date": r.GetDateTime("date").Time().Unix(), - "created": r.GetDateTime("created").Time().Unix(), - "public": r.GetBool("public"), - "thumbnail": thumbnail, - "gpx": r.GetString("gpx"), - "tags": tags, - "polyline": r.GetString("polyline"), - "domain": domain, - "iri": r.GetString("iri"), + "id": r.Id, + "author": author.Id, + "author_name": author.GetString("preferred_username"), + "author_avatar": author.GetString("icon"), + "name": r.GetString("name"), + "description": r.GetString("description"), + "location": r.GetString("location"), + "distance": r.GetFloat("distance"), + "elevation_gain": r.GetFloat("elevation_gain"), + "elevation_loss": r.GetFloat("elevation_loss"), + "duration": r.GetFloat("duration"), + "difficulty": difficultyToNumber(r.GetString("difficulty")), + "category": category, + "completed": r.GetBool("completed"), + "date": r.GetDateTime("date").Time().Unix(), + "created": r.GetDateTime("created").Time().Unix(), + "public": r.GetBool("public"), + "thumbnail": thumbnail, + "gpx": r.GetString("gpx"), + "tags": tags, + "polyline": r.GetString("polyline"), + "domain": domain, + "iri": r.GetString("iri"), + "min_lat": bounds[0], + "max_lat": bounds[1], + "min_lon": bounds[2], + "max_lon": bounds[3], + "bounding_box_diagonal": diagonal, "_geo": map[string]float64{ "lat": r.GetFloat("lat"), "lng": r.GetFloat("lon"), @@ -121,6 +133,22 @@ func difficultyToNumber(difficulty string) int32 { return 0 } +func getStoredBounds(r *core.Record) [4]float64 { + lat := r.GetFloat("lat") + lon := r.GetFloat("lon") + defaultBounds := [4]float64{lat, lat, lon, lon} + + minLat := r.GetFloat("min_lat") + maxLat := r.GetFloat("max_lat") + minLon := r.GetFloat("min_lon") + maxLon := r.GetFloat("max_lon") + if minLat == 0 && maxLat == 0 && minLon == 0 && maxLon == 0 && (lat != 0 || lon != 0) { + return defaultBounds + } + + return [4]float64{minLat, maxLat, minLon, maxLon} +} + func documentFromListRecord(r *core.Record, author *core.Record, includeShares bool) (map[string]any, error) { totalElevationGain := 0.0 diff --git a/db/util/polyline.go b/db/util/polyline.go index 291a740e..69dfd5ba 100644 --- a/db/util/polyline.go +++ b/db/util/polyline.go @@ -12,33 +12,82 @@ import ( const PolylineMaxLength = 5 * 1024 * 1024 -func ComputePolyline(app core.App, r *core.Record) (string, error) { +type TrailGeometry struct { + Polyline string + MinLat float64 + MaxLat float64 + MinLon float64 + MaxLon float64 + BoundingBoxDiagonal float64 +} + +func ComputeTrailGeometry(app core.App, r *core.Record) (*TrailGeometry, error) { + geometry := &TrailGeometry{ + MinLat: r.GetFloat("lat"), + MaxLat: r.GetFloat("lat"), + MinLon: r.GetFloat("lon"), + MaxLon: r.GetFloat("lon"), + } + gpxPath := r.GetString("gpx") if len(gpxPath) == 0 { - return "", nil + return geometry, nil } fsys, err := app.NewFilesystem() if err != nil { - return "", fmt.Errorf("open filesystem: %w", err) + return nil, fmt.Errorf("open filesystem: %w", err) } defer fsys.Close() gpxFilePath := r.BaseFilesPath() + "/" + gpxPath gpxFile, err := fsys.GetReader(gpxFilePath) if err != nil { - return "", fmt.Errorf("open gpx file %q: %w", gpxFilePath, err) + return nil, fmt.Errorf("open gpx file %q: %w", gpxFilePath, err) } defer gpxFile.Close() content := new(bytes.Buffer) if _, err = io.Copy(content, gpxFile); err != nil { - return "", fmt.Errorf("read gpx file %q: %w", gpxFilePath, err) + return nil, fmt.Errorf("read gpx file %q: %w", gpxFilePath, err) } gpxData, err := gpx.Parse(content) if err != nil { - return "", fmt.Errorf("parse gpx file %q: %w", gpxFilePath, err) + return nil, fmt.Errorf("parse gpx file %q: %w", gpxFilePath, err) + } + + minLat, maxLat, minLon, maxLon := 90.0, -90.0, 180.0, -180.0 + hasPoints := false + + addPoint := func(lat, lon float64) { + if lat < minLat { + minLat = lat + } + if lat > maxLat { + maxLat = lat + } + if lon < minLon { + minLon = lon + } + if lon > maxLon { + maxLon = lon + } + hasPoints = true + } + + for _, trk := range gpxData.Tracks { + for _, seg := range trk.Segments { + for _, pt := range seg.Points { + addPoint(pt.Latitude, pt.Longitude) + } + } + } + + for _, rte := range gpxData.Routes { + for _, pt := range rte.Points { + addPoint(pt.Latitude, pt.Longitude) + } } gpxData.SimplifyTracks(50) @@ -50,21 +99,44 @@ func ComputePolyline(app core.App, r *core.Record) (string, error) { } } } - return string(polyline.EncodeCoords(coordinates)), nil + geometry.Polyline = string(polyline.EncodeCoords(coordinates)) + + if hasPoints { + geometry.MinLat = minLat + geometry.MaxLat = maxLat + geometry.MinLon = minLon + geometry.MaxLon = maxLon + geometry.BoundingBoxDiagonal = HaversineDistance(minLat, minLon, maxLat, maxLon) + } + + return geometry, nil +} + +func ComputePolyline(app core.App, r *core.Record) (string, error) { + geometry, err := ComputeTrailGeometry(app, r) + if err != nil { + return "", err + } + return geometry.Polyline, nil } func SavePolyline(app core.App, r *core.Record) error { - encoded, err := ComputePolyline(app, r) + geometry, err := ComputeTrailGeometry(app, r) if err != nil { return err } // Encoded polylines are ASCII-only, so byte length matches character length. - if len(encoded) > PolylineMaxLength { + if len(geometry.Polyline) > PolylineMaxLength { return fmt.Errorf("polyline exceeds maximum length of %d characters", PolylineMaxLength) } - r.Set("polyline", encoded) + r.Set("polyline", geometry.Polyline) + r.Set("min_lat", geometry.MinLat) + r.Set("max_lat", geometry.MaxLat) + r.Set("min_lon", geometry.MinLon) + r.Set("max_lon", geometry.MaxLon) + r.Set("bounding_box_diagonal", geometry.BoundingBoxDiagonal) if err := app.UnsafeWithoutHooks().Save(r); err != nil { - return fmt.Errorf("save trail polyline: %w", err) + return fmt.Errorf("save trail geometry: %w", err) } return nil } diff --git a/docker-compose.yml b/docker-compose.yml index 9dda7d8a..4df742ad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -67,6 +67,7 @@ services: OVERPASS_API_URL: https://overpass-api.de VALHALLA_URL: https://valhalla1.openstreetmap.de NOMINATIM_URL: https://nominatim.openstreetmap.org + PUBLIC_MAP_MAX_POLYLINES: 100 volumes: - ./data/uploads:/app/uploads # - ./data/about.md:/app/build/client/md/about.md diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 5b821175..67b9ff9a 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -55,6 +55,7 @@ services: OVERPASS_API_URL: https://overpass-api.de VALHALLA_URL: https://valhalla1.openstreetmap.de NOMINATIM_URL: https://nominatim.openstreetmap.org + PUBLIC_MAP_MAX_POLYLINES: 100 volumes: - uploads:/app/uploads # - ./data/about.md:/app/build/client/md/about.md diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index fc398ca4..43768373 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -55,6 +55,7 @@ services: OVERPASS_API_URL: https://overpass-api.de VALHALLA_URL: https://valhalla1.openstreetmap.de NOMINATIM_URL: https://nominatim.openstreetmap.org + PUBLIC_MAP_MAX_POLYLINES: 100 volumes: - uploads:/app/uploads # - ./data/about.md:/app/build/client/md/about.md diff --git a/docs/src/content/docs/run/environment-configuration.md b/docs/src/content/docs/run/environment-configuration.md index ee0e10b0..a912cbdc 100644 --- a/docs/src/content/docs/run/environment-configuration.md +++ b/docs/src/content/docs/run/environment-configuration.md @@ -43,6 +43,7 @@ Since we use an unmodified installation of meilisearch you can use all variables | PUBLIC_POCKETBASE_URL | IP or hostname (including the port) of your pocketbase instance | http://db:8090 | | PUBLIC_DISABLE_SIGNUP | Disables signup option for new users | false | | PUBLIC_PRIVATE_INSTANCE | Setting this to true will block visitors from viewing content without an account | false | +| PUBLIC_MAP_MAX_POLYLINES | Maximum number of polylines (route previews) to show simultaneously on the map, based on result density | 100 | | UPLOAD_FOLDER | Folder from which wanderer auto-uploads trails | /app/uploads | | UPLOAD_USER | Username for the account with which wanderer auto-uploads trails | | | UPLOAD_PASSWORD | Password for the account with which wanderer auto-uploads trails | | diff --git a/docs/src/content/docs/use/customize-map.md b/docs/src/content/docs/use/customize-map.md index 84fc5474..d239a918 100644 --- a/docs/src/content/docs/use/customize-map.md +++ b/docs/src/content/docs/use/customize-map.md @@ -17,7 +17,7 @@ You can switch between these styles by opening the style switcher menu with the To further personalize your map, you can add custom map styles by providing a URL to a `style.json` file. This allows you to fully control the map’s appearance using your own vector tile styles. Follow these steps to add and use your custom styles: -1. Navigate to `Settings -> Display`. +1. Navigate to `Settings -> Map`. 2. Under the `Tilesets` section, you can add your custom map styles: - Enter an arbitrary name for your style (this is how it will appear in the style switcher menu). - Paste the URL pointing to your `style.json` file. This file should define the vector tile style you want to use. @@ -32,7 +32,7 @@ Once added, your custom style will be available in the style switcher menu, allo To enhance wanderer's map visualization, you can add two types of data sources to display 3D Terrain and Hillshading. This is achieved by providing URLs pointing to the required `tiles.json` files. Both the terrain and hillshading data must be in Mapbox TileJSON format and accessible through the provided URLs. -To add the respective URLs navigate to `Settings -> Display` and add them in the `Terrain` section. After adding the terrain & hillshading source, you can explore the 3D map view by interacting with the compass control on the map. +To add the respective URLs navigate to `Settings -> Map` and add them in the `Terrain` section. After adding the terrain & hillshading source, you can explore the 3D map view by interacting with the compass control on the map. 1. Enable 3D terrain with the control on the bottom-right. 2. Locate the compass control in the top-right corner of the map. @@ -41,9 +41,18 @@ To add the respective URLs navigate to `Settings -> Display` and add them in the ## Route drawing behavior -You can configure how new route drawing starts in `Settings -> Display`. +You can configure how new route drawing starts in `Settings -> Map`. - Enable `Begin drawing a new trail from your current location` to automatically center route drawing on your current GPS location. - Disable it to start drawing at the current map view instead. This option only affects creating a **new** trail in the route editor. + +## Trail previews on the map + +You can configure how trail previews are displayed on the main map in `Settings -> Map`. + +- `Show trail previews from zoom level` controls from which zoom level individual trail lines are shown instead of clustered points. +- `Show marker at start of trail` adds a small marker to the beginning of visible trail previews. + +The number of trail preview lines shown at the same time can also be limited by the `PUBLIC_MAP_MAX_POLYLINES` environment variable. diff --git a/web/package-lock.json b/web/package-lock.json index 0ac5403c..27579560 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -26,6 +26,7 @@ "@turf/destination": "^7.3.3", "@turf/distance": "^7.3.3", "@types/chart.js": "^4.0.1", + "@types/supercluster": "^7.1.3", "@types/three": "^0.183.1", "@xmldom/xmldom": "^0.8.12", "activitypub-types": "^1.1.0", @@ -53,6 +54,7 @@ "photoswipe": "^5.4.3", "pocketbase": "^0.26.8", "qrcode": "^1.4.4", + "supercluster": "^8.0.1", "svelte-i18n": "^4.0.0", "tailwindcss": "^4.2.4", "three": "^0.183.1", diff --git a/web/package.json b/web/package.json index c49f8417..79f5d6ed 100644 --- a/web/package.json +++ b/web/package.json @@ -51,6 +51,7 @@ "@turf/destination": "^7.3.3", "@turf/distance": "^7.3.3", "@types/chart.js": "^4.0.1", + "@types/supercluster": "^7.1.3", "@types/three": "^0.183.1", "@xmldom/xmldom": "^0.8.12", "activitypub-types": "^1.1.0", @@ -78,6 +79,7 @@ "photoswipe": "^5.4.3", "pocketbase": "^0.26.8", "qrcode": "^1.4.4", + "supercluster": "^8.0.1", "svelte-i18n": "^4.0.0", "tailwindcss": "^4.2.4", "three": "^0.183.1", diff --git a/web/src/lib/components/trail/map_with_elevation_maplibre.svelte b/web/src/lib/components/trail/map_with_elevation_maplibre.svelte index 57072669..4da60518 100644 --- a/web/src/lib/components/trail/map_with_elevation_maplibre.svelte +++ b/web/src/lib/components/trail/map_with_elevation_maplibre.svelte @@ -31,6 +31,7 @@ interface Props { trails?: Trail[]; + serverClusters?: GeoJSON.FeatureCollection; gpx?: GPX; waypoints?: Waypoint[]; markers?: M.Marker[]; @@ -75,6 +76,7 @@ let { trails = [], + serverClusters = undefined, waypoints = [], markers = $bindable([]), map = $bindable(), @@ -117,7 +119,7 @@ let hoveringTrail: boolean = false; - let mapLoaded: boolean = false; + let mapLoaded: boolean = $state(false); let terrainEnabled: boolean | null = null; const trailColors = [ @@ -135,9 +137,16 @@ let clusterPopup: M.Popup | null = null; - let [data, clusterData, previewData] = $derived(getData(trails)); + let mapData = $derived(getData(trails, serverClusters)); + let gpxDataMap = $derived(mapData[0]); + let clusterData = $derived(mapData[1]); + let previewData = $derived(mapData[2]); + $effect(() => { - if (data && map && mapLoaded) { + // Track dependencies for Svelte 5 + mapData; + + if (map && mapLoaded) { untrack(() => initMap(map?.loaded() ?? false)); } }); @@ -189,8 +198,13 @@ function getData( trails: Trail[], - ): [FeatureCollection[], FeatureCollection, FeatureCollection] { - let clusterData: FeatureCollection = { + serverClusters?: GeoJSON.FeatureCollection + ): [ + Record, + FeatureCollection, + FeatureCollection, + ] { + let clusterData: FeatureCollection = serverClusters ?? { type: "FeatureCollection", features: [], }; @@ -198,21 +212,36 @@ type: "FeatureCollection", features: [], }; - let r: FeatureCollection[] = []; + let gpxDataMap: Record = {}; - trails.forEach((t, i) => { - if (t.expand?.gpx) { - r.push(t.expand.gpx.toGeoJSON()); - } else if (t.expand?.gpx_data) { - r.push(GPX.parse(t.expand.gpx_data).toGeoJSON()); + trails.forEach((t) => { + if (t.id) { + let fc: FeatureCollection | null = null; + if (t.expand?.gpx) { + fc = t.expand.gpx.toGeoJSON(); + } else if (t.expand?.gpx_data) { + fc = GPX.parse(t.expand.gpx_data).toGeoJSON(); + } + + if (fc) { + fc.features.forEach((f) => { + if (f.properties) { + f.properties.bounding_box_diagonal = + t.bounding_box_diagonal; + } + }); + gpxDataMap[t.id] = fc; + } } + if (clusterTrails) { - if (t.lat !== null && t.lon !== null) { + if (!serverClusters && t.lat !== undefined && t.lon !== undefined) { clusterData.features.push({ id: t.id, type: "Feature", properties: { trail: t.id, + bounding_box_diagonal: t.bounding_box_diagonal, }, geometry: { type: "Point", @@ -227,6 +256,7 @@ type: "Feature", properties: { trail: t.id, + bounding_box_diagonal: t.bounding_box_diagonal, color: trailColors[ hashStringToIndex( t.id ?? "", @@ -243,7 +273,7 @@ } }); - return [r, clusterData, previewData]; + return [gpxDataMap, clusterData, previewData]; } function initMap(mapLoaded: boolean) { @@ -252,20 +282,20 @@ } refreshElevationProfile(); - if (showElevation && data.length && activeTrail !== null) { + if ( + showElevation && + Object.keys(gpxDataMap).length && + activeTrail !== null + ) { epc?.showProfile(); } else { epc?.hideProfile(); } - trails.forEach((t, i) => { + trails.forEach((t) => { const layerId = t.id!; - addTrailLayer(t, layerId, i, data[i]); + addTrailLayer(t, layerId, 0, gpxDataMap[layerId]); }); - if (clusterTrails) { - addClusterLayer(clusterData); - addPreviewLayer(previewData); - } Object.entries(layerManager.layers).forEach(([id, layer]) => { if (!(layer instanceof TrailLayer)) { @@ -278,18 +308,31 @@ } }); - if ( - !drawing && - fitBounds !== "off" && - data.some((d) => d.bbox !== undefined) - ) { - if (activeTrail !== null && trails[activeTrail] && mapLoaded) { + if (clusterTrails) { + addPreviewLayer(previewData); + addClusterLayer(clusterData); + } + + if (!drawing && fitBounds !== "off") { + const currentBboxes = Object.values(gpxDataMap) + .map((d) => d.bbox) + .filter((b) => b !== undefined); + + if ( + activeTrail !== null && + trails[activeTrail] && + mapLoaded && + gpxDataMap[trails[activeTrail].id!] + ) { focusTrail(trails[activeTrail]); - } else { + } else if (currentBboxes.length > 0) { flyToBounds(); } } else if (drawing && activeTrail !== null && mapLoaded) { - addCaretLayer(data[activeTrail]); + const activeId = trails[activeTrail]?.id; + if (activeId && gpxDataMap[activeId]) { + addCaretLayer(gpxDataMap[activeId]); + } } } @@ -313,8 +356,9 @@ } export function refreshElevationProfile() { - if (activeTrail !== null && data[activeTrail]) { - epc?.setData(data[activeTrail]!, waypoints); + const activeId = activeTrail !== null ? trails[activeTrail]?.id : null; + if (activeId && gpxDataMap[activeId]) { + epc?.setData(gpxDataMap[activeId]!, waypoints); } } @@ -324,7 +368,7 @@ maxX = -Infinity, maxY = -Infinity; - for (const [xMin, yMin, xMax, yMax] of data + for (const [xMin, yMin, xMax, yMax] of Object.values(gpxDataMap) .filter((d) => d.bbox !== undefined) .map((d) => d.bbox!)) { minX = Math.min(minX, xMin); @@ -346,9 +390,10 @@ } function flyToBounds() { + const activeId = activeTrail !== null ? trails[activeTrail]?.id : null; const bounds = - activeTrail !== null && data[activeTrail] - ? (data[activeTrail].bbox as M.LngLatBoundsLike) + activeId && gpxDataMap[activeId] + ? (gpxDataMap[activeId].bbox as M.LngLatBoundsLike) : getBounds(); if (!bounds || !map) { @@ -389,18 +434,20 @@ trailColors[ clusterTrails ? hashStringToIndex(id ?? "", trailColors.length) - : 0 + : index % trailColors.length ], { - onEnter: (e) => - highlightTrail(id, trails[activeTrail ?? -1]?.id == id), + listeners: { + onEnter: (e) => + highlightTrail(id, trails[activeTrail ?? -1]?.id == id), - onLeave: (e) => unHighlightTrail(id), - onMouseUp: (e) => { - activeTrail = trails.findIndex((t) => t.id == trail.id); + onLeave: (e) => unHighlightTrail(id), + onMouseUp: (e) => { + activeTrail = trails.findIndex((t) => t.id == trail.id); + }, + onMouseMove: moveCrosshairToCursorPosition, + onMouseDown: (e) => handleDragStart(e, id), }, - onMouseMove: moveCrosshairToCursorPosition, - onMouseDown: (e) => handleDragStart(e, id), }, ); @@ -415,7 +462,20 @@ if (!geojson || !map || !map.style) { return; } - layerManager.addLayer("clusters", new ClusterLayer(map, geojson)); + layerManager.addLayer( + "clusters", + new ClusterLayer(map, geojson, { + "unclustered-point": { + onEnter: (e) => { + if (map) map.getCanvas().style.cursor = "pointer"; + const id = (e as any).features[0].properties.id; + const trail = trails.find((t) => t.id === id); + if (!hasTrailDetails(trail)) return; + highlightCluster(trail, e.lngLat); + }, + }, + }), + ); } function addPreviewLayer(geojson: FeatureCollection) { @@ -425,18 +485,19 @@ layerManager.addLayer( "preview", new PreviewLayer(map, geojson, { - preview: { - onEnter: (e) => { - const trail = trails.find( - (t) => - t.id === - (e as any).features[0].properties.trail, - ); - if (!trail) return; - highlightCluster(trail, e.lngLat); - }, - onLeave: (e) => { - // unHighlightCluster(); + showStartMarker: page.data.settings?.behavior?.showTrailStartMarker ?? false, + listeners: { + preview: { + onEnter: (e) => { + if (map) map.getCanvas().style.cursor = "pointer"; + const trail = trails.find( + (t) => + t.id === + (e as any).features[0].properties.trail, + ); + if (!hasTrailDetails(trail)) return; + highlightCluster(trail, e.lngLat); + }, }, }, }), @@ -537,11 +598,15 @@ // map?.setPaintProperty(id, "line-color", "#648ad5"); } + function hasTrailDetails(trail: Trail | undefined): trail is Trail { + return Boolean(trail?.name?.trim()); + } + export async function highlightCluster( trail: Trail, lnglat?: M.LngLatLike, ) { - if (!map || !map.style) { + if (!map || !map.style || !hasTrailDetails(trail)) { return; } clusterPopup?.remove(); @@ -581,7 +646,7 @@ if ( !drawing && fitBounds !== "off" && - data.some((d) => d.bbox !== undefined) + Object.values(gpxDataMap).some((d) => d.bbox !== undefined) ) { untrack(() => focusTrail(trails[activeTrail])); } @@ -604,7 +669,9 @@ epc?.showProfile(); } showWaypoints(); - addCaretLayer(data[activeTrail]); + if (trail.id && gpxDataMap[trail.id]) { + addCaretLayer(gpxDataMap[trail.id]); + } flyToBounds(); } catch (e) { console.warn(e); @@ -651,10 +718,11 @@ map.getCanvas().style.cursor = "inherit"; if (activeTrail !== null && trails[activeTrail] && !clusterTrails) { + const activeId = trails[activeTrail].id; addStartEndMarkers( trails[activeTrail], - trails[activeTrail].id, - data[activeTrail], + activeId, + activeId ? gpxDataMap[activeId] : null, ); } } @@ -1006,8 +1074,9 @@ if (e.key == "m") { if (trails.length === 1) { - addTrailLayer(trails[0], trails[0].id!, 0, data[0]); - addCaretLayer(data[0]); + const trailId = trails[0].id!; + addTrailLayer(trails[0], trailId, 0, gpxDataMap[trailId]); + addCaretLayer(gpxDataMap[trailId]); } } else if (e.key == "p") { if (showElevation) { diff --git a/web/src/lib/config/map.ts b/web/src/lib/config/map.ts new file mode 100644 index 00000000..e8246779 --- /dev/null +++ b/web/src/lib/config/map.ts @@ -0,0 +1,3 @@ +import { env } from "$env/dynamic/public"; + +export const MAP_MAX_POLYLINES = Number(env.PUBLIC_MAP_MAX_POLYLINES || 100); diff --git a/web/src/lib/i18n/locales/cs.json b/web/src/lib/i18n/locales/cs.json index 7cf90ee5..1db9629b 100644 --- a/web/src/lib/i18n/locales/cs.json +++ b/web/src/lib/i18n/locales/cs.json @@ -264,6 +264,8 @@ "make-one": "Vytvořte si vlastní!", "make-thumbnail": "Vytvořit náhled", "map": "Mapa", + "map-trail-preview-zoom-level": "Zobrazit náhledy tras od úrovně přiblížení", + "show-trail-start-marker": "Zobrazit značku na začátku trasy", "map-style": "Styl mapy", "mark-trail-as-completed": "", "mark-trail-as-completed-modal-text": "", diff --git a/web/src/lib/i18n/locales/de.json b/web/src/lib/i18n/locales/de.json index 3a312a83..be42eaee 100644 --- a/web/src/lib/i18n/locales/de.json +++ b/web/src/lib/i18n/locales/de.json @@ -286,6 +286,8 @@ "make-one": "Neues erstellen!", "make-thumbnail": "Thumbnail festlegen", "map": "Karte", + "map-trail-preview-zoom-level": "Routenlinien anzeigen ab Zoomstufe", + "show-trail-start-marker": "Marker am Start der Route anzeigen", "map-style": "Kartenstil", "mark-trail-as-completed": "Route als abgeschlossen markieren", "mark-trail-as-completed-modal-text": "Möchtest du diese Route als abgeschlossen markieren? Du kannst diesen Status jederzeit wieder ändern.", diff --git a/web/src/lib/i18n/locales/en.json b/web/src/lib/i18n/locales/en.json index 52d26b58..cf5bde1d 100644 --- a/web/src/lib/i18n/locales/en.json +++ b/web/src/lib/i18n/locales/en.json @@ -286,6 +286,8 @@ "make-one": "Make one!", "make-thumbnail": "Make thumbnail", "map": "Map", + "map-trail-preview-zoom-level": "Show trail previews from zoom level", + "show-trail-start-marker": "Show marker at start of trail", "map-style": "Map style", "mark-trail-as-completed": "Mark trail as completed", "mark-trail-as-completed-modal-text": "Would you like to mark this trail as completed? You can change this status again at any time.", diff --git a/web/src/lib/i18n/locales/es.json b/web/src/lib/i18n/locales/es.json index fb3675bd..36f1b825 100644 --- a/web/src/lib/i18n/locales/es.json +++ b/web/src/lib/i18n/locales/es.json @@ -264,6 +264,8 @@ "make-one": "¡Crea uno!", "make-thumbnail": "Generar miniaturas", "map": "Mapa", + "map-trail-preview-zoom-level": "Mostrar vistas previas de rutas a partir del nivel de zoom", + "show-trail-start-marker": "Mostrar marcador al inicio de la ruta", "map-style": "Estilo de mapa", "mark-trail-as-completed": "", "mark-trail-as-completed-modal-text": "", diff --git a/web/src/lib/i18n/locales/eu.json b/web/src/lib/i18n/locales/eu.json index 065448aa..0611b5b8 100644 --- a/web/src/lib/i18n/locales/eu.json +++ b/web/src/lib/i18n/locales/eu.json @@ -264,6 +264,8 @@ "make-one": "Egin bat!", "make-thumbnail": "Egin iruditxoa", "map": "Mapa", + "map-trail-preview-zoom-level": "Erakutsi ibilbideen aurrebistak zoom-mailatik aurrera", + "show-trail-start-marker": "Erakutsi markatzailea ibilbidearen hasieran", "map-style": "Maparen estiloa", "mark-trail-as-completed": "", "mark-trail-as-completed-modal-text": "", diff --git a/web/src/lib/i18n/locales/fr.json b/web/src/lib/i18n/locales/fr.json index 1449efa3..d4e3b623 100644 --- a/web/src/lib/i18n/locales/fr.json +++ b/web/src/lib/i18n/locales/fr.json @@ -264,6 +264,8 @@ "make-one": "Faites-en un !", "make-thumbnail": "Créer une miniature", "map": "Carte", + "map-trail-preview-zoom-level": "Afficher les aperçus d'itinéraires à partir du niveau de zoom", + "show-trail-start-marker": "Afficher un marqueur au début de l'itinéraire", "map-style": "Style de carte", "mark-trail-as-completed": "", "mark-trail-as-completed-modal-text": "", diff --git a/web/src/lib/i18n/locales/hu.json b/web/src/lib/i18n/locales/hu.json index 24f37bb0..0a40e85a 100644 --- a/web/src/lib/i18n/locales/hu.json +++ b/web/src/lib/i18n/locales/hu.json @@ -264,6 +264,8 @@ "make-one": "Készítsen egyet!", "make-thumbnail": "Készítsen miniatűrképet", "map": "Térkép", + "map-trail-preview-zoom-level": "Útvonal-előnézetek megjelenítése ettől a nagyítási szinttől", + "show-trail-start-marker": "Jelölő megjelenítése az útvonal elején", "map-style": "Map style", "mark-trail-as-completed": "", "mark-trail-as-completed-modal-text": "", diff --git a/web/src/lib/i18n/locales/it.json b/web/src/lib/i18n/locales/it.json index 544140cb..57d4d55a 100644 --- a/web/src/lib/i18n/locales/it.json +++ b/web/src/lib/i18n/locales/it.json @@ -264,6 +264,8 @@ "make-one": "Creane uno!", "make-thumbnail": "Imposta miniatura", "map": "Mappa", + "map-trail-preview-zoom-level": "Mostra le anteprime dei percorsi dal livello di zoom", + "show-trail-start-marker": "Mostra un indicatore all'inizio del percorso", "map-style": "Map style", "mark-trail-as-completed": "", "mark-trail-as-completed-modal-text": "", diff --git a/web/src/lib/i18n/locales/nl.json b/web/src/lib/i18n/locales/nl.json index 05bd37f0..7ce6cec0 100644 --- a/web/src/lib/i18n/locales/nl.json +++ b/web/src/lib/i18n/locales/nl.json @@ -264,6 +264,8 @@ "make-one": "Maak er een aan!", "make-thumbnail": "Miniatuur maken", "map": "Kaart", + "map-trail-preview-zoom-level": "Routevoorbeelden tonen vanaf zoomniveau", + "show-trail-start-marker": "Markering aan het begin van de route tonen", "map-style": "Kaartstijl", "mark-trail-as-completed": "", "mark-trail-as-completed-modal-text": "", diff --git a/web/src/lib/i18n/locales/no.json b/web/src/lib/i18n/locales/no.json index 73c498a3..ab9360e3 100644 --- a/web/src/lib/i18n/locales/no.json +++ b/web/src/lib/i18n/locales/no.json @@ -264,6 +264,8 @@ "make-one": "Lag en!", "make-thumbnail": "Lag miniatyrbilde", "map": "Kart", + "map-trail-preview-zoom-level": "Vis ruteforhåndsvisninger fra zoomnivå", + "show-trail-start-marker": "Vis markør ved starten av stien", "map-style": "Kartstil", "mark-trail-as-completed": "", "mark-trail-as-completed-modal-text": "", diff --git a/web/src/lib/i18n/locales/pl.json b/web/src/lib/i18n/locales/pl.json index 151143f5..8f8dfd9c 100644 --- a/web/src/lib/i18n/locales/pl.json +++ b/web/src/lib/i18n/locales/pl.json @@ -264,6 +264,8 @@ "make-one": "Stwórz ją!", "make-thumbnail": "Zrób miniaturkę", "map": "Mapa", + "map-trail-preview-zoom-level": "Pokazuj podglądy tras od poziomu powiększenia", + "show-trail-start-marker": "Pokaż znacznik na początku trasy", "map-style": "Map style", "mark-trail-as-completed": "", "mark-trail-as-completed-modal-text": "", diff --git a/web/src/lib/i18n/locales/pt.json b/web/src/lib/i18n/locales/pt.json index 7cfb3723..36f7af45 100644 --- a/web/src/lib/i18n/locales/pt.json +++ b/web/src/lib/i18n/locales/pt.json @@ -264,6 +264,8 @@ "make-one": "Faz um!", "make-thumbnail": "Faça miniatura", "map": "Mapa", + "map-trail-preview-zoom-level": "Mostrar pré-visualizações de rotas a partir do nível de zoom", + "show-trail-start-marker": "Mostrar marcador no início da rota", "map-style": "Map style", "mark-trail-as-completed": "", "mark-trail-as-completed-modal-text": "", diff --git a/web/src/lib/i18n/locales/ru.json b/web/src/lib/i18n/locales/ru.json index a8e37d1f..371fe72a 100644 --- a/web/src/lib/i18n/locales/ru.json +++ b/web/src/lib/i18n/locales/ru.json @@ -264,6 +264,8 @@ "make-one": "Создайте!", "make-thumbnail": "Сделать миниатюру", "map": "Карта", + "map-trail-preview-zoom-level": "Показывать предпросмотр маршрутов с уровня масштабирования", + "show-trail-start-marker": "Показывать маркер в начале маршрута", "map-style": "Стиль карты", "mark-trail-as-completed": "", "mark-trail-as-completed-modal-text": "", diff --git a/web/src/lib/i18n/locales/zh.json b/web/src/lib/i18n/locales/zh.json index 81e6f181..e6181333 100644 --- a/web/src/lib/i18n/locales/zh.json +++ b/web/src/lib/i18n/locales/zh.json @@ -264,6 +264,8 @@ "make-one": "立刻注册!", "make-thumbnail": "生成缩略图", "map": "地图", + "map-trail-preview-zoom-level": "从该缩放级别开始显示路线预览", + "show-trail-start-marker": "在路线起点显示标记", "map-style": "地图样式", "mark-trail-as-completed": "", "mark-trail-as-completed-modal-text": "", diff --git a/web/src/lib/models/api/settings_schema.ts b/web/src/lib/models/api/settings_schema.ts index a426adb2..a89e22e7 100644 --- a/web/src/lib/models/api/settings_schema.ts +++ b/web/src/lib/models/api/settings_schema.ts @@ -22,8 +22,11 @@ const SettingsCreateSchema = z.object({ lists: z.enum(["public", "private"]) }).optional().nullable(), notifications: z.record(z.enum(Object.values(NotificationType) as [string, ...string[]]), z.object({ web: z.boolean(), email: z.boolean() })).optional().nullable(), - behavior: z.object({ allowAutoGeolocate: z.boolean() }).optional().nullable(), + behavior: z.object({ + allowAutoGeolocate: z.boolean(), + mapClusteringMaxZoom: z.number().optional(), + showTrailStartMarker: z.boolean().optional() + }).optional().nullable(), }) satisfies ZodType -ZodType> export { SettingsCreateSchema }; diff --git a/web/src/lib/models/settings.ts b/web/src/lib/models/settings.ts index f895288f..529c2a46 100644 --- a/web/src/lib/models/settings.ts +++ b/web/src/lib/models/settings.ts @@ -57,6 +57,8 @@ class Settings { export type Behavior = { allowAutoGeolocate: boolean; + mapClusteringMaxZoom?: number; + showTrailStartMarker?: boolean; } diff --git a/web/src/lib/models/trail.ts b/web/src/lib/models/trail.ts index 4a405aeb..e1811d69 100644 --- a/web/src/lib/models/trail.ts +++ b/web/src/lib/models/trail.ts @@ -34,6 +34,7 @@ class Trail { domain?: string; iri?: string; like_count: number; + bounding_box_diagonal?: number; expand?: { tags?: Tag[] category?: Category; @@ -74,8 +75,9 @@ class Trail { comments?: Comment[], shares?: TrailShare[], tags?: Tag[], - description?: string - created?: string + description?: string, + created?: string, + bounding_box_diagonal?: number } ) { @@ -96,6 +98,7 @@ class Trail { this.photos = params?.photos ?? []; this.tags = []; this.gpx = params?.gpx; + this.bounding_box_diagonal = params?.bounding_box_diagonal ?? 0; this.like_count = 0 this.expand = { category: params?.category, @@ -214,6 +217,7 @@ interface TrailSearchResult { domain?: string; iri?: string; gpx: string; + bounding_box_diagonal: number; _geo: { lat: number, lng: number @@ -245,6 +249,7 @@ export const defaultTrailSearchAttributes = [ "like_count", "shares", "iri", + "bounding_box_diagonal", "_geo",] diff --git a/web/src/lib/stores/search_store.ts b/web/src/lib/stores/search_store.ts index c583422b..1ad9ba35 100644 --- a/web/src/lib/stores/search_store.ts +++ b/web/src/lib/stores/search_store.ts @@ -99,7 +99,7 @@ export async function searchTrails(q: string, options: SearchParams): Promise = await r.json(); - return response.hits + return response.hits || [] } export async function searchLocations(q: string, limit?: number, f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch): Promise> { @@ -245,6 +245,10 @@ export async function searchMulti(options: MultiSearchParams): Promise = await r.json(); + if (!response.results) { + return []; + } + if (locationQuery && locationQuery.q !== undefined && locationQuery.q !== null) { const locationsResults = await searchLocations(locationQuery.q, locationQuery.limit) diff --git a/web/src/lib/stores/trail_store.ts b/web/src/lib/stores/trail_store.ts index 628236db..6acd627e 100644 --- a/web/src/lib/stores/trail_store.ts +++ b/web/src/lib/stores/trail_store.ts @@ -1,5 +1,6 @@ import type { SummitLog } from "$lib/models/summit_log"; import type { Tag } from "$lib/models/tag"; +import { MAP_MAX_POLYLINES } from "$lib/config/map"; import { defaultTrailSearchAttributes, Trail, type TrailFilter, type TrailFilterValues, type TrailSearchResult } from "$lib/models/trail"; import type { Waypoint } from "$lib/models/waypoint"; import { APIError } from "$lib/util/api_util"; @@ -14,11 +15,6 @@ import { tags_create } from "./tag_store"; import { currentUser } from "./user_store"; import { waypoints_create, waypoints_delete, waypoints_update } from "./waypoint_store"; -let trails: Trail[] = [] -export const trail: Writable = writable(new Trail("")); - -export const editTrail: Writable = writable(new Trail("")); - export async function trails_index(perPage: number = 21, random: boolean = false, f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch) { const r = await f('/api/v1/trail?' + new URLSearchParams({ "perPage": perPage.toString(), @@ -93,7 +89,50 @@ export async function trails_search_filter(filter: TrailFilter, page: number = 1 } -export async function trails_search_bounding_box(northEast: M.LngLat, southWest: M.LngLat, filter: TrailFilter, page: number = 1, includePolyline: boolean = true) { +const DETAILED_CACHE_MAX_SIZE = Math.max(200, MAP_MAX_POLYLINES * 10); + +let trails: Trail[] = [] +const detailedCache = new Map(); +let detailedCacheKey = ""; + +function getDetailedCache(id: string): Trail | undefined { + const cached = detailedCache.get(id); + if (!cached) { + return undefined; + } + + detailedCache.delete(id); + // Reinsert the entry so Map iteration order tracks recent usage for LRU eviction. + detailedCache.set(id, cached); + return cached; +} + +function setDetailedCache(id: string, trail: Trail) { + detailedCache.delete(id); + detailedCache.set(id, trail); + + while (detailedCache.size > DETAILED_CACHE_MAX_SIZE) { + const oldestKey = detailedCache.keys().next().value; + if (!oldestKey) { + break; + } + detailedCache.delete(oldestKey); + } +} + +export const trail: Writable = writable(new Trail("")); + +export const editTrail: Writable = writable(new Trail("")); + +export async function trails_search_bounding_box( + northEast: M.LngLat, + southWest: M.LngLat, + filter: TrailFilter, + page: number = 1, + zoom: number = 11, + perPage: number = 50, + loadMapData: boolean = true +) { const user = get(currentUser) let filterText: string = ""; @@ -102,36 +141,182 @@ export async function trails_search_bounding_box(northEast: M.LngLat, southWest: filterText = buildFilterText(user, filter, false); } - let r = await fetch("/api/v1/search/trails", { - method: "POST", - body: JSON.stringify({ - q: "", - options: { - filter: [ - `_geoBoundingBox([${northEast.lat}, ${northEast.lng}], [${southWest.lat}, ${southWest.lng}])`, - filterText - ], - sort: [`${filter.sort}:${filter.sortOrder == "+" ? "asc" : "desc"}`,], - attributesToRetrieve: [...defaultTrailSearchAttributes, ...(includePolyline ? ["polyline"] : [])], - hitsPerPage: 500, - page: page - } - }), - }); - const result: { page: number, totalPages: number, hits: Hits } = await r.json(); - - if (result.hits.length == 0) { - trails = []; - return { trails: [], ...result } + let lonFilter = `max_lon >= ${southWest.lng} AND min_lon <= ${northEast.lng}`; + if (southWest.lng > northEast.lng) { + lonFilter = `(max_lon >= ${southWest.lng} OR min_lon <= ${northEast.lng})`; } - const resultTrails: Trail[] = await searchResultToTrailList(result.hits) + const geoFilter = `max_lat >= ${southWest.lat} AND min_lat <= ${northEast.lat} AND ${lonFilter}`; + const listFilter = [filterText, geoFilter].filter(Boolean).join(" AND "); + const cacheKey = JSON.stringify({ + q: filter.q, + filterText, + sort: filter.sort, + sortOrder: filter.sortOrder, + }); + if (cacheKey !== detailedCacheKey) { + detailedCache.clear(); + detailedCacheKey = cacheKey; + } - trails = page > 1 ? trails.concat(resultTrails) : resultTrails + // Step 1: Fetch paginated trails for the side list. + const listResponse = await fetch("/api/v1/search/trails", { + method: "POST", + body: JSON.stringify({ + q: filter.q, + options: { + filter: listFilter, + attributesToRetrieve: defaultTrailSearchAttributes, + sort: [`${filter.sort}:${filter.sortOrder == "+" ? "asc" : "desc"}`], + hitsPerPage: perPage, + page, + }, + }), + }); - return { trails, ...result }; + if (!listResponse.ok) { + const response = await listResponse.json(); + throw new APIError(listResponse.status, response.message, response.detail) + } + const listResult: { page: number, totalPages: number, totalHits?: number, estimatedTotalHits?: number, hits: Hits } = await listResponse.json(); + const listTrails = listResult.hits.length > 0 + ? await searchResultToTrailList(listResult.hits) + : []; + trails = page > 1 ? trails.concat(listTrails) : listTrails; + + if (!loadMapData) { + return { + trails, + mapTrails: [], + clusters: undefined, + estimatedTotalHits: listResult.estimatedTotalHits, + totalHits: listResult.totalHits ?? listResult.estimatedTotalHits, + totalPages: listResult.totalPages + }; + } + + // Step 2: Fetch server-side clusters and unclustered points for the map. + let cr = await fetch("/api/v1/search/trails/cluster", { + method: "POST", + body: JSON.stringify({ + southWest: { lat: southWest.lat, lng: southWest.lng }, + northEast: { lat: northEast.lat, lng: northEast.lng }, + zoom, + q: filter.q, + filterText + }) + }); + + if (!cr.ok) { + const response = await cr.json(); + throw new APIError(cr.status, response.message, response.detail) + } + + const clusterResult = await cr.json(); + const clusterFeatureCollection = clusterResult; + + const unclusteredFeatures = clusterFeatureCollection.features + .filter((f: any) => !f.properties.cluster); + + // Extract IDs of visible unclustered points that are large enough to show details for + const unclusteredIds = unclusteredFeatures + .filter((f: any) => f.properties.is_large) + .map((f: any) => f.properties.id); + + // Step 3: Identify which visible trails are MISSING from the local cache + const missingIds = unclusteredIds.filter((id: string) => !detailedCache.has(id)); + + // Step 4: Only fetch details for missing trails + if (missingIds.length > 0) { + const batchSize = 100; // Meilisearch filter length safety + for (let i = 0; i < missingIds.length; i += batchSize) { + const batch = missingIds.slice(i, i + batchSize); + const detailBatchQuery = { + indexUid: "trails", + q: "", + filter: [`id IN [${batch.map((id: string) => `'${id}'`).join(",")}]`], + attributesToRetrieve: [...defaultTrailSearchAttributes, "polyline"], + hitsPerPage: batchSize, + }; + + const dr = await fetch("/api/v1/search/multi", { + method: "POST", + body: JSON.stringify({ queries: [detailBatchQuery] }), + }); + + if (dr.ok) { + const detailResult = await dr.json(); + const newTrails = await searchResultToTrailList(detailResult.results[0].hits); + // Populate cache + newTrails.forEach(t => { + if (t.id) setDetailedCache(t.id, t) + }); + } + } + } + + // Step 5: Convert unclustered hits to lightweight Trail objects for map popups/previews. + const mapTrails: Trail[] = unclusteredFeatures + .map((f: any) => { + const s = f.properties; + const lat = f.geometry.coordinates[1]; + const lng = f.geometry.coordinates[0]; + + const cached = getDetailedCache(s.id); + if (cached) { + return { + ...cached, + lat, + lon: lng, + bounding_box_diagonal: s.bounding_box_diagonal ?? cached.bounding_box_diagonal, + // Strip polyline for small trails so they don't linger as lines when zoomed out + polyline: s.is_large ? cached.polyline : undefined, + }; + } + + // Lightweight fallback for map markers + const t: Trail & RecordModel = { + id: s.id, + lat: lat, + lon: lng, + name: "", + author: "", + photos: [], + public: true, + completed: false, + summit_logs: [], + waypoints: [], + tags: [], + category: "", + created: new Date(0).toISOString(), + date: new Date(0).toISOString(), + updated: new Date(0).toISOString(), + description: "", + difficulty: "easy", + distance: 0, + duration: 0, + elevation_gain: 0, + elevation_loss: 0, + location: "", + bounding_box_diagonal: s.bounding_box_diagonal ?? 0, + like_count: 0, + collectionId: "trails", + collectionName: "trails", + expand: { author: {} as any } + }; + return t; + }); + + return { + trails, + mapTrails, + clusters: clusterFeatureCollection, + estimatedTotalHits: listResult.estimatedTotalHits ?? clusterResult.totalHits, + totalHits: listResult.totalHits ?? listResult.estimatedTotalHits ?? clusterResult.totalHits, + totalPages: listResult.totalPages + }; } export async function trails_show(id: string, handle?: string, share?: string, loadGPX?: boolean, f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch) { @@ -503,10 +688,12 @@ export async function fetchGPX(trail: { gpx?: string } & Record, f: export async function searchResultToTrailList(hits: Hits): Promise { const trails: Trail[] = [] for (const h of hits) { + const created = Number(h.created || 0); + const date = Number(h.date || 0); const t: Trail & RecordModel = { collectionId: "trails", collectionName: "trails", - updated: new Date(h.created * 1000).toISOString(), + updated: new Date(created * 1000).toISOString(), author: h.author_name, name: h.name, photos: h.thumbnail ? [h.thumbnail] : [], @@ -516,8 +703,8 @@ export async function searchResultToTrailList(hits: Hits): Pr waypoints: [], tags: h.tags ?? [], category: h.category, - created: new Date(h.created * 1000).toISOString(), - date: new Date(h.date * 1000).toISOString(), + created: new Date(created * 1000).toISOString(), + date: new Date(date * 1000).toISOString(), description: h.description, difficulty: h.difficulty == 0 ? "easy" : h.difficulty == 1 ? "moderate" : "difficult", distance: h.distance, @@ -530,6 +717,7 @@ export async function searchResultToTrailList(hits: Hits): Pr location: h.location, gpx: h.gpx, polyline: h.polyline, + bounding_box_diagonal: h.bounding_box_diagonal ?? 0, domain: h.domain, iri: h.iri, thumbnail: 0, diff --git a/web/src/lib/vendor/maplibre-layer-manager/cluster-layer.ts b/web/src/lib/vendor/maplibre-layer-manager/cluster-layer.ts index ef5885bb..1bf6a261 100644 --- a/web/src/lib/vendor/maplibre-layer-manager/cluster-layer.ts +++ b/web/src/lib/vendor/maplibre-layer-manager/cluster-layer.ts @@ -28,6 +28,7 @@ export class ClusterLayer implements BaseLayer { "clusters": { ...this.listeners["clusters"], ...listeners?.["clusters"] }, "unclustered-point": { ...this.listeners["unclustered-point"], ...listeners?.["unclustered-point"] } } + this.spec = { version: 8, name: "clusters", @@ -36,8 +37,6 @@ export class ClusterLayer implements BaseLayer { "cluster-trails": { type: "geojson", data: geojson, - cluster: true, - clusterRadius: 50, } }, layers: [ @@ -45,26 +44,37 @@ export class ClusterLayer implements BaseLayer { id: "clusters", type: "circle", source: "cluster-trails", - filter: ["has", "point_count"], - maxzoom: 10, + filter: ["all", ["!=", ["get", "is_large"], true], [">", ["get", "point_count"], 1]], paint: { "circle-color": "#242734", "circle-radius": [ "step", ["get", "point_count"], 10, + 5, + 12, 10, 15, - 20, - 20, 50, - 25, + 18, 100, - 30, - 200, - 35, + 22, + 500, + 25, ], - "circle-stroke-width": 3, + "circle-stroke-width": 2, + "circle-stroke-color": "#fff", + }, + }, + { + id: "unclustered-point", + type: "circle", + source: "cluster-trails", + filter: ["all", ["!=", ["get", "is_large"], true], ["==", ["get", "point_count"], 1]], + paint: { + "circle-color": "#242734", + "circle-radius": 5, + "circle-stroke-width": 2, "circle-stroke-color": "#fff", }, }, @@ -72,29 +82,17 @@ export class ClusterLayer implements BaseLayer { id: "cluster-count", type: "symbol", source: "cluster-trails", - filter: ["has", "point_count"], - maxzoom: 10, + filter: ["all", ["!=", ["get", "is_large"], true], [">", ["get", "point_count"], 1]], + layout: { + "text-field": ["get", "point_count_abbreviated"], + "text-font": ["Noto Sans Regular"], + "text-size": 11, + "text-allow-overlap": true, + "text-ignore-placement": true, + }, paint: { "text-color": "#fff", }, - layout: { - "text-field": "{point_count_abbreviated}", - "text-font": ["Noto Sans Regular"], - "text-size": 12, - }, - }, - { - id: "unclustered-point", - type: "circle", - source: "cluster-trails", - maxzoom: 10, - filter: ["!", ["has", "point_count"]], - paint: { - "circle-color": "#242734", - "circle-radius": 7, - "circle-stroke-width": 2, - "circle-stroke-color": "#fff", - }, } ] @@ -105,19 +103,26 @@ export class ClusterLayer implements BaseLayer { const features = this.map.queryRenderedFeatures(e.point, { layers: ["clusters"], }); - const clusterId = features[0].properties.cluster_id; - const zoom = await ( - this.map.getSource("cluster-trails") as M.GeoJSONSource - ).getClusterExpansionZoom(clusterId); + const feature = features[0]; + if (!feature) { + return; + } + + const currentZoom = this.map.getZoom(); this.map.flyTo({ - center: (features[0].geometry as any).coordinates, - zoom, + center: (feature.geometry as any).coordinates, + zoom: currentZoom + 2, maxDuration: 3000 }); } private zoomOnUnclusteredPoint(e: MapMouseEvent) { - const coordinates = (e as any).features[0].geometry.coordinates.slice(); + const feature = (e as any).features?.[0]; + if (!feature) { + return; + } + + const coordinates = feature.geometry.coordinates.slice(); this.map.flyTo({ center: coordinates, @@ -125,4 +130,4 @@ export class ClusterLayer implements BaseLayer { maxDuration: 3000 }); } -} \ No newline at end of file +} diff --git a/web/src/lib/vendor/maplibre-layer-manager/maplibre-layer-manager.ts b/web/src/lib/vendor/maplibre-layer-manager/maplibre-layer-manager.ts index 11f8b8a8..44391e89 100644 --- a/web/src/lib/vendor/maplibre-layer-manager/maplibre-layer-manager.ts +++ b/web/src/lib/vendor/maplibre-layer-manager/maplibre-layer-manager.ts @@ -4,6 +4,7 @@ import { baseMapStyles, defaultMapState, type BaseLayer, type MapState } from ". import { OverlayLayer } from "./overlay-layer"; import { OverpassLayer, type OverpassPopupActionFactory } from "./overpass-layer"; +const DEFAULT_GLYPHS = "https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf"; export class LayerManager { @@ -120,6 +121,14 @@ export class LayerManager { } } + const style = this.map.getStyle(); + if ( + !style.glyphs && + layer.spec.layers.some((l) => l.type === "symbol" && l.layout && "text-field" in l.layout) + ) { + this.map.setGlyphs(layer.spec.glyphs ?? DEFAULT_GLYPHS); + } + for (const l of layer.spec.layers) { if (!this.map.getLayer(l.id)) { this.map.addLayer(l) @@ -210,4 +219,4 @@ export class LayerManager { this.addLayer(id, layer) } } -} \ No newline at end of file +} diff --git a/web/src/lib/vendor/maplibre-layer-manager/preview-layer.ts b/web/src/lib/vendor/maplibre-layer-manager/preview-layer.ts index 0ca39a34..40d0c76d 100644 --- a/web/src/lib/vendor/maplibre-layer-manager/preview-layer.ts +++ b/web/src/lib/vendor/maplibre-layer-manager/preview-layer.ts @@ -18,9 +18,10 @@ export class PreviewLayer implements BaseLayer { } }; - constructor(map: M.Map, geojson: GeoJSON.FeatureCollection, listeners?: Record void; onMouseDown?: (e: MapMouseEvent) => void; onEnter?: (e: MapMouseEvent) => void; onLeave?: (e: MapMouseEvent) => void; onMouseMove?: (e: MapMouseEvent) => void; }>) { + constructor(map: M.Map, geojson: GeoJSON.FeatureCollection, options?: { showStartMarker?: boolean, listeners?: Record void; onMouseDown?: (e: MapMouseEvent) => void; onEnter?: (e: MapMouseEvent) => void; onLeave?: (e: MapMouseEvent) => void; onMouseMove?: (e: MapMouseEvent) => void; }> }) { this.map = map; + const listeners = options?.listeners; this.listeners = { "preview": { ...this.listeners["preview"], ...listeners?.["preview"] }, "preview-start-points": { ...this.listeners["preview-start-points"], ...listeners?.["preview-start-points"] } @@ -40,9 +41,11 @@ export class PreviewLayer implements BaseLayer { } })) }; + this.spec = { version: 8, name: "preview", + glyphs: "https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf", sources: { "preview": { type: "geojson", @@ -58,7 +61,6 @@ export class PreviewLayer implements BaseLayer { id: "preview", type: "line", source: "preview", - minzoom: 10, paint: { "line-color": ["get", "color"], "line-width": 5, @@ -68,10 +70,10 @@ export class PreviewLayer implements BaseLayer { id: "preview-start-points", type: "circle", source: "preview-start-points", - minzoom: 10, + filter: ["literal", options?.showStartMarker ?? false], paint: { "circle-color": "#242734", - "circle-radius": 6, + "circle-radius": 5, "circle-stroke-width": 2, "circle-stroke-color": "#fff", }, @@ -80,7 +82,6 @@ export class PreviewLayer implements BaseLayer { id: "preview-direction-carets", type: "symbol", source: "preview", - minzoom: 10, layout: { "symbol-placement": "line", "symbol-spacing": [ @@ -108,4 +109,4 @@ export class PreviewLayer implements BaseLayer { }; } -} \ No newline at end of file +} diff --git a/web/src/lib/vendor/maplibre-layer-manager/trail-layer.ts b/web/src/lib/vendor/maplibre-layer-manager/trail-layer.ts index 28dc5961..b5494dae 100644 --- a/web/src/lib/vendor/maplibre-layer-manager/trail-layer.ts +++ b/web/src/lib/vendor/maplibre-layer-manager/trail-layer.ts @@ -1,4 +1,5 @@ -import type { MapMouseEvent, Marker, StyleSpecification } from "maplibre-gl"; +import type { FilterSpecification, MapMouseEvent, Marker, StyleSpecification } from "maplibre-gl"; +import * as M from "maplibre-gl"; import type { BaseLayer } from "./layers"; export class TrailLayer implements BaseLayer { @@ -7,7 +8,19 @@ export class TrailLayer implements BaseLayer { listeners: Record void; onMouseDown?: (e: MapMouseEvent) => void; onEnter?: (e: MapMouseEvent) => void; onLeave?: (e: MapMouseEvent) => void; onMouseMove?: (e: MapMouseEvent) => void; }> markers: Record = {}; - constructor(id: string, geojson: GeoJSON.FeatureCollection, color: string, listerners?: { onMouseUp?: (e: MapMouseEvent) => void; onMouseDown?: (e: MapMouseEvent) => void; onEnter?: (e: MapMouseEvent) => void; onLeave?: (e: MapMouseEvent) => void; onMouseMove?: (e: MapMouseEvent) => void; }) { + constructor(id: string, geojson: GeoJSON.FeatureCollection, color: string, options?: { + listeners?: { onMouseUp?: (e: MapMouseEvent) => void; onMouseDown?: (e: MapMouseEvent) => void; onEnter?: (e: MapMouseEvent) => void; onLeave?: (e: MapMouseEvent) => void; onMouseMove?: (e: MapMouseEvent) => void; } + }) { + const layer: M.LineLayerSpecification = { + id: id, + type: "line", + source: id, + paint: { + "line-color": color, + "line-width": 5, + }, + }; + this.spec = { version: 8, name: id, @@ -17,18 +30,10 @@ export class TrailLayer implements BaseLayer { data: geojson, } }, - layers: [{ - id: id, - type: "line", - source: id, - paint: { - "line-color": color, - "line-width": 5, - }, - }] + layers: [layer] }; - this.listeners = { [id]: listerners ?? {} } + this.listeners = { [id]: options?.listeners ?? {} } } } \ No newline at end of file diff --git a/web/src/routes/api/v1/search/trails/cluster/+server.ts b/web/src/routes/api/v1/search/trails/cluster/+server.ts new file mode 100644 index 00000000..e5887b78 --- /dev/null +++ b/web/src/routes/api/v1/search/trails/cluster/+server.ts @@ -0,0 +1,128 @@ +import { error, json, type RequestEvent } from "@sveltejs/kit"; +import Supercluster from "supercluster"; +import { MAP_MAX_POLYLINES } from "$lib/config/map"; + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function isValidLngLat(value: any): value is { lat: number; lng: number } { + return isFiniteNumber(value?.lat) && isFiniteNumber(value?.lng); +} + +export async function POST(event: RequestEvent) { + const data = await event.request.json() + const { southWest, northEast, zoom, filterText, q = "" } = data; + + if (!southWest || !northEast || zoom === undefined) { + throw error(400, "Missing required parameters: southWest, northEast, zoom"); + } + + if (!isValidLngLat(southWest) || !isValidLngLat(northEast) || !isFiniteNumber(zoom)) { + throw error(400, "Invalid cluster bounds or zoom"); + } + + try { + let lonFilter = `max_lon >= ${southWest.lng} AND min_lon <= ${northEast.lng}`; + if (southWest.lng > northEast.lng) { + lonFilter = `(max_lon >= ${southWest.lng} OR min_lon <= ${northEast.lng})`; + } + + const geoFilter = `max_lat >= ${southWest.lat} AND min_lat <= ${northEast.lat} AND ${lonFilter}`; + + const summaryQuery = { + indexUid: "trails", + q, + filter: [geoFilter, filterText].filter(f => f && f !== ""), + attributesToRetrieve: ["id", "_geo", "bounding_box_diagonal"], + limit: 10000, + }; + + const r = await event.locals.ms.multiSearch({ + queries: [summaryQuery] + }); + + const hits = r.results[0].hits; + + const clusteringMaxZoom = event.locals.settings?.behavior?.mapClusteringMaxZoom ?? 11; + const forceClustering = zoom < clusteringMaxZoom; + + // Dynamic Threshold: Sort by diagonal and pick top N for polylines + const sortedHits = [...hits].sort((a: any, b: any) => (b.bounding_box_diagonal ?? 0) - (a.bounding_box_diagonal ?? 0)); + + const largeHits = forceClustering ? [] : sortedHits.slice(0, MAP_MAX_POLYLINES); + const smallHits = forceClustering ? sortedHits : sortedHits.slice(MAP_MAX_POLYLINES); + + const smallFeatures: GeoJSON.Feature[] = smallHits.map((h: any) => ({ + type: "Feature", + properties: { + id: h.id, + bounding_box_diagonal: h.bounding_box_diagonal ?? 0 + }, + geometry: { + type: "Point", + coordinates: [h._geo.lng, h._geo.lat] + } + })); + + const index = new Supercluster({ + radius: 40, // Less aggressive clustering + maxZoom: 16, + }); + + index.load(smallFeatures); + + const bbox: [number, number, number, number] = southWest.lng > northEast.lng + ? [-180, southWest.lat, 180, northEast.lat] + : [southWest.lng, southWest.lat, northEast.lng, northEast.lat]; + + const clusters = index.getClusters( + bbox, + Math.floor(zoom) + ); + + function abbreviateCount(count: number): string { + if (count >= 1000) { + return (count / 1000).toFixed(1) + "k"; + } + return count.toString(); + } + + const normalizedSmallFeatures = clusters.map((f: any) => { + if (f.properties.cluster) { + f.properties.point_count_abbreviated = abbreviateCount(f.properties.point_count); + } else { + f.properties.point_count = 1; + f.properties.point_count_abbreviated = "1"; + f.properties.is_large = false; + } + return f; + }); + + // Step 3: Individual markers for large trails (NOT clustered) + const largeFeatures: GeoJSON.Feature[] = largeHits.map((h: any) => ({ + type: "Feature", + properties: { + id: h.id, + cluster: false, + is_large: true, + point_count: 1, + point_count_abbreviated: "1", + bounding_box_diagonal: h.bounding_box_diagonal ?? 0 + }, + geometry: { + type: "Point", + coordinates: [h._geo.lng, h._geo.lat] // Back to stable anchor point + } + })); + + return json({ + type: "FeatureCollection", + features: [...normalizedSmallFeatures, ...largeFeatures], + totalHits: r.results[0].estimatedTotalHits ?? r.results[0].totalHits + }); + } catch (e: any) { + console.error("Clustering error:", e); + throw error(e.httpStatus || 500, e.message ?? "Unable to cluster trails"); + } +} diff --git a/web/src/routes/map/+page.svelte b/web/src/routes/map/+page.svelte index de73ddc7..2c2e555e 100644 --- a/web/src/routes/map/+page.svelte +++ b/web/src/routes/map/+page.svelte @@ -29,11 +29,14 @@ import { trails_search_bounding_box } from "$lib/stores/trail_store"; import { getIconForLocation } from "$lib/util/icon_util"; import type { Snapshot } from "@sveltejs/kit"; + import type { FeatureCollection } from "geojson"; import * as M from "maplibre-gl"; import { _ } from "svelte-i18n"; import { slide } from "svelte/transition"; let trails: Trail[] = $state([]); + let mapTrails: Trail[] = $state([]); + let clusters: FeatureCollection | undefined = $state(); let map: M.Map | undefined = $state(); let mapWithElevation: MapWithElevationMaplibre | undefined = $state(); @@ -46,8 +49,6 @@ const maxBoundingBox: TrailBoundingBox = page.data.boundingBox; const settings: Settings = page.data.settings; - const MIN_ZOOM = 10; - let loading: boolean = $state(true); let loadingNextPage: boolean = false; @@ -55,6 +56,7 @@ page: 1, totalPages: 1, }; + let searchRequestId = 0; const sortOptions: SelectItem[] = [ { text: $_("name"), value: "name" }, @@ -98,19 +100,19 @@ ], }); - const trailItems = r[0].hits.map((t: TrailSearchResult) => ({ + const trailItems = (r[0]?.hits || []).map((t: TrailSearchResult) => ({ text: t.name, description: `Trail ${t.location.length ? ", " + t.location : ""}`, value: `@${t.author_name}${t.domain ? `@${t.domain}` : ""}/${t.id}`, icon: "route", })); - const listItems = r[1].hits.map((t: ListSearchResult) => ({ + const listItems = (r[1]?.hits || []).map((t: ListSearchResult) => ({ text: t.name, description: `List, ${t.trails} ${$_("trail", { values: { n: t.trails } })}`, value: t.id, icon: "layer-group", })); - const cityItems = r[2].hits.map((c: LocationSearchResult) => ({ + const cityItems = (r[2]?.hits || []).map((c: LocationSearchResult) => ({ text: c.name, description: c.description, value: c, @@ -135,7 +137,11 @@ northEast: M.LngLat, southWest: M.LngLat, reset: boolean = true, + loadMapData: boolean = true, ) { + const requestId = + reset || loadMapData ? ++searchRequestId : searchRequestId; + if (reset) { pagination.page = 1; loading = true; @@ -146,11 +152,23 @@ southWest, filter, pagination.page, - (map?.getZoom() ?? 0) > MIN_ZOOM, + map?.getZoom(), + 50, + loadMapData, ); + + if (requestId !== searchRequestId) { + return false; + } + pagination.totalPages = trailsInBox.totalPages; trails = trailsInBox.trails; + if (loadMapData) { + mapTrails = trailsInBox.mapTrails; + clusters = trailsInBox.clusters; + } loading = false; + return true; } function handleTrailCardMouseEnter(trail: Trail) { @@ -190,33 +208,57 @@ await searchTrails(bounds.getNorthEast(), bounds.getSouthWest()); } + let moveTimeout: ReturnType | undefined; async function handleMapMove() { if (!map) { return; } - const bounds = map.getBounds(); - const normalizedBounds = { - southWest: new M.LngLat( - ((((bounds.getSouthWest().lng + 180) % 360) + 360) % 360) - 180, - bounds.getSouthWest().lat, - ), - northEast: new M.LngLat( - ((((bounds.getNorthEast().lng + 180) % 360) + 360) % 360) - 180, - bounds.getNorthEast().lat, - ), - }; - await searchTrails( - normalizedBounds.northEast, - normalizedBounds.southWest, - ); + if (moveTimeout) { + clearTimeout(moveTimeout); + } + moveTimeout = setTimeout(async () => { + const bounds = map!.getBounds(); + const west = bounds.getWest(); + const east = bounds.getEast(); + const north = bounds.getNorth(); + const south = bounds.getSouth(); - page.url.searchParams.set("tl_lat", bounds.getNorth().toString()); - page.url.searchParams.set("tl_lon", bounds.getEast().toString()); - page.url.searchParams.set("br_lat", bounds.getSouth().toString()); - page.url.searchParams.set("br_lon", bounds.getWest().toString()); + let normalizedSW: M.LngLat; + let normalizedNE: M.LngLat; - goto(`?${page.url.searchParams.toString()}`); + if (east - west >= 360) { + // Global view + normalizedSW = new M.LngLat(-180, south); + normalizedNE = new M.LngLat(180, north); + } else { + // Handle wrap-around + normalizedSW = new M.LngLat( + ((((west + 180) % 360) + 360) % 360) - 180, + south, + ); + normalizedNE = new M.LngLat( + ((((east + 180) % 360) + 360) % 360) - 180, + north, + ); + } + + const applied = await searchTrails(normalizedNE, normalizedSW); + if (!applied) { + return; + } + + page.url.searchParams.set("tl_lat", north.toString()); + page.url.searchParams.set("tl_lon", east.toString()); + page.url.searchParams.set("br_lat", south.toString()); + page.url.searchParams.set("br_lon", west.toString()); + + goto(`?${page.url.searchParams.toString()}`, { + replaceState: true, + noScroll: true, + keepFocus: true, + }); + }, 200); } function handleMapInit() { @@ -314,7 +356,7 @@ } pagination.page += 1; const bounds = map.getBounds(); - await searchTrails(bounds.getNorthEast(), bounds.getSouthWest(), false); + await searchTrails(bounds.getNorthEast(), bounds.getSouthWest(), false, false); } @@ -391,7 +433,7 @@ {#if trails.length == 0} {/if} - {#each trails as trail, i} + {#each trails.filter(t => t.name !== "") as trail, i}
diff --git a/web/src/routes/map/+page.ts b/web/src/routes/map/+page.ts index aa53828a..35ffe098 100644 --- a/web/src/routes/map/+page.ts +++ b/web/src/routes/map/+page.ts @@ -3,7 +3,7 @@ import { categories_index } from "$lib/stores/category_store"; import { trails_get_bounding_box, trails_get_filter_values } from "$lib/stores/trail_store"; import type { ServerLoad } from "@sveltejs/kit"; -export const load: ServerLoad = async ({ params, locals, fetch }) => { +export const load: ServerLoad = async ({ fetch }) => { const boundingBox = await trails_get_bounding_box(fetch); const filterValues = await trails_get_filter_values(fetch); diff --git a/web/src/routes/settings/map/+page.svelte b/web/src/routes/settings/map/+page.svelte index 79e52da2..3d9c9c14 100644 --- a/web/src/routes/settings/map/+page.svelte +++ b/web/src/routes/settings/map/+page.svelte @@ -7,6 +7,7 @@ type SelectItem, } from "$lib/components/base/select.svelte"; + import Slider from "$lib/components/base/slider.svelte"; import TextField from "$lib/components/base/text_field.svelte"; import { searchLocations, @@ -15,7 +16,7 @@ import { settings_update } from "$lib/stores/settings_store"; import { currentUser } from "$lib/stores/user_store"; import { getIconForLocation } from "$lib/util/icon_util"; - import { onMount } from "svelte"; + import { onMount, untrack } from "svelte"; import { _ } from "svelte-i18n"; import Toggle from "$lib/components/base/toggle.svelte"; import { show_toast } from "$lib/stores/toast_store.svelte.js"; @@ -25,20 +26,40 @@ let allowAutoGeolocate = $state( page.data.settings.behavior?.allowAutoGeolocate ?? false, ); + let mapClusteringMaxZoom = $state( + page.data.settings.behavior?.mapClusteringMaxZoom ?? 11, + ); + let showTrailStartMarker = $state( + page.data.settings.behavior?.showTrailStartMarker ?? false, + ); - async function handleAllowAutoGeolocateChange() { + $effect(() => { + const b = page.data.settings?.behavior; + if (b) { + untrack(() => { + allowAutoGeolocate = b.allowAutoGeolocate ?? false; + mapClusteringMaxZoom = b.mapClusteringMaxZoom ?? 11; + showTrailStartMarker = b.showTrailStartMarker ?? false; + }); + } + }); + + async function handleBehaviorChange() { if (!settings) { return; } try { - if (!settings.behavior) { - settings.behavior = { allowAutoGeolocate: allowAutoGeolocate }; - } else { - settings.behavior.allowAutoGeolocate = allowAutoGeolocate; - } + const updatedSettings = { + ...settings, + behavior: { + allowAutoGeolocate: allowAutoGeolocate, + mapClusteringMaxZoom: Number(mapClusteringMaxZoom), + showTrailStartMarker: showTrailStartMarker, + }, + }; - await settings_update(settings); + await settings_update(updatedSettings); } catch (e) { show_toast({ type: "error", @@ -166,16 +187,50 @@ >
{/if} -
-

{$_("allow-auto-geolocate")}

-
- +
+
+

{$_("allow-auto-geolocate")}

+
+ +
+
+
+

{$_("map-trail-preview-zoom-level")}

+
+ + {Math.round(Number(mapClusteringMaxZoom))} + +
+ +
+
+
+
+

{$_("show-trail-start-marker")}

+
+ +
From 2fca0bdc7e214b816b04fcd841252aa47e3b78fc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 7 Jun 2026 13:27:06 +0200 Subject: [PATCH 17/26] Bump softprops/action-gh-release from 2 to 3 (#952) Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3. - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3) --- updated-dependencies: - dependency-name: softprops/action-gh-release dependency-version: '3' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: slothful-vassal <89943360+slothful-vassal@users.noreply.github.com> --- .github/workflows/release.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 895540c5..a519e147 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -89,7 +89,7 @@ jobs: echo 'EOF' >> $GITHUB_OUTPUT - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ needs.publish.outputs.version }} body: ${{ steps.changelog.outputs.changelog }} From 1540113381f4efaf4f3a7803b07a0c4e31010347 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 7 Jun 2026 13:38:44 +0200 Subject: [PATCH 18/26] Bump the docker-backward-compatible group across 1 directory with 8 updates (#1053) Bumps the docker-backward-compatible group with 7 updates in the /docs directory: | Package | From | To | | --- | --- | --- | | [@astrojs/check](https://github.com/withastro/astro/tree/HEAD/packages/language-tools/astro-check) | `0.9.8` | `0.9.9` | | [@astrojs/node](https://github.com/withastro/astro/tree/HEAD/packages/integrations/node) | `10.1.1` | `10.1.2` | | [@astrojs/starlight](https://github.com/withastro/starlight/tree/HEAD/packages/starlight) | `0.38.4` | `0.39.2` | | [@astrojs/svelte](https://github.com/withastro/astro/tree/HEAD/packages/integrations/svelte) | `8.1.1` | `8.1.2` | | [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.2.4` | `4.3.0` | | [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro) | `6.3.2` | `6.4.2` | | [starlight-openapi](https://github.com/HiDeoo/starlight-openapi/tree/HEAD/packages/starlight-openapi) | `0.25.0` | `0.25.3` | Updates `@astrojs/check` from 0.9.8 to 0.9.9 - [Release notes](https://github.com/withastro/astro/releases) - [Changelog](https://github.com/withastro/astro/blob/main/packages/language-tools/astro-check/CHANGELOG.md) - [Commits](https://github.com/withastro/astro/commits/@astrojs/check@0.9.9/packages/language-tools/astro-check) Updates `@astrojs/node` from 10.1.1 to 10.1.2 - [Release notes](https://github.com/withastro/astro/releases) - [Changelog](https://github.com/withastro/astro/blob/main/packages/integrations/node/CHANGELOG.md) - [Commits](https://github.com/withastro/astro/commits/@astrojs/node@10.1.2/packages/integrations/node) Updates `@astrojs/starlight` from 0.38.4 to 0.39.2 - [Release notes](https://github.com/withastro/starlight/releases) - [Changelog](https://github.com/withastro/starlight/blob/main/packages/starlight/CHANGELOG.md) - [Commits](https://github.com/withastro/starlight/commits/@astrojs/starlight@0.39.2/packages/starlight) Updates `@astrojs/svelte` from 8.1.1 to 8.1.2 - [Release notes](https://github.com/withastro/astro/releases) - [Changelog](https://github.com/withastro/astro/blob/main/packages/integrations/svelte/CHANGELOG.md) - [Commits](https://github.com/withastro/astro/commits/@astrojs/svelte@8.1.2/packages/integrations/svelte) Updates `@tailwindcss/vite` from 4.2.4 to 4.3.0 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.0/packages/@tailwindcss-vite) Updates `astro` from 6.3.2 to 6.4.2 - [Release notes](https://github.com/withastro/astro/releases) - [Changelog](https://github.com/withastro/astro/blob/main/packages/astro/CHANGELOG.md) - [Commits](https://github.com/withastro/astro/commits/astro@6.4.2/packages/astro) Updates `starlight-openapi` from 0.25.0 to 0.25.3 - [Release notes](https://github.com/HiDeoo/starlight-openapi/releases) - [Changelog](https://github.com/HiDeoo/starlight-openapi/blob/main/packages/starlight-openapi/CHANGELOG.md) - [Commits](https://github.com/HiDeoo/starlight-openapi/commits/starlight-openapi@0.25.3/packages/starlight-openapi) Updates `tailwindcss` from 4.2.4 to 4.3.0 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.0/packages/tailwindcss) --- updated-dependencies: - dependency-name: "@astrojs/check" dependency-version: 0.9.9 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: docker-backward-compatible - dependency-name: "@astrojs/node" dependency-version: 10.1.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: docker-backward-compatible - dependency-name: "@astrojs/starlight" dependency-version: 0.39.2 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: docker-backward-compatible - dependency-name: "@astrojs/svelte" dependency-version: 8.1.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: docker-backward-compatible - dependency-name: "@tailwindcss/vite" dependency-version: 4.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: docker-backward-compatible - dependency-name: astro dependency-version: 6.4.2 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: docker-backward-compatible - dependency-name: starlight-openapi dependency-version: 0.25.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: docker-backward-compatible - dependency-name: tailwindcss dependency-version: 4.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: docker-backward-compatible ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/package-lock.json | 668 +++++++++++++++++++---------------------- docs/package.json | 14 +- 2 files changed, 313 insertions(+), 369 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index ec0980e6..ea5bde44 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -8,17 +8,17 @@ "name": "docs", "version": "0.19.2", "dependencies": { - "@astrojs/check": "^0.9.8", - "@astrojs/node": "^10.1.1", - "@astrojs/starlight": "^0.38.4", + "@astrojs/check": "^0.9.9", + "@astrojs/node": "^10.1.2", + "@astrojs/starlight": "^0.39.2", "@astrojs/starlight-tailwind": "^5.0.0", - "@astrojs/svelte": "^8.1.1", + "@astrojs/svelte": "^8.1.2", "@fontsource/ibm-plex-mono": "^5.2.7", "@fontsource/ibm-plex-sans": "^5.2.8", - "@tailwindcss/vite": "^4.2.4", - "astro": "^6.3.2", + "@tailwindcss/vite": "^4.3.0", + "astro": "^6.4.2", "sharp": "^0.34.5", - "starlight-openapi": "^0.25.0", + "starlight-openapi": "^0.25.3", "svelte": "^5.56.0", "tailwindcss": "^4.1.10", "typescript": "^5.9.3" @@ -41,12 +41,12 @@ } }, "node_modules/@astrojs/check": { - "version": "0.9.8", - "resolved": "https://registry.npmjs.org/@astrojs/check/-/check-0.9.8.tgz", - "integrity": "sha512-LDng8446QLS5ToKjRHd3bgUdirvemVVExV7nRyJfW2wV36xuv7vDxwy5NWN9zqeSEDgg0Tv84sP+T3yEq+Zlkw==", + "version": "0.9.9", + "resolved": "https://registry.npmjs.org/@astrojs/check/-/check-0.9.9.tgz", + "integrity": "sha512-A5UW8uIuErLWEoRQvzgXpO1gTjUFtK8r7nU2Z7GewAMxUb7bPvpk11qaKKgxqXlHJWlAvaaxy+Xg28A6bmQ1Tg==", "license": "MIT", "dependencies": { - "@astrojs/language-server": "^2.16.5", + "@astrojs/language-server": "^2.16.7", "chokidar": "^4.0.3", "kleur": "^4.1.5", "yargs": "^17.7.2" @@ -55,7 +55,7 @@ "astro-check": "bin/astro-check.js" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": "^5.0.0 || ^6.0.0" } }, "node_modules/@astrojs/compiler": { @@ -65,29 +65,36 @@ "license": "MIT" }, "node_modules/@astrojs/internal-helpers": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.8.0.tgz", - "integrity": "sha512-J56GrhEiV+4dmrGLPNOl2pZjpHXAndWVyiVDYGDuw6MWKpBSEMLdFxHzeM/6sqaknw9M+HFfHZAcvi3OfT3D/w==", + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.10.0.tgz", + "integrity": "sha512-Ry2R3VPeIN4uPCSA4xQc+e+vsJXkalKpEbDc07hV+a/o5Bs2N/s/uDcPJH/05L19DKh9tAy7e6JM3YZ6Cxfezw==", "license": "MIT", "dependencies": { - "picomatch": "^4.0.3" + "@types/hast": "^3.0.4", + "@types/mdast": "^4.0.4", + "js-yaml": "^4.1.1", + "picomatch": "^4.0.4", + "retext-smartypants": "^6.2.0", + "shiki": "^4.0.2", + "smol-toml": "^1.6.0", + "unified": "^11.0.5" } }, "node_modules/@astrojs/language-server": { - "version": "2.16.6", - "resolved": "https://registry.npmjs.org/@astrojs/language-server/-/language-server-2.16.6.tgz", - "integrity": "sha512-N990lu+HSFiG57owR0XBkr02BYMgiLCshLf+4QG4v6jjSWkBeQGnzqi+E1L08xFPPJ7eEeXnxPXGLaVv5pa4Ug==", + "version": "2.16.10", + "resolved": "https://registry.npmjs.org/@astrojs/language-server/-/language-server-2.16.10.tgz", + "integrity": "sha512-87VQ/5GSdHlRnUA+hGuerYyIGAj+9RbZmATyuKLEUePinUXhQ5YkRnRrHhOD9sSi5JOErLjrLkHnfZFEvGrV8w==", "license": "MIT", "dependencies": { "@astrojs/compiler": "^2.13.1", - "@astrojs/yaml2ts": "^0.2.3", + "@astrojs/yaml2ts": "^0.2.4", "@jridgewell/sourcemap-codec": "^1.5.5", "@volar/kit": "~2.4.28", "@volar/language-core": "~2.4.28", "@volar/language-server": "~2.4.28", "@volar/language-service": "~2.4.28", "muggle-string": "^0.4.1", - "tinyglobby": "^0.2.15", + "tinyglobby": "^0.2.16", "volar-service-css": "0.0.70", "volar-service-emmet": "0.0.70", "volar-service-html": "0.0.70", @@ -115,17 +122,16 @@ } }, "node_modules/@astrojs/markdown-remark": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-7.0.1.tgz", - "integrity": "sha512-zAfLJmn07u9SlDNNHTpjv0RT4F8D4k54NR7ReRas8CO4OeGoqSvOuKwqCFg2/cqN3wHwdWlK/7Yv/lMXlhVIaw==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-7.2.0.tgz", + "integrity": "sha512-+YxmVQu1Bd+MFfSzjq1rOJvD9+nIOJzz5YIIhdIH01RrxRkKbyKoEgyIqP3yv51MhzMDgd79QaPv+kCVPT8vHw==", "license": "MIT", "dependencies": { - "@astrojs/internal-helpers": "0.8.0", - "@astrojs/prism": "4.0.1", + "@astrojs/internal-helpers": "0.10.0", + "@astrojs/prism": "4.0.2", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", - "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", @@ -133,8 +139,6 @@ "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", - "shiki": "^4.0.0", - "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.1.0", @@ -143,12 +147,12 @@ } }, "node_modules/@astrojs/mdx": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@astrojs/mdx/-/mdx-5.0.2.tgz", - "integrity": "sha512-0as6odPH9ZQhS3pdH9dWmVOwgXuDtytJiE4VvYgR0lSFBvF4PSTyE0HdODHm/d7dBghvWTPc2bQaBm4y4nTBNw==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@astrojs/mdx/-/mdx-5.0.6.tgz", + "integrity": "sha512-4dKe0ZMmqujofPNDHahzClkwinn9f8jHPcaXcgdGvPAlboD2mjzkUCofli2cBnxYAkdfhC6d50gBJ8i/cH8gHw==", "license": "MIT", "dependencies": { - "@astrojs/markdown-remark": "7.0.1", + "@astrojs/markdown-remark": "7.1.2", "@mdx-js/mdx": "^3.1.1", "acorn": "^8.16.0", "es-module-lexer": "^2.0.0", @@ -169,21 +173,7 @@ "astro": "^6.0.0" } }, - "node_modules/@astrojs/node": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@astrojs/node/-/node-10.1.1.tgz", - "integrity": "sha512-kCRbxconkgPpY4vR0GS7exovWEiCbxXLarsp+JeKixyDNf+fKN6v7jXDL8KdQgrzjhy131Kvl+GGGX8jGd8adA==", - "license": "MIT", - "dependencies": { - "@astrojs/internal-helpers": "0.9.1", - "send": "^1.2.1", - "server-destroy": "^1.0.1" - }, - "peerDependencies": { - "astro": "^6.3.0" - } - }, - "node_modules/@astrojs/node/node_modules/@astrojs/internal-helpers": { + "node_modules/@astrojs/mdx/node_modules/@astrojs/internal-helpers": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.9.1.tgz", "integrity": "sha512-1pWuARqYom/TzuU3+0ZugsTrKlUydWKuULmDqSMTuonY+9IRDUEGKX/8PXQ1nBxRq3w85uGtd9q9SXfqEldMIQ==", @@ -192,10 +182,53 @@ "picomatch": "^4.0.4" } }, + "node_modules/@astrojs/mdx/node_modules/@astrojs/markdown-remark": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-7.1.2.tgz", + "integrity": "sha512-caXZ4Dc2St2dW8luEg22GlP0gupLdztCTQE4EzZOxW1pqWXz9mbeJEuHUkgDYcKWW8tjIHkydYDhWLVoxJ327Q==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.9.1", + "@astrojs/prism": "4.0.2", + "github-slugger": "^2.0.0", + "hast-util-from-html": "^2.0.3", + "hast-util-to-text": "^4.0.2", + "js-yaml": "^4.1.1", + "mdast-util-definitions": "^6.0.0", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "remark-smartypants": "^3.0.2", + "retext-smartypants": "^6.2.0", + "shiki": "^4.0.0", + "smol-toml": "^1.6.0", + "unified": "^11.0.5", + "unist-util-remove-position": "^5.0.0", + "unist-util-visit": "^5.1.0", + "unist-util-visit-parents": "^6.0.2", + "vfile": "^6.0.3" + } + }, + "node_modules/@astrojs/node": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@astrojs/node/-/node-10.1.2.tgz", + "integrity": "sha512-6MtNb0iEdZw3m7Dva8V3qo1y9MVoVJKx9dQiNGvy4Ncg5yfaAwGa+9mrDJ3gWCWUtESZi8cvqVshy92sFufg1Q==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.10.0", + "send": "^1.2.1", + "server-destroy": "^1.0.1" + }, + "peerDependencies": { + "astro": "^6.3.0" + } + }, "node_modules/@astrojs/prism": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.0.1.tgz", - "integrity": "sha512-nksZQVjlferuWzhPsBpQ1JE5XuKAf1id1/9Hj4a9KG4+ofrlzxUUwX4YGQF/SuDiuiGKEnzopGOt38F3AnVWsQ==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.0.2.tgz", + "integrity": "sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==", "license": "MIT", "dependencies": { "prismjs": "^1.30.0" @@ -205,9 +238,9 @@ } }, "node_modules/@astrojs/sitemap": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.7.1.tgz", - "integrity": "sha512-IzQqdTeskaMX+QDZCzMuJIp8A8C1vgzMBp/NmHNnadepHYNHcxQdGLQZYfkbd2EbRXUfOS+UDIKx8sKg0oWVdw==", + "version": "3.7.3", + "resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.7.3.tgz", + "integrity": "sha512-f8euLVsyeAmAkSm/1M2Kb8sL8byQmfgbvBNaHFItCheTj/IpiJYSEWVcqDHZ/yEHxiS7+w87mQkzwZaPHmk5GA==", "license": "MIT", "dependencies": { "sitemap": "^9.0.0", @@ -216,39 +249,39 @@ } }, "node_modules/@astrojs/starlight": { - "version": "0.38.4", - "resolved": "https://registry.npmjs.org/@astrojs/starlight/-/starlight-0.38.4.tgz", - "integrity": "sha512-TGFIr2aVC+gcZCPQzJOO4ZnA/yL3jRnsUDcKlVdEhxhxaOQnWr9lZ9MRScg9zU6uh3HVeZAmmjkLCdTlHdcaZA==", + "version": "0.39.2", + "resolved": "https://registry.npmjs.org/@astrojs/starlight/-/starlight-0.39.2.tgz", + "integrity": "sha512-vlw+bwnjtf5buCTUtLU7JfV6D3knslxqnspr6LKs6hfRuFZiyr5hT44F7GyDqR9FKANUqFxnIzWM81F1k/kOUA==", "license": "MIT", "dependencies": { - "@astrojs/markdown-remark": "^7.0.0", - "@astrojs/mdx": "^5.0.0", - "@astrojs/sitemap": "^3.7.1", + "@astrojs/markdown-remark": "^7.1.1", + "@astrojs/mdx": "^5.0.4", + "@astrojs/sitemap": "^3.7.2", "@pagefind/default-ui": "^1.3.0", "@types/hast": "^3.0.4", "@types/js-yaml": "^4.0.9", "@types/mdast": "^4.0.4", - "astro-expressive-code": "^0.41.6", + "astro-expressive-code": "^0.42.0", "bcp-47": "^2.1.0", - "hast-util-from-html": "^2.0.1", - "hast-util-select": "^6.0.2", - "hast-util-to-string": "^3.0.0", - "hastscript": "^9.0.0", - "i18next": "^23.11.5", - "js-yaml": "^4.1.0", + "hast-util-from-html": "^2.0.3", + "hast-util-select": "^6.0.4", + "hast-util-to-string": "^3.0.1", + "hastscript": "^9.0.1", + "i18next": "^26.0.7", + "js-yaml": "^4.1.1", "klona": "^2.0.6", - "magic-string": "^0.30.17", - "mdast-util-directive": "^3.0.0", - "mdast-util-to-markdown": "^2.1.0", + "magic-string": "^0.30.21", + "mdast-util-directive": "^3.1.0", + "mdast-util-to-markdown": "^2.1.2", "mdast-util-to-string": "^4.0.0", "pagefind": "^1.3.0", - "rehype": "^13.0.1", - "rehype-format": "^5.0.0", - "remark-directive": "^3.0.0", + "rehype": "^13.0.2", + "rehype-format": "^5.0.1", + "remark-directive": "^4.0.0", "ultrahtml": "^1.6.0", "unified": "^11.0.5", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.2" + "unist-util-visit": "^5.1.0", + "vfile": "^6.0.3" }, "peerDependencies": { "astro": "^6.0.0" @@ -265,9 +298,9 @@ } }, "node_modules/@astrojs/svelte": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/@astrojs/svelte/-/svelte-8.1.1.tgz", - "integrity": "sha512-/9sgVenHRoGRhENjLA585qPBFIECl9u7LXu0H7PeB0LW98Pa2nxvvei14CszuaYhwMoeXwDKAX211M3L6WIObw==", + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/@astrojs/svelte/-/svelte-8.1.2.tgz", + "integrity": "sha512-L4eBoTY+DdgyH1g8pmKJxidRSvdk+NbkyJt5+f8EtdyJtPdxogeCyG3eEDmlIqqXxzNqLg7k/tYCjfBQ4kDMRA==", "license": "MIT", "dependencies": { "@sveltejs/vite-plugin-svelte": "^6.2.4", @@ -301,12 +334,12 @@ } }, "node_modules/@astrojs/yaml2ts": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@astrojs/yaml2ts/-/yaml2ts-0.2.3.tgz", - "integrity": "sha512-PJzRmgQzUxI2uwpdX2lXSHtP4G8ocp24/t+bZyf5Fy0SZLSF9f9KXZoMlFM/XCGue+B0nH/2IZ7FpBYQATBsCg==", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@astrojs/yaml2ts/-/yaml2ts-0.2.4.tgz", + "integrity": "sha512-8oddpOae35pJsXPQXhTkM0ypfKPskVsh2bCxRtbf7e+/Epw2nReakFYpLKjZMEr75CsoF203PMnCocpfz0s69A==", "license": "MIT", "dependencies": { - "yaml": "^2.8.2" + "yaml": "^2.8.3" } }, "node_modules/@babel/code-frame": { @@ -900,9 +933,9 @@ } }, "node_modules/@expressive-code/core": { - "version": "0.41.7", - "resolved": "https://registry.npmjs.org/@expressive-code/core/-/core-0.41.7.tgz", - "integrity": "sha512-ck92uZYZ9Wba2zxkiZLsZGi9N54pMSAVdrI9uW3Oo9AtLglD5RmrdTwbYPCT2S/jC36JGB2i+pnQtBm/Ib2+dg==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@expressive-code/core/-/core-0.42.0.tgz", + "integrity": "sha512-MN11+9nfmaC7sYu2BZJXAXqwkBRt8t1xTSqP+Ti1NfTEskgl6xUnzDxoaiQkg0BMzpglA0pys4dpDKquP/cyIw==", "license": "MIT", "dependencies": { "@ctrl/tinycolor": "^4.0.4", @@ -917,108 +950,31 @@ } }, "node_modules/@expressive-code/plugin-frames": { - "version": "0.41.7", - "resolved": "https://registry.npmjs.org/@expressive-code/plugin-frames/-/plugin-frames-0.41.7.tgz", - "integrity": "sha512-diKtxjQw/979cTglRFaMCY/sR6hWF0kSMg8jsKLXaZBSfGS0I/Hoe7Qds3vVEgeoW+GHHQzMcwvgx/MOIXhrTA==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@expressive-code/plugin-frames/-/plugin-frames-0.42.0.tgz", + "integrity": "sha512-XtkPm+941Uta7Y+81Acv+OA/20F1NJmJhCX6UYGKpqEIGqplNh3PTOhcURp6tcruhlzJcWcvpWy6Oigz3SrjqA==", "license": "MIT", "dependencies": { - "@expressive-code/core": "^0.41.7" + "@expressive-code/core": "^0.42.0" } }, "node_modules/@expressive-code/plugin-shiki": { - "version": "0.41.7", - "resolved": "https://registry.npmjs.org/@expressive-code/plugin-shiki/-/plugin-shiki-0.41.7.tgz", - "integrity": "sha512-DL605bLrUOgqTdZ0Ot5MlTaWzppRkzzqzeGEu7ODnHF39IkEBbFdsC7pbl3LbUQ1DFtnfx6rD54k/cdofbW6KQ==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@expressive-code/plugin-shiki/-/plugin-shiki-0.42.0.tgz", + "integrity": "sha512-PMKey/kLmewttAHQezL+Y5Fx3vVssfDi3+FJOYQQS2mXP3tQspFELtKKAfsXfmSXdToZYgwoO69HJndqfE+09g==", "license": "MIT", "dependencies": { - "@expressive-code/core": "^0.41.7", - "shiki": "^3.2.2" - } - }, - "node_modules/@expressive-code/plugin-shiki/node_modules/@shikijs/core": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.23.0.tgz", - "integrity": "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.23.0", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4", - "hast-util-to-html": "^9.0.5" - } - }, - "node_modules/@expressive-code/plugin-shiki/node_modules/@shikijs/engine-javascript": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.23.0.tgz", - "integrity": "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.23.0", - "@shikijs/vscode-textmate": "^10.0.2", - "oniguruma-to-es": "^4.3.4" - } - }, - "node_modules/@expressive-code/plugin-shiki/node_modules/@shikijs/engine-oniguruma": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", - "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.23.0", - "@shikijs/vscode-textmate": "^10.0.2" - } - }, - "node_modules/@expressive-code/plugin-shiki/node_modules/@shikijs/langs": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz", - "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.23.0" - } - }, - "node_modules/@expressive-code/plugin-shiki/node_modules/@shikijs/themes": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", - "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.23.0" - } - }, - "node_modules/@expressive-code/plugin-shiki/node_modules/@shikijs/types": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", - "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", - "license": "MIT", - "dependencies": { - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" - } - }, - "node_modules/@expressive-code/plugin-shiki/node_modules/shiki": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.23.0.tgz", - "integrity": "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==", - "license": "MIT", - "dependencies": { - "@shikijs/core": "3.23.0", - "@shikijs/engine-javascript": "3.23.0", - "@shikijs/engine-oniguruma": "3.23.0", - "@shikijs/langs": "3.23.0", - "@shikijs/themes": "3.23.0", - "@shikijs/types": "3.23.0", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" + "@expressive-code/core": "^0.42.0", + "shiki": "^4.0.2" } }, "node_modules/@expressive-code/plugin-text-markers": { - "version": "0.41.7", - "resolved": "https://registry.npmjs.org/@expressive-code/plugin-text-markers/-/plugin-text-markers-0.41.7.tgz", - "integrity": "sha512-Ewpwuc5t6eFdZmWlFyeuy3e1PTQC0jFvw2Q+2bpcWXbOZhPLsT7+h8lsSIJxb5mS7wZko7cKyQ2RLYDyK6Fpmw==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@expressive-code/plugin-text-markers/-/plugin-text-markers-0.42.0.tgz", + "integrity": "sha512-l59lUx8fq1v5g6SpmbDjiU0+7IdfbiWnAyRmtTVSpfhyq+nZMN4UcmYyu2b9Mynhzt7Gr+O+cXyEPDNb2AVWVQ==", "license": "MIT", "dependencies": { - "@expressive-code/core": "^0.41.7" + "@expressive-code/core": "^0.42.0" } }, "node_modules/@fontsource/ibm-plex-mono": { @@ -2234,47 +2190,47 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", - "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.19.0", + "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.2.4" + "tailwindcss": "4.3.0" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", - "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.4", - "@tailwindcss/oxide-darwin-arm64": "4.2.4", - "@tailwindcss/oxide-darwin-x64": "4.2.4", - "@tailwindcss/oxide-freebsd-x64": "4.2.4", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", - "@tailwindcss/oxide-linux-x64-musl": "4.2.4", - "@tailwindcss/oxide-wasm32-wasi": "4.2.4", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", - "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", "cpu": [ "arm64" ], @@ -2288,9 +2244,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz", - "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", "cpu": [ "arm64" ], @@ -2304,9 +2260,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz", - "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", "cpu": [ "x64" ], @@ -2320,9 +2276,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz", - "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", "cpu": [ "x64" ], @@ -2336,9 +2292,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz", - "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", "cpu": [ "arm" ], @@ -2352,9 +2308,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz", - "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", "cpu": [ "arm64" ], @@ -2368,9 +2324,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz", - "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", "cpu": [ "arm64" ], @@ -2384,9 +2340,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz", - "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", "cpu": [ "x64" ], @@ -2400,9 +2356,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz", - "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", "cpu": [ "x64" ], @@ -2416,9 +2372,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", - "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -2433,10 +2389,10 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.8.1", - "@emnapi/runtime": "^1.8.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.1", + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, @@ -2445,17 +2401,17 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.8.1", + "version": "1.10.0", "inBundle": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.1.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.8.1", + "version": "1.10.0", "inBundle": true, "license": "MIT", "optional": true, @@ -2464,7 +2420,7 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", + "version": "1.2.1", "inBundle": true, "license": "MIT", "optional": true, @@ -2473,18 +2429,20 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", + "version": "1.1.4", "inBundle": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { @@ -2503,9 +2461,9 @@ "optional": true }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", - "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", "cpu": [ "arm64" ], @@ -2519,9 +2477,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", - "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", "cpu": [ "x64" ], @@ -2535,14 +2493,14 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz", - "integrity": "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", + "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.2.4", - "@tailwindcss/oxide": "4.2.4", - "tailwindcss": "4.2.4" + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "tailwindcss": "4.3.0" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" @@ -2603,9 +2561,9 @@ } }, "node_modules/@types/mdx": { - "version": "2.0.13", - "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", - "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==", + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.14.tgz", + "integrity": "sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==", "license": "MIT" }, "node_modules/@types/ms": { @@ -2890,14 +2848,14 @@ } }, "node_modules/astro": { - "version": "6.3.2", - "resolved": "https://registry.npmjs.org/astro/-/astro-6.3.2.tgz", - "integrity": "sha512-Wvl/420m99OjKRH9Q+Vk7JBed8H39n66R61FG2ty0yZjQXBplIIXvJWYXUouMn2U4znfjpYe1HLOA2Rpet6uog==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/astro/-/astro-6.4.2.tgz", + "integrity": "sha512-8H89CH2dKL5SCU99OCqdU9BGjmPkSJqaPurywj5XMo7eMFGUFD3vsNhdEKnEh4mK4LgGje3/QDTTSIIGst0G0Q==", "license": "MIT", "dependencies": { "@astrojs/compiler": "^4.0.0", - "@astrojs/internal-helpers": "0.9.1", - "@astrojs/markdown-remark": "7.1.2", + "@astrojs/internal-helpers": "0.10.0", + "@astrojs/markdown-remark": "7.2.0", "@astrojs/telemetry": "3.3.2", "@capsizecss/unpack": "^4.0.0", "@clack/prompts": "^1.1.0", @@ -2968,12 +2926,12 @@ } }, "node_modules/astro-expressive-code": { - "version": "0.41.7", - "resolved": "https://registry.npmjs.org/astro-expressive-code/-/astro-expressive-code-0.41.7.tgz", - "integrity": "sha512-hUpogGc6DdAd+I7pPXsctyYPRBJDK7Q7d06s4cyP0Vz3OcbziP3FNzN0jZci1BpCvLn9675DvS7B9ctKKX64JQ==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/astro-expressive-code/-/astro-expressive-code-0.42.0.tgz", + "integrity": "sha512-aiTePi2Cn0mJPYWZSzP1GcxCinX9mNtJyCCshVVPSg1yRwM7ADvFJOx0FnS440M9t65hp8JH//dc2qr22Bm4ag==", "license": "MIT", "dependencies": { - "rehype-expressive-code": "^0.41.7" + "rehype-expressive-code": "^0.42.0" }, "peerDependencies": { "astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta" @@ -2985,56 +2943,6 @@ "integrity": "sha512-eouss7G8ygdZqHuke033VMcVw5HTZUu+PXd/h06DGDUg/jt5btPYPqh66ENWw/mU78rBrf/oeC4oqoBwMtDMNA==", "license": "MIT" }, - "node_modules/astro/node_modules/@astrojs/internal-helpers": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.9.1.tgz", - "integrity": "sha512-1pWuARqYom/TzuU3+0ZugsTrKlUydWKuULmDqSMTuonY+9IRDUEGKX/8PXQ1nBxRq3w85uGtd9q9SXfqEldMIQ==", - "license": "MIT", - "dependencies": { - "picomatch": "^4.0.4" - } - }, - "node_modules/astro/node_modules/@astrojs/markdown-remark": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-7.1.2.tgz", - "integrity": "sha512-caXZ4Dc2St2dW8luEg22GlP0gupLdztCTQE4EzZOxW1pqWXz9mbeJEuHUkgDYcKWW8tjIHkydYDhWLVoxJ327Q==", - "license": "MIT", - "dependencies": { - "@astrojs/internal-helpers": "0.9.1", - "@astrojs/prism": "4.0.2", - "github-slugger": "^2.0.0", - "hast-util-from-html": "^2.0.3", - "hast-util-to-text": "^4.0.2", - "js-yaml": "^4.1.1", - "mdast-util-definitions": "^6.0.0", - "rehype-raw": "^7.0.0", - "rehype-stringify": "^10.0.1", - "remark-gfm": "^4.0.1", - "remark-parse": "^11.0.0", - "remark-rehype": "^11.1.2", - "remark-smartypants": "^3.0.2", - "retext-smartypants": "^6.2.0", - "shiki": "^4.0.0", - "smol-toml": "^1.6.0", - "unified": "^11.0.5", - "unist-util-remove-position": "^5.0.0", - "unist-util-visit": "^5.1.0", - "unist-util-visit-parents": "^6.0.2", - "vfile": "^6.0.3" - } - }, - "node_modules/astro/node_modules/@astrojs/prism": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.0.2.tgz", - "integrity": "sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==", - "license": "MIT", - "dependencies": { - "prismjs": "^1.30.0" - }, - "engines": { - "node": ">=22.12.0" - } - }, "node_modules/astro/node_modules/jsonc-parser": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", @@ -3695,9 +3603,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.21.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", - "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", + "version": "5.23.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.23.0.tgz", + "integrity": "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -4015,15 +3923,15 @@ "license": "MIT" }, "node_modules/expressive-code": { - "version": "0.41.7", - "resolved": "https://registry.npmjs.org/expressive-code/-/expressive-code-0.41.7.tgz", - "integrity": "sha512-2wZjC8OQ3TaVEMcBtYY4Va3lo6J+Ai9jf3d4dbhURMJcU4Pbqe6EcHe424MIZI0VHUA1bR6xdpoHYi3yxokWqA==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/expressive-code/-/expressive-code-0.42.0.tgz", + "integrity": "sha512-V5DtJLEKuj4wf9O6IRtPtRObkMVy2ggR+S0MdjrTw6m58krZnDioyhW1si3Y04c5YPeooP4nd85Yq9NwEVHS4g==", "license": "MIT", "dependencies": { - "@expressive-code/core": "^0.41.7", - "@expressive-code/plugin-frames": "^0.41.7", - "@expressive-code/plugin-shiki": "^0.41.7", - "@expressive-code/plugin-text-markers": "^0.41.7" + "@expressive-code/core": "^0.42.0", + "@expressive-code/plugin-frames": "^0.42.0", + "@expressive-code/plugin-shiki": "^0.42.0", + "@expressive-code/plugin-text-markers": "^0.42.0" } }, "node_modules/extend": { @@ -4776,26 +4684,31 @@ } }, "node_modules/i18next": { - "version": "23.16.8", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.16.8.tgz", - "integrity": "sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==", + "version": "26.3.1", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz", + "integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==", "funding": [ { "type": "individual", - "url": "https://locize.com" - }, - { - "type": "individual", - "url": "https://locize.com/i18next.html" + "url": "https://www.locize.com/i18next" }, { "type": "individual", "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" } ], "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.23.2" + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/inherits": { @@ -5779,9 +5692,9 @@ } }, "node_modules/micromark-extension-directive": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-3.0.2.tgz", - "integrity": "sha512-wjcXHgk+PPdmvR58Le9d7zQYWy+vKEU9Se44p2CrCDPiLr2FMyiT4Fyb5UFKFC66wGB3kPlgD7q3TnoqPS7SZA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-4.0.0.tgz", + "integrity": "sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==", "license": "MIT", "dependencies": { "devlop": "^1.0.0", @@ -6857,9 +6770,9 @@ } }, "node_modules/prettier": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", - "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" @@ -7026,12 +6939,12 @@ } }, "node_modules/rehype-expressive-code": { - "version": "0.41.7", - "resolved": "https://registry.npmjs.org/rehype-expressive-code/-/rehype-expressive-code-0.41.7.tgz", - "integrity": "sha512-25f8ZMSF1d9CMscX7Cft0TSQIqdwjce2gDOvQ+d/w0FovsMwrSt3ODP4P3Z7wO1jsIJ4eYyaDRnIR/27bd/EMQ==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/rehype-expressive-code/-/rehype-expressive-code-0.42.0.tgz", + "integrity": "sha512-8rp/1YMEVVSYbtz+bFBx+uSx3vA4i4T8RwRm5Q/IWbucQnnQqQ0hDqtmKOr8tv+59Cik6cu5aH3WPo0I7csuTA==", "license": "MIT", "dependencies": { - "expressive-code": "^0.41.7" + "expressive-code": "^0.42.0" } }, "node_modules/rehype-format": { @@ -7109,14 +7022,14 @@ } }, "node_modules/remark-directive": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/remark-directive/-/remark-directive-3.0.1.tgz", - "integrity": "sha512-gwglrEQEZcZYgVyG1tQuA+h58EZfq5CSULw7J90AFuCTyib1thgHPoqQ+h9iFvU6R+vnZ5oNFQR5QKgGpk741A==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/remark-directive/-/remark-directive-4.0.0.tgz", + "integrity": "sha512-7sxn4RfF1o3izevPV1DheyGDD6X4c9hrGpfdUpm7uC++dqrnJxIZVkk7CoKqcLm0VUMAuOol7Mno3m6g8cfMuA==", "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-directive": "^3.0.0", - "micromark-extension-directive": "^3.0.0", + "micromark-extension-directive": "^4.0.0", "unified": "^11.0.0" }, "funding": { @@ -7563,9 +7476,9 @@ } }, "node_modules/starlight-openapi": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/starlight-openapi/-/starlight-openapi-0.25.0.tgz", - "integrity": "sha512-3Y5nB/cSReTYLKZvWplOcG3S3NAWB04l9Q9B/m/KpJdT2OJUT9hqt2iWySGOKz5e5ShHVVNFlmLN0RjL4s7h7Q==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/starlight-openapi/-/starlight-openapi-0.25.3.tgz", + "integrity": "sha512-erFtGyU1NI1mHmlRrVo/SWLdVvjFr1Qsxwz1bnOss/rOSBH3Tgq39rSlkXr9HWbSTnbjIpZkitgYw+s73zk5pA==", "license": "MIT", "dependencies": { "@readme/openapi-parser": "^4.1.2", @@ -7767,9 +7680,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", - "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", "license": "MIT" }, "node_modules/tapable": { @@ -7816,13 +7729,13 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -8507,6 +8420,12 @@ "vscode-uri": "^3.1.0" } }, + "node_modules/vscode-css-languageservice/node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, "node_modules/vscode-html-languageservice": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/vscode-html-languageservice/-/vscode-html-languageservice-5.6.2.tgz", @@ -8542,9 +8461,9 @@ "license": "MIT" }, "node_modules/vscode-jsonrpc": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", - "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.0.tgz", + "integrity": "sha512-+VvMmQPJhtvJ+8O+zu2JKIRiLxXF8NW7krWgyMGeOHrp4Cn23T5hc0v2LknNeopDOB70wghHAds7mKtcZ0I4Sg==", "license": "MIT", "engines": { "node": ">=14.0.0" @@ -8563,13 +8482,13 @@ } }, "node_modules/vscode-languageserver-protocol": { - "version": "3.17.5", - "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", - "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "version": "3.18.0", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.18.0.tgz", + "integrity": "sha512-Zdz+kJ12Iz6tc11xfZyEo501bBATHXrCjmMfnaR3pMnf1CoqZBKIynba3P+/bi9VEdrMbNtAVKYpKhbODvqy+Q==", "license": "MIT", "dependencies": { - "vscode-jsonrpc": "8.2.0", - "vscode-languageserver-types": "3.17.5" + "vscode-jsonrpc": "9.0.0", + "vscode-languageserver-types": "3.18.0" } }, "node_modules/vscode-languageserver-textdocument": { @@ -8579,6 +8498,31 @@ "license": "MIT" }, "node_modules/vscode-languageserver-types": { + "version": "3.18.0", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.18.0.tgz", + "integrity": "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g==", + "license": "MIT" + }, + "node_modules/vscode-languageserver/node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver/node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver/node_modules/vscode-languageserver-types": { "version": "3.17.5", "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", diff --git a/docs/package.json b/docs/package.json index bd2c93f8..6597bcf7 100644 --- a/docs/package.json +++ b/docs/package.json @@ -11,17 +11,17 @@ "astro": "astro" }, "dependencies": { - "@astrojs/check": "^0.9.8", - "@astrojs/node": "^10.1.1", - "@astrojs/starlight": "^0.38.4", + "@astrojs/check": "^0.9.9", + "@astrojs/node": "^10.1.2", + "@astrojs/starlight": "^0.39.2", "@astrojs/starlight-tailwind": "^5.0.0", - "@astrojs/svelte": "^8.1.1", + "@astrojs/svelte": "^8.1.2", "@fontsource/ibm-plex-mono": "^5.2.7", "@fontsource/ibm-plex-sans": "^5.2.8", - "@tailwindcss/vite": "^4.2.4", - "astro": "^6.3.2", + "@tailwindcss/vite": "^4.3.0", + "astro": "^6.4.2", "sharp": "^0.34.5", - "starlight-openapi": "^0.25.0", + "starlight-openapi": "^0.25.3", "svelte": "^5.56.0", "tailwindcss": "^4.1.10", "typescript": "^5.9.3" From ca326fabbd464914f78d99683c95a36c8a88a325 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 7 Jun 2026 13:45:01 +0200 Subject: [PATCH 19/26] Bump golang.org/x/net (#1036) Bumps the gomod-backward-compatible group with 1 update in the /db directory: [golang.org/x/net](https://github.com/golang/net). Updates `golang.org/x/net` from 0.53.0 to 0.55.0 - [Commits](https://github.com/golang/net/compare/v0.53.0...v0.55.0) --- updated-dependencies: - dependency-name: golang.org/x/net dependency-version: 0.55.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: gomod-backward-compatible ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- db/go.mod | 8 ++++---- db/go.sum | 28 ++++++++++++++-------------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/db/go.mod b/db/go.mod index 8efc8e6e..392bdc65 100644 --- a/db/go.mod +++ b/db/go.mod @@ -44,13 +44,13 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 // indirect github.com/twpayne/go-polyline v1.1.1 - golang.org/x/crypto v0.50.0 // indirect + golang.org/x/crypto v0.51.0 // indirect golang.org/x/image v0.39.0 // indirect - golang.org/x/net v0.53.0 + golang.org/x/net v0.55.0 golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.20.0 - golang.org/x/sys v0.43.0 // indirect - golang.org/x/text v0.36.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect modernc.org/libc v1.72.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/db/go.sum b/db/go.sum index 48131992..b24c2e91 100644 --- a/db/go.sum +++ b/db/go.sum @@ -108,18 +108,18 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i 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-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= 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/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= -golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= -golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= 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-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= 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/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= @@ -128,19 +128,19 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h 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-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= 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.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= -golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= From 1e2b4ea75de03360566b1cd91e18a5e22af1c4c1 Mon Sep 17 00:00:00 2001 From: slothful-vassal <89943360+slothful-vassal@users.noreply.github.com> Date: Tue, 9 Jun 2026 17:10:27 +0200 Subject: [PATCH 20/26] Remove search docker (#1054) * Bump golang.org/x/net Bumps the gomod-backward-compatible group with 1 update in the /db directory: [golang.org/x/net](https://github.com/golang/net). Updates `golang.org/x/net` from 0.53.0 to 0.55.0 - [Commits](https://github.com/golang/net/compare/v0.53.0...v0.55.0) --- updated-dependencies: - dependency-name: golang.org/x/net dependency-version: 0.55.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: gomod-backward-compatible ... Signed-off-by: dependabot[bot] * remove search docker --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- search/Dockerfile | 9 --------- search/entrypoint.sh | 3 --- 2 files changed, 12 deletions(-) delete mode 100644 search/Dockerfile delete mode 100644 search/entrypoint.sh diff --git a/search/Dockerfile b/search/Dockerfile deleted file mode 100644 index c16c6440..00000000 --- a/search/Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -FROM getmeili/meilisearch:v1.11.3 - -COPY ./entrypoint.sh /entrypoint.sh - -RUN chmod +x /entrypoint.sh - -CMD [ "/entrypoint.sh" ] - - diff --git a/search/entrypoint.sh b/search/entrypoint.sh deleted file mode 100644 index 6893f4f7..00000000 --- a/search/entrypoint.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh - -meilisearch From c04a719ca6534885e4f7c143696b9c09abb4ddac Mon Sep 17 00:00:00 2001 From: Flomp Date: Tue, 9 Jun 2026 21:02:05 +0200 Subject: [PATCH 21/26] Feat add actors to meilisearch (#1048) * initial commit * update api docs * adds search token versioning * make id filterable * isLocal -> is_local * add iri to actor index * update gitignore * Merge remote-tracking branch 'origin/main' into feat-add-actors-to-meilisearch * fix sharing for newly discovered actors * fix ActorSearchResult type --------- Co-authored-by: Christian Beutel <> --- .gitignore | 2 +- db/federation/actor.go | 8 +- db/federation/announce.go | 4 +- db/federation/create.go | 4 +- db/federation/delete.go | 10 +- db/federation/follow.go | 2 +- db/federation/like.go | 2 +- db/federation/undo.go | 4 +- db/hooks/activitypub_actor.go | 48 +++++++ db/hooks/list.go | 4 +- db/hooks/trail_like.go | 4 +- db/hooks/trails.go | 4 +- db/main.go | 37 +++++ .../1780734977_updated_activitypub_actors.go | 52 +++++++ db/routes/remote_trail_comment.go | 2 +- db/routes/search_token.go | 1 + db/services/trailmerge/service.go | 2 +- db/util/activitypub.go | 6 +- db/util/meilisearch.go | 58 +++++++- db/util/notification.go | 2 +- web/src/hooks.server.ts | 13 +- web/src/lib/components/actor_search.svelte | 6 +- web/src/lib/components/base/editor.svelte | 8 +- .../components/list/list_share_modal.svelte | 8 +- web/src/lib/components/share_info.svelte | 2 +- .../summit_log/summit_log_table_row.svelte | 4 +- .../lib/components/trail/trail_card.svelte | 2 +- .../components/trail/trail_share_modal.svelte | 10 +- .../lib/components/trail/trail_table.svelte | 2 +- web/src/lib/models/activitypub/actor.ts | 12 +- web/src/lib/stores/search_store.ts | 9 +- web/src/lib/stores/trail_store.ts | 2 +- web/src/lib/util/activitypub_util.ts | 12 ++ web/src/routes/+page.svelte | 4 +- .../v1/activitypub/user/[handle]/+server.ts | 2 +- .../user/[handle]/followers/+server.ts | 2 +- .../user/[handle]/following/+server.ts | 2 +- .../user/[handle]/outbox/+server.ts | 2 +- web/src/routes/api/v1/follow/+server.ts | 2 +- .../api/v1/profile/[handle]/feed/+server.ts | 2 +- .../api/v1/profile/[handle]/lists/+server.ts | 2 +- .../api/v1/profile/[handle]/stats/+server.ts | 2 +- .../api/v1/profile/[handle]/trails/+server.ts | 2 +- web/src/routes/api/v1/search/actor/+server.ts | 130 ++++++++++++++---- .../[handle]/users/[type]/+page.svelte | 2 +- 45 files changed, 392 insertions(+), 108 deletions(-) create mode 100644 db/hooks/activitypub_actor.go create mode 100644 db/migrations/1780734977_updated_activitypub_actors.go diff --git a/.gitignore b/.gitignore index 276d58d5..d6962a7a 100644 --- a/.gitignore +++ b/.gitignore @@ -13,5 +13,5 @@ start*.* data*/ .planning/ -.claude +.claude/ CLAUDE.md \ No newline at end of file diff --git a/db/federation/actor.go b/db/federation/actor.go index 3199b408..26a05ce3 100644 --- a/db/federation/actor.go +++ b/db/federation/actor.go @@ -69,7 +69,7 @@ func GetActorByHandle(app core.App, ctx context.Context, handle string, includeF if domain != "" { filter += "domain={:domain}" } else { - filter += "isLocal=true" + filter += "is_local=true" } var dbActor *core.Record @@ -81,7 +81,7 @@ func GetActorByHandle(app core.App, ctx context.Context, handle string, includeF } dbActor = core.NewRecord(collection) - dbActor.Set("isLocal", false) + dbActor.Set("is_local", false) iri, err := iriFromHandle(ctx, domain, username) if err != nil { return nil, err @@ -105,7 +105,7 @@ func GetActorByIRI(app core.App, ctx context.Context, iri string, includeFollows } dbActor = core.NewRecord(collection) - dbActor.Set("isLocal", false) + dbActor.Set("is_local", false) dbActor.Set("iri", iri) } else if err != nil { @@ -167,7 +167,7 @@ func assembleActor(app core.App, ctx context.Context, dbActor *core.Record, incl } private := false - if dbActor.GetBool("isLocal") { + if dbActor.GetBool("is_local") { user, err := app.FindRecordById("users", dbActor.GetString("user")) if err != nil { return nil, err diff --git a/db/federation/announce.go b/db/federation/announce.go index 5188c518..2ca9a9f3 100644 --- a/db/federation/announce.go +++ b/db/federation/announce.go @@ -132,7 +132,7 @@ func processTrailAnnounceActivity(app core.App, actor *core.Record, activity pub } var trail *core.Record - if !actor.GetBool("isLocal") { + if !actor.GetBool("is_local") { trail, err = util.TrailFromActivity(activity, app, actor) if err != nil { return err @@ -210,7 +210,7 @@ func processListAnnounceActivity(app core.App, actor *core.Record, activity pub. } var list *core.Record - if !actor.GetBool("isLocal") { + if !actor.GetBool("is_local") { list, err = util.ListFromActivity(activity, app, actor) if err != nil { return err diff --git a/db/federation/create.go b/db/federation/create.go index 3751bec7..17405fe2 100644 --- a/db/federation/create.go +++ b/db/federation/create.go @@ -531,7 +531,7 @@ func processCreateOrUpdateCommentActivity(activity pub.Activity, app core.App, a } // no need to do anything else if the actor is local - if actor.GetBool("isLocal") { + if actor.GetBool("is_local") { return nil } @@ -645,7 +645,7 @@ func processCreateOrUpdateSummitLogActivity(activity pub.Activity, app core.App, } } // no need to do anything else if the actor is local - if actor.GetBool("isLocal") { + if actor.GetBool("is_local") { return nil } diff --git a/db/federation/delete.go b/db/federation/delete.go index f5d78f9c..0deba908 100644 --- a/db/federation/delete.go +++ b/db/federation/delete.go @@ -91,7 +91,7 @@ func CreateCommentDeleteActivity(app core.App, client meilisearch.ServiceManager return err } - if !author.GetBool("isLocal") { + if !author.GetBool("is_local") { return nil } @@ -105,7 +105,7 @@ func CreateCommentDeleteActivity(app core.App, client meilisearch.ServiceManager return err } - if commentTrailAuthor.GetBool("isLocal") { + if commentTrailAuthor.GetBool("is_local") { return nil } @@ -153,7 +153,7 @@ func CreateSummitLogDeleteActivity(app core.App, r *core.Record) error { return err } - if !author.GetBool("isLocal") { + if !author.GetBool("is_local") { return nil } @@ -234,7 +234,7 @@ func CreateListDeleteActivity(app core.App, r *core.Record) error { return err } - if !author.GetBool("isLocal") { + if !author.GetBool("is_local") { return nil } @@ -290,7 +290,7 @@ func CreateListDeleteActivity(app core.App, r *core.Record) error { func ProcessDeleteActivity(app core.App, actor *core.Record, activity pub.Activity) error { // no need to do anything if the actor is local - if actor.GetBool("isLocal") { + if actor.GetBool("is_local") { return nil } diff --git a/db/federation/follow.go b/db/federation/follow.go index 620bd6f9..2e926041 100644 --- a/db/federation/follow.go +++ b/db/federation/follow.go @@ -76,7 +76,7 @@ func ProcessFollowActivity(app core.App, actor *core.Record, activity pub.Activi // a remote actor has requested the follow // this means we have not yet created a follow entry in our db // we accept it immediately - if !actor.GetBool("isLocal") { + if !actor.GetBool("is_local") { followCollection, err := app.FindCollectionByNameOrId("follows") if err != nil { return err diff --git a/db/federation/like.go b/db/federation/like.go index 2ae55fd0..90dbe6d0 100644 --- a/db/federation/like.go +++ b/db/federation/like.go @@ -80,7 +80,7 @@ func ProcessLikeActivity(app core.App, actor *core.Record, activity pub.Activity return err } - if !actor.GetBool("isLocal") { + if !actor.GetBool("is_local") { trailLikeCollection, err := app.FindCollectionByNameOrId("trail_like") if err != nil { return err diff --git a/db/federation/undo.go b/db/federation/undo.go index d7c3d35f..0dbbcffd 100644 --- a/db/federation/undo.go +++ b/db/federation/undo.go @@ -140,7 +140,7 @@ func ProcessUndoActivity(app core.App, actor *core.Record, activity pub.Activity func processUnfollowActivity(app core.App, actor *core.Record, activity pub.Activity) error { // this was a local follow - if actor.GetBool("isLocal") { + if actor.GetBool("is_local") { return nil } @@ -164,7 +164,7 @@ func processUnfollowActivity(app core.App, actor *core.Record, activity pub.Acti } func processUnlikeActivity(app core.App, actor *core.Record, activity pub.Activity) error { - if actor.GetBool("isLocal") { + if actor.GetBool("is_local") { return nil } diff --git a/db/hooks/activitypub_actor.go b/db/hooks/activitypub_actor.go new file mode 100644 index 00000000..9bf19473 --- /dev/null +++ b/db/hooks/activitypub_actor.go @@ -0,0 +1,48 @@ +package hooks + +import ( + "log" + "pocketbase/util" + "time" + + "github.com/meilisearch/meilisearch-go" + "github.com/pocketbase/pocketbase/core" +) + +func CreateActorHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error { + return func(e *core.RecordEvent) error { + err := e.Next() + if err != nil { + return err + } + + return util.IndexActors([]*core.Record{e.Record}, client) + } +} + +func UpdateActorHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error { + return func(e *core.RecordEvent) error { + err := e.Next() + if err != nil { + return err + } + + return util.UpdateActor(e.Record, client) + } +} + +func DeleteActorHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error { + return func(e *core.RecordEvent) error { + task, err := client.Index("actors").DeleteDocument(e.Record.Id, nil) + if err != nil { + return err + } + + interval := 500 * time.Millisecond + _, err = client.WaitForTask(task.TaskUID, interval) + if err != nil { + log.Fatalf("Error waiting for task completion: %v", err) + } + return e.Next() + } +} diff --git a/db/hooks/list.go b/db/hooks/list.go index b2651871..fadce824 100644 --- a/db/hooks/list.go +++ b/db/hooks/list.go @@ -41,7 +41,7 @@ func CreateListHandler(client meilisearch.ServiceManager) func(e *core.RecordEve return err } - if !author.GetBool("isLocal") { + if !author.GetBool("is_local") { // this happens if someone fetches a remote list // we create a stub list record for later reference // no need to create an activity for that @@ -75,7 +75,7 @@ func UpdateListHandler(client meilisearch.ServiceManager) func(e *core.RecordEve return err } - if !author.GetBool("isLocal") { + if !author.GetBool("is_local") { // this happens if someone fetches a remote list // we create a stub list record for later reference // no need to create an activity for that diff --git a/db/hooks/trail_like.go b/db/hooks/trail_like.go index 779b0b9a..0a09a180 100644 --- a/db/hooks/trail_like.go +++ b/db/hooks/trail_like.go @@ -51,7 +51,7 @@ func CreateTrailLikeHandler(client meilisearch.ServiceManager) func(e *core.Reco return err } - if !actor.GetBool("isLocal") { + if !actor.GetBool("is_local") { // this happens if someone likes a remote trail // we create a local copy // no need to create an activity for that @@ -107,7 +107,7 @@ func DeleteTrailLikeHandler(client meilisearch.ServiceManager) func(e *core.Reco return err } - if !actor.GetBool("isLocal") { + if !actor.GetBool("is_local") { // this happens if someone likes a remote trail // we create a local copy // no need to create an activity for that diff --git a/db/hooks/trails.go b/db/hooks/trails.go index 2e6eb956..a0307721 100644 --- a/db/hooks/trails.go +++ b/db/hooks/trails.go @@ -47,7 +47,7 @@ func CreateTrailHandler(client meilisearch.ServiceManager) func(e *core.RecordEv return err } - if !userActor.GetBool("isLocal") { + if !userActor.GetBool("is_local") { // this happens if someone fetches a remote list // we create a stub list record for later reference // no need to create an activity for that @@ -92,7 +92,7 @@ func UpdateTrailHandler(client meilisearch.ServiceManager) func(e *core.RecordEv if err != nil { return err } - if !userActor.GetBool("isLocal") { + if !userActor.GetBool("is_local") { // this happens if someone fetches a remote trail // we create a stub trail record for later reference // no need to create an activity for that diff --git a/db/main.go b/db/main.go index 11f5b152..1bc335ba 100644 --- a/db/main.go +++ b/db/main.go @@ -90,6 +90,10 @@ func setupEventHandlers(app *pocketbase.PocketBase, client meilisearch.ServiceMa app.OnRecordAfterCreateSuccess("users").BindFunc(hooks.CreateUserHandler(client)) app.OnRecordAfterUpdateSuccess("users").BindFunc(hooks.UpdateUserHandler(client)) + app.OnRecordAfterCreateSuccess("activitypub_actors").BindFunc(hooks.CreateActorHandler(client)) + app.OnRecordAfterUpdateSuccess("activitypub_actors").BindFunc(hooks.UpdateActorHandler(client)) + app.OnRecordAfterDeleteSuccess("activitypub_actors").BindFunc(hooks.DeleteActorHandler(client)) + app.OnRecordAfterCreateSuccess("trails").BindFunc(hooks.CreateTrailHandler(client)) app.OnRecordAfterUpdateSuccess("trails").BindFunc(hooks.UpdateTrailHandler(client)) app.OnRecordAfterDeleteSuccess("trails").BindFunc(hooks.DeleteTrailHandler(client)) @@ -315,6 +319,12 @@ func initMeilisearchConfig(client meilisearch.ServiceManager) { SortableAttributes: []string{"created", "name"}, RankingRules: []string{"words", "typo", "proximity", "attribute", "sort", "exactness"}, }, + "actors": { + SearchableAttributes: []string{"username", "preferred_username", "domain"}, + FilterableAttributes: []string{"id"}, + SortableAttributes: []string{}, + RankingRules: []string{"words", "typo", "proximity", "attribute", "sort", "exactness"}, + }, } for indexName, settings := range configs { @@ -404,5 +414,32 @@ func initMeilisearchDocuments(app core.App, client meilisearch.ServiceManager) e page++ } + // --- Actors --- + if _, err := client.Index("actors").DeleteAllDocuments(nil); err != nil { + return err + } + + page = 0 + for { + actors := []*core.Record{} + err := app.RecordQuery("activitypub_actors"). + Limit(pageSize). + Offset(page * pageSize). + All(&actors) + if err != nil { + return err + } + if len(actors) == 0 { + break + } + + if err := util.IndexActors(actors, client); err != nil { + app.Logger().Warn(fmt.Sprintf("Unable to index actor page %d: %v", page, err)) + continue + } + + page++ + } + return nil } diff --git a/db/migrations/1780734977_updated_activitypub_actors.go b/db/migrations/1780734977_updated_activitypub_actors.go new file mode 100644 index 00000000..09d92d56 --- /dev/null +++ b/db/migrations/1780734977_updated_activitypub_actors.go @@ -0,0 +1,52 @@ +package migrations + +import ( + "github.com/pocketbase/pocketbase/core" + m "github.com/pocketbase/pocketbase/migrations" +) + +func init() { + m.Register(func(app core.App) error { + collection, err := app.FindCollectionByNameOrId("pbc_1295301207") + if err != nil { + return err + } + + // update field + if err := collection.Fields.AddMarshaledJSONAt(14, []byte(`{ + "help": "", + "hidden": false, + "id": "bool2193750486", + "name": "is_local", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }`)); err != nil { + return err + } + + return app.Save(collection) + }, func(app core.App) error { + collection, err := app.FindCollectionByNameOrId("pbc_1295301207") + if err != nil { + return err + } + + // update field + if err := collection.Fields.AddMarshaledJSONAt(14, []byte(`{ + "help": "", + "hidden": false, + "id": "bool2193750486", + "name": "isLocal", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }`)); err != nil { + return err + } + + return app.Save(collection) + }) +} diff --git a/db/routes/remote_trail_comment.go b/db/routes/remote_trail_comment.go index 1df4c468..f00327a1 100644 --- a/db/routes/remote_trail_comment.go +++ b/db/routes/remote_trail_comment.go @@ -45,7 +45,7 @@ func RemoteTrailCommentsList(e *core.RequestEvent) error { } // Sync remote data first (Fetch + Save) - if trail.GetString("iri") != "" && !trailAuthor.GetBool("isLocal") { + if trail.GetString("iri") != "" && !trailAuthor.GetBool("is_local") { _ = syncRemoteComments(e, trail) } diff --git a/db/routes/search_token.go b/db/routes/search_token.go index 228c2dc8..24048f14 100644 --- a/db/routes/search_token.go +++ b/db/routes/search_token.go @@ -28,6 +28,7 @@ func SearchToken(client meilisearch.ServiceManager) func(e *core.RequestEvent) e "trails": map[string]string{ "filter": "public = true OR author = " + userActor.Id + " OR shares = " + userActor.Id, }, + "actors": map[string]string{}, } } diff --git a/db/services/trailmerge/service.go b/db/services/trailmerge/service.go index 4205b2c1..52211657 100644 --- a/db/services/trailmerge/service.go +++ b/db/services/trailmerge/service.go @@ -1516,7 +1516,7 @@ func buildMergedCommentText(app core.App, comment *core.Record) string { if authorID := comment.GetString("author"); authorID != "" { if author, err := app.FindRecordById("activitypub_actors", authorID); err == nil { authorHandle = "@" + author.GetString("preferred_username") - if !author.GetBool("isLocal") && author.GetString("domain") != "" { + if !author.GetBool("is_local") && author.GetString("domain") != "" { authorHandle += "@" + author.GetString("domain") } } diff --git a/db/util/activitypub.go b/db/util/activitypub.go index 2f1387ed..74fde892 100644 --- a/db/util/activitypub.go +++ b/db/util/activitypub.go @@ -86,7 +86,7 @@ func ActorFromUser(app core.App, u *core.Record) (*core.Record, error) { record.Set("outbox", id+"/outbox") record.Set("followers", id+"/followers") record.Set("following", id+"/following") - record.Set("isLocal", true) + record.Set("is_local", true) record.Set("public_key", string(pubPem)) record.Set("private_key", privEncrypted) record.Set("user", u.Id) @@ -147,7 +147,7 @@ func TrailFromActivity(activity pub.Activity, app core.App, actor *core.Record) // the local record; a remote actor must not be able to reference or attach // side effects (feeds/shares/notifications) to local content by id. if IsLocalIRI(iri) { - if !actor.GetBool("isLocal") { + if !actor.GetBool("is_local") { return nil, fmt.Errorf("refusing remote activity referencing local trail %q", iri) } @@ -440,7 +440,7 @@ func ListFromActivity(activity pub.Activity, app core.App, actor *core.Record) ( // Own content must never be ingested as if it were remote (see TrailFromActivity). if IsLocalIRI(iri) { - if !actor.GetBool("isLocal") { + if !actor.GetBool("is_local") { return nil, fmt.Errorf("refusing remote activity referencing local list %q", iri) } diff --git a/db/util/meilisearch.go b/db/util/meilisearch.go index 6c4c4b58..395c5cd8 100644 --- a/db/util/meilisearch.go +++ b/db/util/meilisearch.go @@ -15,7 +15,7 @@ import ( "github.com/pocketbase/pocketbase/core" ) -func documentFromTrailRecord(app core.App, r *core.Record, author *core.Record, includeShares bool) (map[string]interface{}, error) { +func documentFromTrailRecord(r *core.Record, author *core.Record, includeShares bool) (map[string]interface{}, error) { photos := r.GetStringSlice("photos") thumbnail := "" if len(photos) > 0 { @@ -42,7 +42,7 @@ func documentFromTrailRecord(app core.App, r *core.Record, author *core.Record, bounds := getStoredBounds(r) domain := "" - if !author.GetBool("isLocal") { + if !author.GetBool("is_local") { domain = author.GetString("domain") } @@ -157,7 +157,7 @@ func documentFromListRecord(r *core.Record, author *core.Record, includeShares b totalDuration := 0.0 trails := len(r.GetStringSlice("trails")) - if r.GetString("iri") != "" && !author.GetBool("isLocal") { + if r.GetString("iri") != "" && !author.GetBool("is_local") { doc, err := documentFromRemoteRecord(r, "lists") if err == nil { totalElevationGain = doc["elevation_gain"].(float64) @@ -181,7 +181,7 @@ func documentFromListRecord(r *core.Record, author *core.Record, includeShares b } domain := "" - if !author.GetBool("isLocal") { + if !author.GetBool("is_local") { domain = author.GetString("domain") } @@ -222,6 +222,21 @@ func documentFromListRecord(r *core.Record, author *core.Record, includeShares b return document, nil } +func documentFromActorRecord(r *core.Record) (map[string]any, error) { + + document := map[string]any{ + "id": r.Id, + "username": r.GetString("username"), + "preferred_username": r.GetString("preferred_username"), + "domain": r.GetString("domain"), + "iri": r.GetString("iri"), + "icon": r.GetString("icon"), + "is_local": r.GetBool("is_local"), + } + + return document, nil +} + func documentFromRemoteRecord(r *core.Record, index string) (map[string]any, error) { client := &http.Client{} @@ -309,7 +324,7 @@ func IndexTrails(app core.App, trails []*core.Record, client meilisearch.Service author := r.ExpandedOne("author") - doc, err := documentFromTrailRecord(app, r, author, true) + doc, err := documentFromTrailRecord(r, author, true) if err != nil { return err } @@ -334,7 +349,7 @@ func UpdateTrail(app core.App, r *core.Record, author *core.Record, client meili return fmt.Errorf("meilisearch update trail: failed to expand category: %v", errs) } - doc, err := documentFromTrailRecord(app, r, author, false) + doc, err := documentFromTrailRecord(r, author, false) if err != nil { return err } @@ -424,6 +439,37 @@ func UpdateList(app core.App, r *core.Record, author *core.Record, client meilis return nil } +func IndexActors(actors []*core.Record, client meilisearch.ServiceManager) error { + documents := make([]map[string]any, len(actors)) + + for i, r := range actors { + + doc, err := documentFromActorRecord(r) + if err != nil { + return err + } + documents[i] = doc + } + if _, err := client.Index("actors").AddDocuments(documents, nil); err != nil { + return err + } + + return nil +} + +func UpdateActor(r *core.Record, client meilisearch.ServiceManager) error { + documents, err := documentFromActorRecord(r) + if err != nil { + return err + } + + if _, err = client.Index("actors").UpdateDocuments(documents, nil); err != nil { + return err + } + + return nil +} + func UpdateListShares(listId string, shares []string, client meilisearch.ServiceManager) error { documents := []map[string]interface{}{ { diff --git a/db/util/notification.go b/db/util/notification.go index e1b0e2d8..0855e582 100644 --- a/db/util/notification.go +++ b/db/util/notification.go @@ -61,7 +61,7 @@ func SendNotification(app core.App, notification Notification, recipient *core.R if notification.Author == recipient.Id { return nil } - if !recipient.GetBool("isLocal") { + if !recipient.GetBool("is_local") { return nil } permissions, err := getNotificationPermissions(app, recipient.GetString("user"), notification.Type) diff --git a/web/src/hooks.server.ts b/web/src/hooks.server.ts index 8c091c1b..02c13c6d 100644 --- a/web/src/hooks.server.ts +++ b/web/src/hooks.server.ts @@ -12,6 +12,7 @@ import type { Actor } from '$lib/models/activitypub/actor' import { normalizeLocale } from '$lib/i18n/locales' import { handleError } from '$lib/util/api_util' +const SEARCH_TOKEN_VERSION = 1; function csrf(allowedPaths: string[]): Handle { return async ({ event, resolve }) => { @@ -85,12 +86,12 @@ const auth: Handle = async ({ event, resolve }) => { const currentUserId = pb.authStore.record?.id || 'public'; if (meiliCookie) { - const [token, ownerId] = meiliCookie.split('|'); + const [token, ownerId, version] = meiliCookie.split('|'); - if (ownerId === currentUserId) { + if (ownerId === currentUserId && Number(version) === SEARCH_TOKEN_VERSION) { meilisearchToken = token; } else { - // Identity mismatch (e.g. just logged in/out) + // Identity mismatch (e.g. just logged in/out) or stale token version event.cookies.delete('meilisearch_token', { path: '/' }); } } @@ -99,7 +100,7 @@ const auth: Handle = async ({ event, resolve }) => { try { const tokenResponse = await pb.send("/search/token", { method: "GET", fetch: event.fetch }); meilisearchToken = tokenResponse.token - event.cookies.set('meilisearch_token', `${meilisearchToken}|${currentUserId}`, { + event.cookies.set('meilisearch_token', `${meilisearchToken}|${currentUserId}|${SEARCH_TOKEN_VERSION}`, { path: '/', httpOnly: false, maxAge: 60 * 60 * 24, @@ -143,7 +144,7 @@ const auth: Handle = async ({ event, resolve }) => { if (pb.authStore.record) { settings = await pb.collection('settings').getFirstListItem(`user="${pb.authStore.record.id}"`, { requestKey: null }) - actor = await pb.collection("activitypub_actors").getFirstListItem(`isLocal=1&&user='${pb.authStore.record.id}'`) + actor = await pb.collection("activitypub_actors").getFirstListItem(`is_local=1&&user='${pb.authStore.record.id}'`) } const meiliHost = env.MEILI_URL; if (!meiliHost) { @@ -190,4 +191,4 @@ const removeLinkFromHeaders: Handle = } -export const handle = sequence(csrf(['/api/v1']), auth, removeLinkFromHeaders) +export const handle = sequence(csrf(['/api/v1']), auth, removeLinkFromHeaders) \ No newline at end of file diff --git a/web/src/lib/components/actor_search.svelte b/web/src/lib/components/actor_search.svelte index fbe018f7..4dbc7117 100644 --- a/web/src/lib/components/actor_search.svelte +++ b/web/src/lib/components/actor_search.svelte @@ -1,5 +1,5 @@ %sveltekit.head% @@ -24,4 +24,4 @@
%sveltekit.body%
- \ No newline at end of file + diff --git a/web/src/css/components.css b/web/src/css/components.css index b7b70871..dc1ea2dc 100644 --- a/web/src/css/components.css +++ b/web/src/css/components.css @@ -129,4 +129,4 @@ .mention { @apply bg-blue-100 dark:bg-slate-700 rounded-md text-sm; padding: 0.1rem 0.3rem; -} \ No newline at end of file +} diff --git a/web/src/lib/components/base/select.svelte b/web/src/lib/components/base/select.svelte index a45cf08e..30f5887f 100644 --- a/web/src/lib/components/base/select.svelte +++ b/web/src/lib/components/base/select.svelte @@ -6,11 +6,14 @@ + + + +
+ {#if label.length} + + {/if} + + + {#if open} +
    + {#each items as item, i} +
  • { + event.preventDefault(); + selectItem(item); + }} + > + {item.text} + {#if item.value === value} + + {/if} +
  • + {/each} +
+ {/if} +
diff --git a/web/src/lib/components/base/text_field.svelte b/web/src/lib/components/base/text_field.svelte index d0a2475b..79bafca3 100644 --- a/web/src/lib/components/base/text_field.svelte +++ b/web/src/lib/components/base/text_field.svelte @@ -11,7 +11,7 @@ error?: string | string[] | null; icon?: string; extraClasses?: string; - type?: "text" | "password" | "search"; + type?: "text" | "password" | "search" | "url"; autocomplete?: "on" | "off"; onchange?: ChangeEventHandler; oninput?: FormEventHandler; diff --git a/web/src/lib/components/confirm_modal.svelte b/web/src/lib/components/confirm_modal.svelte index 72e83f96..5970b252 100644 --- a/web/src/lib/components/confirm_modal.svelte +++ b/web/src/lib/components/confirm_modal.svelte @@ -7,9 +7,11 @@ text: string; action?: string; deny?: string; + alternative?: string; id?: string; onconfirm?: () => void oncancel?: () => void + onalternative?: () => void } let { @@ -17,9 +19,11 @@ text, action = "delete", deny ="cancel", + alternative, id = "confirm-modal", onconfirm, - oncancel + oncancel, + onalternative }: Props = $props(); let modal: Modal; @@ -29,13 +33,18 @@ } function cancel() { - oncancel?.(); modal.closeModal!(); + oncancel?.(); + } + + function alternativeAction() { + modal.closeModal!(); + onalternative?.(); } function confirm() { - onconfirm?.() modal.closeModal!(); + onconfirm?.() } @@ -48,6 +57,11 @@ + {#if alternative} + + {/if} -
- - - - {/snippet} - {#snippet footer()} -
- - -
- {/snippet} diff --git a/web/src/lib/components/settings/integrations/integration_card.svelte b/web/src/lib/components/settings/integrations/integration_card.svelte deleted file mode 100644 index ebf556f7..00000000 --- a/web/src/lib/components/settings/integrations/integration_card.svelte +++ /dev/null @@ -1,39 +0,0 @@ - -
- integration logo -
-
{title}
-

- {description} -

-
-
- - -
-
diff --git a/web/src/lib/components/settings/integrations/komoot_settings_modal.svelte b/web/src/lib/components/settings/integrations/komoot_settings_modal.svelte deleted file mode 100644 index c15b538f..00000000 --- a/web/src/lib/components/settings/integrations/komoot_settings_modal.svelte +++ /dev/null @@ -1,130 +0,0 @@ - - - - {#snippet content()} -
- - -
- - -
- - -

- {#if $d.privacy == "original"} - {$_("integration-privacy-hint-original")} - {:else} - {$_("integration-privacy-hint-user")} - {/if} -

- - - - {/snippet} - {#snippet footer()} -
- - -
- {/snippet}
diff --git a/web/src/lib/components/settings/integrations/strava_settings_modal.svelte b/web/src/lib/components/settings/integrations/strava_settings_modal.svelte deleted file mode 100644 index cd246af4..00000000 --- a/web/src/lib/components/settings/integrations/strava_settings_modal.svelte +++ /dev/null @@ -1,154 +0,0 @@ - - - - {#snippet content()} -
- - -
- - -
- - -

- {#if $formData.privacy == "original"} - {$_("integration-privacy-hint-original")} - {:else} - {$_("integration-privacy-hint-user")} - {/if} -

-
- - -
-

- {$_("strava-integration-after-date-hint")} -

- - - - {/snippet} - {#snippet footer()} -
- - -
- {/snippet}
diff --git a/web/src/lib/components/settings/plugins/plugin_card.svelte b/web/src/lib/components/settings/plugins/plugin_card.svelte new file mode 100644 index 00000000..58a87cd7 --- /dev/null +++ b/web/src/lib/components/settings/plugins/plugin_card.svelte @@ -0,0 +1,111 @@ + + +
+
+ {#if img} + plugin logo + {:else} + + {/if} +
+
{title}
+ {#if description} +

{description}

+ {/if} +
+
+
+
+ +
+ +
+
+
+ {#if lastSyncAt} + + {#if error} + + {:else} + + {/if} + {formatLastSyncAt(lastSyncAt)} + + {:else if error} + + + {$_("plugin-setup-error")} + + {/if} +
+
+
diff --git a/web/src/lib/components/settings/plugins/plugin_instance_settings_modal.svelte b/web/src/lib/components/settings/plugins/plugin_instance_settings_modal.svelte new file mode 100644 index 00000000..be1e10de --- /dev/null +++ b/web/src/lib/components/settings/plugins/plugin_instance_settings_modal.svelte @@ -0,0 +1,753 @@ + + + + {#snippet content()} +
{ + event.preventDefault(); + submit(); + }} + > + {#each authFields as field} + + {/each} + + {#if isOAuthPlugin} +

+ {#if isConnected} + {$_("plugin-oauth-connected-hint")} + {:else} + {$_("plugin-oauth-needs-connect-hint")} + {/if} +

+ {/if} + + {#if hasTourKindChoice} +
+ + +
+ {/if} + + {#if supportsSourcePrivacy} + +

+ {#if privacy == "original"} + {$_("plugin-privacy-hint-original")} + {:else} + {$_("plugin-privacy-hint-user")} + {/if} +

+ {/if} + + {#each visibleConfigSchema as field} + {#if field.type === "select"} + + {:else if field.type === "boolean"} + + {@const hint = fieldHint(field)} + {#if hint} +

{hint}

+ {/if} + {:else if field.type === "date"} + {@const hint = fieldHint(field)} + {#if hint} +

+ {hint} +

+ {/if} +
+ + +
+ {:else if field.type === "text" || field.type === "url"} + + {/if} + {/each} + + {#if supportsCategoryMapping && categorySelectItems.length > 0} +
+
+
+

{$_("category-mapping")}

+

+ {$_("category-mapping-help")} +

+
+ +
+ {#if categoryMappingRows.length > 0} + +
6} + class:overflow-y-auto={categoryMappingRows.length > 6} + class:overflow-y-visible={categoryMappingRows.length <= 6} + > + {#each categoryMappingRows as row, i} + {@const providerItems = providerCategoryItemsForRow(i)} +
+ + + +
+ {/each} +
+ {/if} +
+ {/if} + + {#if mergeAvailable} + + {/if} + + {/snippet} + {#snippet footer()} +
+ + {#if isOAuthPlugin} + {#if needsOAuthConnect} + + {:else} + + {/if} + {:else} + + {/if} +
+ {/snippet} +
diff --git a/web/src/lib/components/settings/integrations/integration_merge_settings.svelte b/web/src/lib/components/settings/plugins/plugin_merge_settings.svelte similarity index 64% rename from web/src/lib/components/settings/integrations/integration_merge_settings.svelte rename to web/src/lib/components/settings/plugins/plugin_merge_settings.svelte index d687d02e..297f9337 100644 --- a/web/src/lib/components/settings/integrations/integration_merge_settings.svelte +++ b/web/src/lib/components/settings/plugins/plugin_merge_settings.svelte @@ -4,17 +4,19 @@ interface Props { prefix: string; + value?: boolean; } - let { prefix }: Props = $props(); + let { prefix, value = $bindable(false) }: Props = $props();

- {$_("integration-auto-merge-hint")} + {$_("plugin-auto-merge-hint")}

diff --git a/web/src/lib/components/trail/trail_dropdown.svelte b/web/src/lib/components/trail/trail_dropdown.svelte index 8bfb051c..2dc9fb5e 100644 --- a/web/src/lib/components/trail/trail_dropdown.svelte +++ b/web/src/lib/components/trail/trail_dropdown.svelte @@ -3,11 +3,6 @@ import { page } from "$app/state"; import type { List } from "$lib/models/list"; import type { Trail } from "$lib/models/trail"; - import { - integrations, - integrations_index, - uploadGpx, - } from "$lib/stores/integration_store"; import { lists_add_trail, lists_index, @@ -25,9 +20,8 @@ import { trail2gpx } from "$lib/util/gpx_util"; import { gpx } from "$lib/vendor/toGeoJSON/toGeoJSON"; import JSZip from "jszip"; - import { onMount, type Snippet } from "svelte"; + import { type Snippet } from "svelte"; import { _ } from "svelte-i18n"; - import { get } from "svelte/store"; import Dropdown, { type DropdownItem } from "../base/dropdown.svelte"; import ConfirmModal from "../confirm_modal.svelte"; import ListSearchModal from "../list/list_search_modal.svelte"; @@ -43,6 +37,7 @@ import type { MergeSelection, MergeSettings } from "./trail_merge_modal.svelte"; import MergeDialog from "$lib/components/trail/trail_merge_dialog.svelte"; import { trail_merge } from "$lib/stores/trail_merge_api"; + import { hasSendCapablePlugin } from "$lib/stores/plugin_store"; export interface MergeResult { targetTrail: Trail; @@ -65,58 +60,11 @@ let confirmModal: ConfirmModal; let listSelectModal: ListSearchModal; let trailExportModal: TrailExportModal; + let trailSendModal: TrailSendModal; let trailShareModal: TrailShareModal; let trailMergeModal: TrailMergeModal; - let trailSendModal: TrailSendModal; - - const hammerheadIntegration = $derived( - $integrations.find((integration) => - Boolean(integration.hammerhead?.active) - ) - ); let lists: List[] = $state([]); - let integrationsLoading = false; - let integrationsLoadedForUser: string | undefined; - - onMount(() => { - const unsubscribe = currentUser.subscribe(async (user) => { - if (!user) { - integrationsLoadedForUser = undefined; - return; - } - - const existing = get(integrations); - if ( - existing.length && - existing[0]?.user === user.id - ) { - integrationsLoadedForUser = user.id; - return; - } - - if ( - integrationsLoading || - integrationsLoadedForUser === user.id - ) { - return; - } - - integrationsLoading = true; - try { - await integrations_index(); - integrationsLoadedForUser = user.id; - } catch (error) { - console.error("Failed to load integrations", error); - } finally { - integrationsLoading = false; - } - }); - - return () => { - unsubscribe(); - }; - }); let loading: boolean = $state(false); @@ -214,6 +162,16 @@ return !isMultiselectMode(); } + function allowSend(): boolean { + return ( + hasTrail() && + !isMultiselectMode() && + hasGpx() && + Boolean($currentUser) && + $hasSendCapablePlugin + ); + } + function allowPublish(): boolean { if (mode !== "multi-select") return false; @@ -240,7 +198,7 @@ }); const allowListManagement = isFromCurrentUser(); const allowShareSingleTrail = !isMultiselectMode() && isFromCurrentUser(); - const allowSingleOutput = canExport() || (!isMultiselectMode() && hammerheadIntegration && canExport()); + const allowSingleOutput = canExport(); if (isMultiselectMode()) { return [ @@ -389,7 +347,7 @@ }, ] : []), - ...(!isMultiselectMode() && hammerheadIntegration && canExport() + ...(allowSend() ? [ { text: $_("send-to"), @@ -528,6 +486,8 @@ } } else if (ddVal == "share") { trailShareModal.openModal(); + } else if (ddVal == "send-to") { + trailSendModal.openModal(); } else if (ddVal == "download") { trailExportModal.openModal(); } else if (ddVal == "edit") { @@ -548,8 +508,6 @@ if (trail()) { await trailMergeModal.openSimilarTrailsModal(trail()!); } - } else if (item.value == "send-to") { - trailSendModal.openModal(); } } @@ -605,49 +563,6 @@ await trail_merge(trailSource.id, trailTarget.id, settings); onProgress?.(1); } - async function uploadToHammerhead() { - if (!hammerheadIntegration || !hasTrail()) { - console.error("No Hammerhead integration found."); - return; - } - - for (const uTrail of trails!) { - try { - if (uTrail.gpx) { - const gpxData = await trail2gpx(uTrail, $currentUser); - const formData = new FormData(); - const gpxFile = new File( - [gpxData], - `${uTrail.name || "trail"}.gpx`, - { type: "application/gpx+xml" }, - ); - formData.append("file", gpxFile); - - await uploadGpx("hammerhead", gpxFile); - - show_toast({ - type: "success", - icon: "check", - text: $_("uploaded-trail-to-hammerhead"), - }); - } else { - show_toast({ - type: "error", - icon: "close", - text: $_("trail-has-no-gpx"), - }); - } - } catch (e) { - console.error(e); - show_toast({ - type: "error", - icon: "close", - text: $_("error-uploading-trail-to-hammerhead"), - }); - } - } - } - async function updateTrailsVisibility() { const newVisibility = !majorityOfSelectedTrailsArePublic(); @@ -926,17 +841,14 @@ onsave={handleShareUpdate} bind:this={trailShareModal} > + mergeTrails(settings, selection)} > - { - if (settings.integrationName === "hammerhead") { - await uploadToHammerhead(); - } - }} -> diff --git a/web/src/lib/components/trail/trail_send_modal.svelte b/web/src/lib/components/trail/trail_send_modal.svelte index 839a6a23..60ce7a32 100644 --- a/web/src/lib/components/trail/trail_send_modal.svelte +++ b/web/src/lib/components/trail/trail_send_modal.svelte @@ -1,42 +1,145 @@ - + {#snippet content()} -
- -
+ {#if loading} +
+
+
+ {:else if eligible.length === 0} +

{$_("no-send-plugins")}

+
{$_("plugins")} + {:else} +
+ {#each eligible as plugin (plugin.id)} + + {/each} +
+ {/if} {/snippet} {#snippet footer()}
@@ -44,5 +147,5 @@ >{$_("cancel")}
- {/snippet} + {/snippet} + diff --git a/web/src/lib/i18n/locales/cs.json b/web/src/lib/i18n/locales/cs.json index 1db9629b..202d2feb 100644 --- a/web/src/lib/i18n/locales/cs.json +++ b/web/src/lib/i18n/locales/cs.json @@ -3,6 +3,7 @@ "Canoeing": "Kanoistika", "Climbing": "Horolezectví", "Hiking": "Turistika", + "Other": "Ostatní", "Skiing": "", "Walking": "Chůze", "about": "O aplikaci", @@ -144,7 +145,7 @@ "error-copying-trail": "", "error-creating-user": "Chyba při vytváření uživatele", "error-deleting-token": "", - "error-disabling-strava-integration": "Chyba při deaktivaci integrace strava", + "error-disabling-strava-plugin": "Chyba při deaktivaci pluginu strava", "error-during-login": "Během přihlášení došlo k chybě", "error-during-password-reset": "Nelze odeslat e-mail pro obnovu hesla", "error-exporting-trail": "Chyba při exportování trasy", @@ -157,11 +158,11 @@ "error-reading-file": "Soubor nelze načíst", "error-saving-list": "Chyba při ukládání seznamu", "error-saving-trail": "Chyba při ukládání trasy", - "error-setting-up-integration": "Chyba během nastavování {provider} integrace", - "error-updating-hammerhead-integration": "", - "error-updating-komoot-integration": "", + "error-setting-up-plugin": "Chyba během nastavování pluginu {provider}", + "error-updating-hammerhead-plugin": "", + "error-updating-komoot-plugin": "", "error-updating-password": "Chyba během aktualizace hesla", - "error-updating-strava-integration": "Chyba při aktualizaci Komoot integrace", + "error-updating-strava-plugin": "Chyba při aktualizaci pluginu Strava", "error-uploading-trail-to-hammerhead": "", "est-duration": "Odhadovaná doba trvání", "everyone-with-the-link": "Každý, kdo má odkaz", @@ -202,7 +203,6 @@ "get-started": "Začněte", "grid": "Mřížka", "grocery-store": "Potraviny", - "hammerhead-integration-after-date-hint": "", "heading": "Nadpis", "height": "Výška", "help": "Nápověda", @@ -224,14 +224,11 @@ "import-hint": "Vyberte nebo sem přetáhněte soubory GPX, FIT, KML, či TCX...", "include-description": "Zahrnout popis", "include-waypoints": "Zahrnout body trasy", - "integration-description-hammerhead": "", - "integration-description-komoot": "Synchronizuje vaše trasy z aplikace Komoot s Wandererem v pravidelných intervalech.", - "integration-description-strava": "Synchronizuje vaše trasy a aktivity z aplikace Strava s Wandererem v pravidelných intervalech.", - "integration-disabled": "integrace zakázána", - "integration-enabled": "integrace povolena", - "integration-privacy-hint-original": "", - "integration-privacy-hint-user": "", - "integrations": "Integrace", + "plugin-disabled": "plugin zakázán", + "plugin-enabled": "plugin povolen", + "plugin-privacy-hint-original": "", + "plugin-privacy-hint-user": "", + "plugins": "Pluginy", "invalid-date": "Neplatné datum", "invalid-username": "Neplatné uživatelské jméno", "italian": "Italština", @@ -434,7 +431,6 @@ "statistics": "Statistiky", "stop-drawing": "Ukončit kreslení", "stop-editing": "Ukončit úpravy", - "strava-integration-after-date-hint": "Pokud váš účet obsahuje velké množství aktivit, můžete narazit na limit API služby Strava, což znemožní synchronizaci všech aktivit najednou. Tomuto problému předejdete nastavením data \"Od\" a dále - synchronizují se tak pouze aktivity zaznamenané po tomto datu.", "subway-stop": "Vstup do metra", "summit": "Vrchol", "summit-book": "Vrcholová kniha", diff --git a/web/src/lib/i18n/locales/de.json b/web/src/lib/i18n/locales/de.json index be42eaee..105966f2 100644 --- a/web/src/lib/i18n/locales/de.json +++ b/web/src/lib/i18n/locales/de.json @@ -3,6 +3,7 @@ "Canoeing": "Kanufahren", "Climbing": "Klettern", "Hiking": "Wandern", + "Other": "Sonstiges", "Skiing": "Skifahren", "Walking": "Laufen", "about": "Über", @@ -30,7 +31,7 @@ "append-waypoint-description": "Kommentar anhängen", "append-waypoint-photos": "Fotos hinzufügen", "append-waypoint-title": "Titel anhängen", - "apply-user-settings": "", + "apply-user-settings": "Benutzereinstellungen anwenden", "attraction": "Sehenswürdigkeit", "author": "Autor", "avatar": "Avatar", @@ -69,11 +70,14 @@ "card": "{n, plural, =1 {Karte} other {Karten}}", "categories": "Kategorien", "category": "Kategorie", + "category-mapping": "Kategorie-Zuordnung", + "category-mapping-help": "Entfernte Provider-Kategorien werden bewusst nicht zugeordnet und erhalten beim Import keine Kategorie.", "change": "Ändern", "change-email": "Email ändern", "change-password": "Passwort ändern", "changelog": "Änderungshistorie", "chinese": "Chinesisch (vereinfacht)", + "clear": "Leeren", "clear-all": "Alle ausblenden", "dismiss": "Schließen", "climbing": "Klettern", @@ -158,7 +162,7 @@ "error-copying-trail": "Fehler beim Kopieren der Route", "error-creating-user": "Fehler beim Erstellen des Nutzers", "error-deleting-token": "", - "error-disabling-strava-integration": "Fehler beim Deaktivieren der Stravaintegration", + "error-disabling-strava-plugin": "Fehler beim Deaktivieren des Strava-Plugins", "error-during-login": "Fehler beim Login", "error-during-password-reset": "Email zum Zurücksetzen des Passworts konnte nicht versandt werden", "error-exporting-trail": "Fehler beim Exportieren der Route", @@ -172,11 +176,12 @@ "error-reading-file": "Fehler beim Lesen der Datei", "error-saving-list": "Fehler beim Speichern der Liste", "error-saving-trail": "Fehler beim Speichern der Route", - "error-setting-up-integration": "Fehler beim Einrichten der {provider}-Integration", - "error-updating-hammerhead-integration": "Fehler bei Aktualisierung der Hammerhead-Integration", - "error-updating-komoot-integration": "Fehler bei Aktualisierung der komoot-Integration", + "error-setting-up-plugin": "Fehler beim Einrichten des {provider}-Plugins", + "error-starting-oauth": "Fehler beim Starten der OAuth-Verbindung", + "error-updating-hammerhead-plugin": "Fehler bei Aktualisierung des Hammerhead-Plugins", + "error-updating-komoot-plugin": "Fehler bei Aktualisierung des komoot-Plugins", "error-updating-password": "Fehler beim Aktualisieren des Passworts", - "error-updating-strava-integration": "Fehler bei Aktualisierung der Strava-Integration", + "error-updating-strava-plugin": "Fehler bei Aktualisierung des Strava-Plugins", "error-uploading-trail-to-hammerhead": "Fehler beim Hochladen der Route zu Hammerhead", "est-duration": "Gesch. Dauer", "everyone-with-the-link": "Jeder mit dem Link", @@ -218,7 +223,6 @@ "get-started": "Los geht’s", "grid": "Gitter", "grocery-store": "Lebensmittelgeschäft", - "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", "height": "Höhe", "help": "Hilfe", @@ -240,23 +244,46 @@ "import-hint": "GPX, FIT, KML oder TCX Dateien auswählen oder hierher ziehen...", "include-description": "Beschreibung übernehmen", "include-waypoints": "Wegpunkte einbeziehen", - "integration-description-hammerhead": "Synchronisiert Deine Hammerhead-Touren regelmäßig mit wanderer.", - "integration-description-komoot": "Synchronisiert Deine komoot-Touren regelmäßig mit wanderer.", - "integration-description-strava": "Synchronisiert Deine Strava-Routen und -Aktivitäten regelmäßig mit wanderer.", - "integration-auto-merge-label": "Automatisch mergen", - "integration-auto-merge-hint": "Nach dem Import wird nur dann automatisch gemergt, wenn ein eindeutiger Treffer gefunden wird. Der importierte Trail wird in diesem Fall nicht separat gespeichert.", - "integration-disabled": "Integration deaktiviert", - "integration-enabled": "Integration aktiviert", - "integration-privacy-hint-original": "", - "integration-privacy-hint-user": "", - "integrations": "Integrationen", + "plugin-auto-merge-label": "Automatisch mergen", + "plugin-auto-merge-hint": "Nach dem Import wird nur dann automatisch gemergt, wenn ein eindeutiger Treffer gefunden wird. Der importierte Trail wird in diesem Fall nicht separat gespeichert.", + "plugin-after-date-hint": "Lege ein Startdatum fest, um nur Routen zu synchronisieren, die nach diesem Datum aufgezeichnet wurden. Das kann helfen, Duplikate zu vermeiden, wenn ältere Routen bereits in wanderer vorhanden sind.", + "plugin-category-remap-action": "Kategorien aktualisieren", + "plugin-category-remap-back-to-settings": "Zurück zu den Einstellungen", + "plugin-category-remap-backfilled-confirm": "Für {count} importierte Routen wurde die Provider-Kategorie erst nach deiner letzten Änderung der wanderer Kategorie-Zuordnung ermittelt. Soll die Zuordnung für diese Routen jetzt nachgeholt werden? Andere Routendaten bleiben unverändert.", + "plugin-category-remap-confirm": "{count} Routen sind von den Zuordnungsanpassungen betroffen. Soll die aktualisierte Zuordnung jetzt auf diese Routen angewendet werden? Andere Routendaten bleiben unverändert.", + "plugin-category-remap-confirm-unspecified": "Die Kategorie-Zuordnung wurde geändert. Aktuell sind keine importierten Routen bekannt, die sofort aktualisiert werden können. Änderungen speichern?", + "plugin-category-remap-continue-without-remap": "Ohne Remapping fortfahren", + "plugin-category-remap-dismiss-error": "Remap-Erinnerung konnte nicht gespeichert werden", + "plugin-category-remap-error": "Kategorien importierter Routen konnten nicht aktualisiert werden", + "plugin-category-remap-ignore": "Ignorieren", + "plugin-category-remap-preview-error": "Importierte Routen konnten nicht auf Kategorie-Updates geprüft werden", + "plugin-category-remap-success": "Kategorien für {count} importierte Routen aktualisiert", + "plugin-category-remap-sync-hint": "Einige dieser Treffer wurden erst nach der letzten Änderung der Zuordnung durch eine Synchronisierung gefunden.", + "plugin-category-remap-title": "Kategorien importierter Routen aktualisieren?", + "plugin-connect-before-enabling": "Verbinde dieses Plugin, bevor du es aktivierst.", + "plugin-disabled": "Plugin deaktiviert", + "plugin-enabled": "Plugin aktiviert", + "plugins-empty-title": "Keine Plugins installiert", + "plugins-empty-description": "Auf diesem Server sind noch keine Plugins verfügbar. Kontaktiere deinen Administrator oder lies die Installationsanleitung.", + "plugins-empty-docs-link": "Plugins installieren", + "plugin-oauth-connected-hint": "Dieses Plugin ist verbunden. Wenn du die OAuth-Zugangsdaten änderst, verbinde es erneut, bevor du die Synchronisierung wieder aktivierst.", + "plugin-oauth-needs-connect-hint": "Speichere und verbinde dieses Plugin, bevor du die Synchronisierung aktivierst.", + "plugin-privacy-hint-original": "Importierte Routen behalten dieselbe Sichtbarkeit wie auf der externen Plattform. Wenn die ursprüngliche Route öffentlich war, ist sie auch in wanderer öffentlich, selbst wenn Routen laut deinen Privatsphäre-Einstellungen standardmäßig privat sind.", + "plugin-privacy-hint-user": "Die ursprüngliche Sichtbarkeit der Route wird verworfen. Stattdessen werden die lokalen Privatsphäre-Einstellungen für Routen auf alle importierten Routen angewendet.", + "plugin-setup-error": "Setup-Fehler", + "plugin-error-rate-limited": "Der Anbieter begrenzt gerade Anfragen. Versuche es später erneut.", + "plugin-error-provider-unavailable": "Der Anbieter ist momentan nicht erreichbar. Versuche es später erneut.", + "plugin-error-invalid-request": "Das Plugin hat eine ungültige Anfrage gesendet.", + "plugin-error-internal": "Das Plugin ist unerwartet fehlgeschlagen.", + "plugin-error-reconnect-required": "Die Plugin-Autorisierung ist abgelaufen. Verbinde das Plugin erneut.", "invalid-date": "Ungültiges Datum", "invalid-username": "Ungültiger Nutzername", "italian": "Italienisch", "joined": "Beigetreten", - "keep-original": "", + "keep-original": "Original beibehalten", "keep-private": "Ohne Veröffentlichung fortfahren", "language": "Sprache", + "last-sync": "Letzte Synchronisierung", "last-used": "", "latitude": "Breitengrad", "layer": "{n, plural, =1 {Ebene} other {Ebenen}}", @@ -378,6 +405,7 @@ "privacy": "Privatsphäre", "private": "Privat", "profile": "Profil", + "provider-category": "Provider-Kategorie", "public": "Öffentlich", "public-access": "Öffentlicher Zugriff", "public-share-everyone": "Jeder im Internet mit dem Link kann diese Route sehen", @@ -393,6 +421,7 @@ "remote-users-cannot-edit": "Remote-Benutzer können nicht bearbeiten", "removed-trail-from": "Route entfernt aus", "removed-trails-from": "Routen entfernt aus", + "remove": "Entfernen", "required": "Pflichtfeld", "reset": "Zurücksetzen", "reset-password": "Passwort zurücksetzen", @@ -414,8 +443,16 @@ "search-places": "Orte suchen", "search-trails": "Route suchen", "select-list": "Liste auswählen", + "select-category": "Kategorie auswählen", + "select-provider-category": "Provider-Kategorie auswählen", "selected": "ausgewählt", "send-to": "Senden an...", + "trail-sent": "Trail gesendet", + "error-sending-trail": "Fehler beim Senden des Trails", + "no-send-plugins": "Kein verbundenes Plugin unterstützt das Senden von Trails. Verbinde zuerst eines in den Plugin-Einstellungen.", + "plugins": "Plugins", + "plugin-type-trails": "Trails", + "plugin-type-trails-description": "Trail-Plugins verbinden wanderer mit externen Diensten und synchronisieren Routen oder Aktivitäten automatisch im Hintergrund.", "set-private": "Verbergen", "set-public": "Veröffentlichen", "settings": "Einstellungen", @@ -437,6 +474,9 @@ "settings-privacy-trails-private": "Deine Routen sind standardmäßig privat. Niemand außer Dir kann sie sehen. Du kannst diese Einstellung jederzeit für einzelne Routen ändern.", "settings-privacy-trails-public": "Deine Trails sind standardmäßig öffentlich. Jeder kann sie sehen. Du kannst diese Einstellung jederzeit für einzelne Routen ändern.", "settings-saved": "Einstellungen gespeichert", + "save-and-connect": "Speichern & verbinden", + "save-and-reconnect": "Speichern & neu verbinden", + "save-and-validate": "Speichern & prüfen", "share": "Teilen", "share-profile": "Profil teilen", "share-this-list": "Diese Liste teilen", @@ -462,7 +502,6 @@ "statistics": "Statistiken", "stop-drawing": "Zeichnen beenden", "stop-editing": "Bearbeiten beenden", - "strava-integration-after-date-hint": "Wenn Ihr Konto eine große Anzahl von Aktivitäten enthält, kann es vorkommen, dass Sie aufgrund der API-Restriktionen von Strava nicht alle Aktivitäten auf einmal synchronisieren können. Um dieses Problem zu umgehen, können Sie unten ein Startdatum festlegen, sodass nur Aktivitäten synchronisiert werden, die nach diesem Datum aufgezeichnet wurden.", "subway-stop": "U-Bahn Eingang", "summit": "Gipfel", "summit-book": "Gipfelbuch", diff --git a/web/src/lib/i18n/locales/en.json b/web/src/lib/i18n/locales/en.json index cf5bde1d..f49ea388 100644 --- a/web/src/lib/i18n/locales/en.json +++ b/web/src/lib/i18n/locales/en.json @@ -3,6 +3,7 @@ "Canoeing": "Canoeing", "Climbing": "Climbing", "Hiking": "Hiking", + "Other": "Other", "Skiing": "", "Walking": "Walking", "about": "About", @@ -69,11 +70,14 @@ "card": "{n, plural, =1 {Card} other {Cards}}", "categories": "Categories", "category": "Category", + "category-mapping": "Category mapping", + "category-mapping-help": "Removed provider categories are intentionally left unmapped and will be imported without a category.", "change": "Change", "change-email": "Change email", "change-password": "Change password", "changelog": "Changelog", "chinese": "Chinese (simplified)", + "clear": "Clear", "clear-all": "Clear all", "dismiss": "Dismiss", "climbing": "Climbing", @@ -158,7 +162,7 @@ "error-copying-trail": "Error copying trail", "error-creating-user": "Error creating user", "error-deleting-token": "Error deleting token", - "error-disabling-strava-integration": "Error disabling strava integration", + "error-disabling-strava-plugin": "Error disabling strava plugin", "error-during-login": "Error during login", "error-during-password-reset": "Unable to send password reset email", "error-exporting-trail": "Error exporting trail", @@ -172,11 +176,12 @@ "error-reading-file": "Error reading file", "error-saving-list": "Error saving list", "error-saving-trail": "Error saving trail", - "error-setting-up-integration": "Error setting up {provider} integration", - "error-updating-hammerhead-integration": "Error updating Hammerhead integration", - "error-updating-komoot-integration": "Error updating komoot integration", + "error-setting-up-plugin": "Error setting up {provider} plugin", + "error-starting-oauth": "Error starting OAuth connection", + "error-updating-hammerhead-plugin": "Error updating Hammerhead plugin", + "error-updating-komoot-plugin": "Error updating komoot plugin", "error-updating-password": "Error updating password", - "error-updating-strava-integration": "Error updating Strava integration", + "error-updating-strava-plugin": "Error updating Strava plugin", "error-uploading-trail-to-hammerhead": "Error uploading trail to Hammerhead", "est-duration": "Est. duration", "everyone-with-the-link": "Everyone with the link", @@ -218,7 +223,6 @@ "get-started": "Get started", "grid": "Grid", "grocery-store": "Grocery store", - "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", "height": "Height", "help": "Help", @@ -240,16 +244,38 @@ "import-hint": "Select or drag GPX, FIT, KML or TCX files here...", "include-description": "Include description", "include-waypoints": "Include waypoints", - "integration-description-hammerhead": "Syncs your Hammerhead tours with wanderer in regular intervals.", - "integration-description-komoot": "Syncs your komoot tours with wanderer in regular intervals.", - "integration-description-strava": "Syncs your Strava routes & activities with wanderer in regular intervals.", - "integration-auto-merge-label": "Auto-merge", - "integration-auto-merge-hint": "Imported trails are only merged automatically when exactly one clear match is found. The imported trail is always deleted afterwards.", - "integration-disabled": "integration disabled", - "integration-enabled": "integration enabled", - "integration-privacy-hint-original": "Imported trails will maintain the same visibility they have on the external platform. For example, if the original trail was public, it will be public in wanderer, even if trails are private by default according to your privacy settings.", - "integration-privacy-hint-user": "The original trail's visibility is discarded. Instead, the local privacy settings for trails are applied to all imported trails.", - "integrations": "Integrations", + "plugin-auto-merge-label": "Auto-merge", + "plugin-auto-merge-hint": "Imported trails are only merged automatically when exactly one clear match is found. The imported trail is always deleted afterwards.", + "plugin-after-date-hint": "Set a start date to sync only trails recorded after that date. This can help avoid duplicates when older trails are already available in wanderer.", + "plugin-category-remap-action": "Update categories", + "plugin-category-remap-back-to-settings": "Back to settings", + "plugin-category-remap-backfilled-confirm": "For {count} imported trails, the provider category was detected only after you last changed the wanderer category mapping. Apply the mapping to these trails now? Other trail data stays unchanged.", + "plugin-category-remap-confirm": "{count} trails are affected by the category mapping changes. Apply the updated mapping to these trails now? Other trail data stays unchanged.", + "plugin-category-remap-confirm-unspecified": "The category mapping was changed. There are currently no imported trails that can be updated immediately. Save changes?", + "plugin-category-remap-continue-without-remap": "Continue without remapping", + "plugin-category-remap-dismiss-error": "Could not save remap reminder preference", + "plugin-category-remap-error": "Could not update imported trail categories", + "plugin-category-remap-ignore": "Ignore", + "plugin-category-remap-preview-error": "Could not check imported trails for category updates", + "plugin-category-remap-success": "Updated categories for {count} imported trails", + "plugin-category-remap-sync-hint": "Some of these matches were found during a sync after the mapping was last changed.", + "plugin-category-remap-title": "Update imported trail categories?", + "plugin-connect-before-enabling": "Connect this plugin before enabling it.", + "plugin-disabled": "plugin disabled", + "plugin-enabled": "plugin enabled", + "plugins-empty-title": "No plugins installed", + "plugins-empty-description": "No plugins are available on this server yet. Contact your administrator or read the installation guide.", + "plugins-empty-docs-link": "How to install plugins", + "plugin-oauth-connected-hint": "This plugin is connected. If you change the OAuth credentials, reconnect it before enabling sync again.", + "plugin-oauth-needs-connect-hint": "Save and connect this plugin before enabling sync.", + "plugin-privacy-hint-original": "Imported trails will maintain the same visibility they have on the external platform. For example, if the original trail was public, it will be public in wanderer, even if trails are private by default according to your privacy settings.", + "plugin-privacy-hint-user": "The original trail's visibility is discarded. Instead, the local privacy settings for trails are applied to all imported trails.", + "plugin-setup-error": "Setup error", + "plugin-error-rate-limited": "Provider rate limit reached. Try again later.", + "plugin-error-provider-unavailable": "Provider is currently unavailable. Try again later.", + "plugin-error-invalid-request": "Plugin sent an invalid request.", + "plugin-error-internal": "Plugin failed unexpectedly.", + "plugin-error-reconnect-required": "Plugin authorization expired. Reconnect the plugin.", "invalid-date": "Invalid Date", "invalid-username": "Invalid username", "italian": "Italian", @@ -257,6 +283,7 @@ "keep-original": "Keep original", "keep-private": "Keep private", "language": "Language", + "last-sync": "Last sync", "last-used": "Last used", "latitude": "Latitude", "layer": "{n, plural, =1 {Layer} other {Layers}}", @@ -378,6 +405,7 @@ "privacy": "Privacy", "private": "Private", "profile": "Profile", + "provider-category": "Provider category", "public": "Public", "public-access": "Public access", "public-share-everyone": "Everyone on the internet with the link can see this trail", @@ -393,6 +421,7 @@ "remote-users-cannot-edit": "Remote users cannot edit", "removed-trail-from": "Removed trail from", "removed-trails-from": "Removed trails from", + "remove": "Remove", "required": "Required", "reset": "Reset", "reset-password": "Reset Password", @@ -414,8 +443,16 @@ "search-places": "Search places", "search-trails": "Search trails", "select-list": "Select List", + "select-category": "Select category", + "select-provider-category": "Select provider category", "selected": "selected", "send-to": "Send to...", + "trail-sent": "Trail sent", + "error-sending-trail": "Error sending trail", + "no-send-plugins": "No connected plugin supports sending trails. Connect one in the plugin settings first.", + "plugins": "Plugins", + "plugin-type-trails": "Trails", + "plugin-type-trails-description": "Trail plugins connect wanderer with external services and automatically sync routes or activities in the background.", "set-private": "Set private", "set-public": "Set public", "settings": "Settings", @@ -437,6 +474,9 @@ "settings-privacy-trails-private": "Your trails are private by default. No one except you will be able to see them. You can change this setting at any point for individual trails.", "settings-privacy-trails-public": "Your trails are public by default. Everyone will be able to see them. You can change this setting at any point for individual trails.", "settings-saved": "Settings saved", + "save-and-connect": "Save & connect", + "save-and-reconnect": "Save & reconnect", + "save-and-validate": "Save & validate", "share": "Share", "share-profile": "Share profile", "share-this-list": "Share this list", @@ -462,7 +502,6 @@ "statistics": "Statistics", "stop-drawing": "Stop drawing", "stop-editing": "Stop editing", - "strava-integration-after-date-hint": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an start date below so that only activities that were recorded after this date are synced.", "subway-stop": "Subway entrance", "summit": "Summit", "summit-book": "Summit Book", diff --git a/web/src/lib/i18n/locales/es.json b/web/src/lib/i18n/locales/es.json index 36f1b825..9db41f8c 100644 --- a/web/src/lib/i18n/locales/es.json +++ b/web/src/lib/i18n/locales/es.json @@ -3,6 +3,7 @@ "Canoeing": "Remo", "Climbing": "Escalada", "Hiking": "Senderismo", + "Other": "Otro", "Skiing": "", "Walking": "Paseo", "about": "Sobre", @@ -144,7 +145,7 @@ "error-copying-trail": "", "error-creating-user": "Error creando el usuario", "error-deleting-token": "", - "error-disabling-strava-integration": "Error al desactivar la integración de strava", + "error-disabling-strava-plugin": "Error al desactivar el plugin de Strava", "error-during-login": "Error durante el acceso", "error-during-password-reset": "Imposible enviar la contraseña de restablecimiento al correo electrónico", "error-exporting-trail": "Error exportando la ruta", @@ -157,11 +158,11 @@ "error-reading-file": "Error leyendo el archivo", "error-saving-list": "Error guardando la lista", "error-saving-trail": "Error guardando la ruta", - "error-setting-up-integration": "Error al configurar la integración con {provider}", - "error-updating-hammerhead-integration": "", - "error-updating-komoot-integration": "", + "error-setting-up-plugin": "Error al configurar el plugin {provider}", + "error-updating-hammerhead-plugin": "", + "error-updating-komoot-plugin": "", "error-updating-password": "Error actualizando la contraseña", - "error-updating-strava-integration": "Error al actualizar la integración con komoot", + "error-updating-strava-plugin": "Error al actualizar el plugin Strava", "error-uploading-trail-to-hammerhead": "", "est-duration": "Duración estimada", "everyone-with-the-link": "Cualquier persona con el enlace", @@ -202,7 +203,6 @@ "get-started": "Iniciar", "grid": "Cuadricula", "grocery-store": "Supermercado", - "hammerhead-integration-after-date-hint": "", "heading": "Título", "height": "Altura", "help": "Ayuda", @@ -224,14 +224,11 @@ "import-hint": "Selecciona o arrastra aquí archivos GPX, FIT, KML o TCX...", "include-description": "Incluir descripción", "include-waypoints": "Incluir puntos de interés", - "integration-description-hammerhead": "", - "integration-description-komoot": "Sincroniza tus recorridos de Komoot con Wanderer en intervalos regulares.", - "integration-description-strava": "Sincroniza tus recorridos y actividades de Strava con Wanderer en intervalos regulares.", - "integration-disabled": "integración desactivada", - "integration-enabled": "integración activada", - "integration-privacy-hint-original": "", - "integration-privacy-hint-user": "", - "integrations": "Integraciones", + "plugin-disabled": "plugin desactivado", + "plugin-enabled": "plugin activado", + "plugin-privacy-hint-original": "", + "plugin-privacy-hint-user": "", + "plugins": "Plugins", "invalid-date": "Fecha no válida", "invalid-username": "Usuario no válido", "italian": "Italiano", @@ -434,7 +431,6 @@ "statistics": "Estadísticas", "stop-drawing": "Parar de diseñar", "stop-editing": "Parar de editar", - "strava-integration-after-date-hint": "Si tu cuenta tiene una gran cantidad de actividades, es posible que alcances el límite de peticiones de la API de Strava, lo que impedirá la sincronización de todas las actividades a la vez. Para mitigar este problema, puedes establecer una fecha \"Posterior a\" a continuación, de modo que solo se sincronicen las actividades que se registraron después de esa fecha.", "subway-stop": "Entrada de metro", "summit": "Cumbre", "summit-book": "Libro de ascensos", diff --git a/web/src/lib/i18n/locales/eu.json b/web/src/lib/i18n/locales/eu.json index 0611b5b8..25678ca3 100644 --- a/web/src/lib/i18n/locales/eu.json +++ b/web/src/lib/i18n/locales/eu.json @@ -3,6 +3,7 @@ "Canoeing": "Kanoa", "Climbing": "Eskalada", "Hiking": "Mendi-ibilaldia", + "Other": "Besteak", "Skiing": "", "Walking": "Oinez", "about": "Honi buruz", @@ -144,7 +145,7 @@ "error-copying-trail": "", "error-creating-user": "Errorea erabiltzailea sortzen", "error-deleting-token": "", - "error-disabling-strava-integration": "Errorea stravarekin integrazioa desaktibatzean", + "error-disabling-strava-plugin": "Errorea strava plugina desaktibatzean", "error-during-login": "Errorea sartzean", "error-during-password-reset": "Ezin izan da pasahitza berrezartzeko mezua bidali", "error-exporting-trail": "Errorea ibilbidea esportatzean", @@ -157,11 +158,11 @@ "error-reading-file": "Errorea fitxategia irakurtzean", "error-saving-list": "Errorea zerrenda gordetzean", "error-saving-trail": "Errorea ibilbidea gordetzean", - "error-setting-up-integration": "Errorea {provider} integrazioa egitean", - "error-updating-hammerhead-integration": "", - "error-updating-komoot-integration": "", + "error-setting-up-plugin": "Errorea {provider} plugina konfiguratzean", + "error-updating-hammerhead-plugin": "", + "error-updating-komoot-plugin": "", "error-updating-password": "Errorea pasahitza eguneratzean", - "error-updating-strava-integration": "Errorea komoot integrazioa eguneratzean", + "error-updating-strava-plugin": "Errorea Strava plugina eguneratzean", "error-uploading-trail-to-hammerhead": "", "est-duration": "Ustezko iraupena", "everyone-with-the-link": "Esteka duen edonor", @@ -202,7 +203,6 @@ "get-started": "Hasi", "grid": "Sareta", "grocery-store": "Janari-denda", - "hammerhead-integration-after-date-hint": "", "heading": "Goiburukoa", "height": "Altuera", "help": "Laguntza", @@ -224,14 +224,11 @@ "import-hint": "Aukeratu edo arrastatu hona GPX, FIT, KML edo TCX fitxategiak...", "include-description": "Gehitu deskribapena", "include-waypoints": "Gehitu bidepuntuak", - "integration-description-hammerhead": "", - "integration-description-komoot": "Zure komooteko ibilbideak wandererekin sinkronizatzen ditu aldian behin.", - "integration-description-strava": "Zure stravako ibilbideak wandererekin sinkronizatzen ditu aldian behin.", - "integration-disabled": "integrazioa desaktibatuta", - "integration-enabled": "integrazioa aktibatuta", - "integration-privacy-hint-original": "", - "integration-privacy-hint-user": "", - "integrations": "Integrazioak", + "plugin-disabled": "plugina desaktibatuta", + "plugin-enabled": "plugina aktibatuta", + "plugin-privacy-hint-original": "", + "plugin-privacy-hint-user": "", + "plugins": "Pluginak", "invalid-date": "Data ez da zuzena", "invalid-username": "Erabiltzailea ez da zuzena", "italian": "Italiera", @@ -434,7 +431,6 @@ "statistics": "Estatistikak", "stop-drawing": "Utzi marrazteari", "stop-editing": "Utzi editatzeari", - "strava-integration-after-date-hint": "Zure kontuak ekintza esko baditu Stravaren APIaren mugekin topo egin dezakezu eta agian ezingo dituzu zure ekintza guztiak aldi berean inportatu. Horretarako data jakin batetik aurrerako ekintzak sinkronizatzeko aukera duzu.", "subway-stop": "Metro sarbidea", "summit": "Gailurra", "summit-book": "Igoeren liburua", diff --git a/web/src/lib/i18n/locales/fr.json b/web/src/lib/i18n/locales/fr.json index d4e3b623..6b488f64 100644 --- a/web/src/lib/i18n/locales/fr.json +++ b/web/src/lib/i18n/locales/fr.json @@ -3,6 +3,7 @@ "Canoeing": "Canoë", "Climbing": "Escalade", "Hiking": "Randonnée", + "Other": "Autre", "Skiing": "", "Walking": "Marche", "about": "Informations", @@ -144,7 +145,7 @@ "error-copying-trail": "", "error-creating-user": "Erreur durant la création de l'utilisateur", "error-deleting-token": "", - "error-disabling-strava-integration": "Erreur lors de la désactivation de l'intégration Strava", + "error-disabling-strava-plugin": "Erreur lors de la désactivation du plugin Strava", "error-during-login": "Erreur durant la connexion", "error-during-password-reset": "Impossible d'envoyer l'e-mail de réinitialisation du mot de passe", "error-exporting-trail": "Erreur lors de l'export de l'itinéraire", @@ -157,11 +158,11 @@ "error-reading-file": "Erreur de lecture du fichier", "error-saving-list": "Erreur lors de l'enregistrement de la liste", "error-saving-trail": "Erreur lors de l'enregistrement de l'itinéraire", - "error-setting-up-integration": "Error setting up strava integration", - "error-updating-hammerhead-integration": "", - "error-updating-komoot-integration": "", + "error-setting-up-plugin": "Erreur lors de la configuration du plugin {provider}", + "error-updating-hammerhead-plugin": "", + "error-updating-komoot-plugin": "", "error-updating-password": "Erreur lors de la mise à jour du mot de passe", - "error-updating-strava-integration": "Erreur lors de la mise à jour de l'intégration Komoot", + "error-updating-strava-plugin": "Erreur lors de la mise à jour du plugin Strava", "error-uploading-trail-to-hammerhead": "", "est-duration": "Temps estimé", "everyone-with-the-link": "Tout le monde avec ce lien", @@ -202,7 +203,6 @@ "get-started": "C'est parti", "grid": "Grille", "grocery-store": "Épicerie", - "hammerhead-integration-after-date-hint": "", "heading": "Titre", "height": "Hauteur", "help": "Aide", @@ -224,14 +224,11 @@ "import-hint": "Sélectionnez ou glissez des fichiers GPX, FIT, KML ou TCX ici...", "include-description": "Inclure la description", "include-waypoints": "Inclure les points de passage", - "integration-description-hammerhead": "", - "integration-description-komoot": "Synchronisez vos Tours Komoot avec wanderer à intervalles réguliers.", - "integration-description-strava": "Synchronisez vos itinéraires et vos activités Strava avec wanderer à intervalles réguliers.", - "integration-disabled": "Intégration désactivée", - "integration-enabled": "Intégration activée", - "integration-privacy-hint-original": "", - "integration-privacy-hint-user": "", - "integrations": "Intégrations", + "plugin-disabled": "Plugin désactivé", + "plugin-enabled": "Plugin activé", + "plugin-privacy-hint-original": "", + "plugin-privacy-hint-user": "", + "plugins": "Plugins", "invalid-date": "Date invalide", "invalid-username": "Nom d'utilisateur invalide", "italian": "Italien", @@ -434,7 +431,6 @@ "statistics": "Statistiques", "stop-drawing": "Arrêter de tracer", "stop-editing": "Arrêter la modification", - "strava-integration-after-date-hint": "Si votre compte a une grande quantité d'activités, vous pouvez rencontrer la limite d'utilisation de l'API de Strava vous empêchant de synchroniser toutes les activités en même temps. Pour atténuer ce problème, vous pouvez définir une date \"Après-\" ci-dessous afin que seules les activités qui ont été enregistrées après cette date soient synchronisées.", "subway-stop": "Bouche de métro", "summit": "Sommet", "summit-book": "Liste des ascensions", diff --git a/web/src/lib/i18n/locales/hu.json b/web/src/lib/i18n/locales/hu.json index 0a40e85a..8e4517dd 100644 --- a/web/src/lib/i18n/locales/hu.json +++ b/web/src/lib/i18n/locales/hu.json @@ -3,6 +3,7 @@ "Canoeing": "Canoeing", "Climbing": "Climbing", "Hiking": "Hiking", + "Other": "Egyéb", "Skiing": "", "Walking": "Walking", "about": "A programról", @@ -144,7 +145,7 @@ "error-copying-trail": "", "error-creating-user": "Hiba felhasználó hozzáadása közben", "error-deleting-token": "", - "error-disabling-strava-integration": "Error disabling strava integration", + "error-disabling-strava-plugin": "Error disabling strava plugin", "error-during-login": "Hiba bejelentkezés közben", "error-during-password-reset": "Unable to send password reset email", "error-exporting-trail": "Error exporting trail", @@ -157,11 +158,11 @@ "error-reading-file": "Hiba a fájl olvasása közben", "error-saving-list": "Error saving list", "error-saving-trail": "Error saving trail", - "error-setting-up-integration": "Error setting up strava integration", - "error-updating-hammerhead-integration": "", - "error-updating-komoot-integration": "", + "error-setting-up-plugin": "Error setting up {provider} plugin", + "error-updating-hammerhead-plugin": "", + "error-updating-komoot-plugin": "", "error-updating-password": "Error updating password", - "error-updating-strava-integration": "Error updating komoot integration", + "error-updating-strava-plugin": "Error updating Strava plugin", "error-uploading-trail-to-hammerhead": "", "est-duration": "Becsült időtartam", "everyone-with-the-link": "Everyone with the link", @@ -202,7 +203,6 @@ "get-started": "Get started", "grid": "Grid", "grocery-store": "Grocery store", - "hammerhead-integration-after-date-hint": "", "heading": "Heading", "height": "Height", "help": "Help", @@ -224,14 +224,11 @@ "import-hint": "Select or drag GPX, FIT, KML or TCX files here...", "include-description": "Include description", "include-waypoints": "Útpontok hozzáadása", - "integration-description-hammerhead": "", - "integration-description-komoot": "Syncs your komoot tours with wanderer in regular intervals.", - "integration-description-strava": "Syncs your strava routes & activities with wanderer in regular intervals.", - "integration-disabled": "integration disabled", - "integration-enabled": "integration enabled", - "integration-privacy-hint-original": "", - "integration-privacy-hint-user": "", - "integrations": "Integrations", + "plugin-disabled": "plugin disabled", + "plugin-enabled": "plugin enabled", + "plugin-privacy-hint-original": "", + "plugin-privacy-hint-user": "", + "plugins": "Bővítmények", "invalid-date": "Érvénytelen dátum", "invalid-username": "Érvénytelen felhasználó", "italian": "Olasz", @@ -434,7 +431,6 @@ "statistics": "Statistics", "stop-drawing": "Stop drawing", "stop-editing": "Stop editing", - "strava-integration-after-date-hint": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.", "subway-stop": "Subway entrance", "summit": "Summit", "summit-book": "Csúcspont könyv", diff --git a/web/src/lib/i18n/locales/it.json b/web/src/lib/i18n/locales/it.json index 57d4d55a..9428e300 100644 --- a/web/src/lib/i18n/locales/it.json +++ b/web/src/lib/i18n/locales/it.json @@ -3,6 +3,7 @@ "Canoeing": "Canoa", "Climbing": "Arrampicata", "Hiking": "Escursionismo", + "Other": "Altro", "Skiing": "", "Walking": "Camminare", "about": "Su di noi", @@ -144,7 +145,7 @@ "error-copying-trail": "", "error-creating-user": "Errore nella creazione dell'utente", "error-deleting-token": "", - "error-disabling-strava-integration": "Error disabling strava integration", + "error-disabling-strava-plugin": "Error disabling strava plugin", "error-during-login": "Errore durante il login", "error-during-password-reset": "Impossibile inviare email per ripristinare la password", "error-exporting-trail": "Errore durante l'esportazione del percorso", @@ -157,11 +158,11 @@ "error-reading-file": "Errore durante la lettura del file", "error-saving-list": "Errore salvando la lista", "error-saving-trail": "Errore nel salvataggio del percorso", - "error-setting-up-integration": "Error setting up strava integration", - "error-updating-hammerhead-integration": "", - "error-updating-komoot-integration": "", + "error-setting-up-plugin": "Errore durante la configurazione del plugin {provider}", + "error-updating-hammerhead-plugin": "", + "error-updating-komoot-plugin": "", "error-updating-password": "Errore nell'aggiornamento della password", - "error-updating-strava-integration": "Error updating komoot integration", + "error-updating-strava-plugin": "Errore durante l'aggiornamento del plugin Strava", "error-uploading-trail-to-hammerhead": "", "est-duration": "Durata stimata", "everyone-with-the-link": "Everyone with the link", @@ -202,7 +203,6 @@ "get-started": "Get started", "grid": "Griglia", "grocery-store": "Grocery store", - "hammerhead-integration-after-date-hint": "", "heading": "Heading", "height": "Height", "help": "Aiuto", @@ -224,14 +224,11 @@ "import-hint": "Seleziona o trascina qui i file GPX, FIT, KML o TCX...", "include-description": "Adotta descrizione", "include-waypoints": "Includi waypoint", - "integration-description-hammerhead": "", - "integration-description-komoot": "Syncs your komoot tours with wanderer in regular intervals.", - "integration-description-strava": "Syncs your strava routes & activities with wanderer in regular intervals.", - "integration-disabled": "integration disabled", - "integration-enabled": "integration enabled", - "integration-privacy-hint-original": "", - "integration-privacy-hint-user": "", - "integrations": "Integrations", + "plugin-disabled": "plugin disattivato", + "plugin-enabled": "plugin attivato", + "plugin-privacy-hint-original": "", + "plugin-privacy-hint-user": "", + "plugins": "Plugin", "invalid-date": "Data non valida", "invalid-username": "Nome utente non valido", "italian": "Italiano", @@ -434,7 +431,6 @@ "statistics": "Statistiche", "stop-drawing": "Smettere di disegnare", "stop-editing": "Stop editing", - "strava-integration-after-date-hint": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.", "subway-stop": "Subway entrance", "summit": "Summit", "summit-book": "Libro di vetta", diff --git a/web/src/lib/i18n/locales/nl.json b/web/src/lib/i18n/locales/nl.json index 7ce6cec0..3e633add 100644 --- a/web/src/lib/i18n/locales/nl.json +++ b/web/src/lib/i18n/locales/nl.json @@ -3,6 +3,7 @@ "Canoeing": "Kanoën", "Climbing": "Klimmen", "Hiking": "Hiken", + "Other": "Overig", "Skiing": "", "Walking": "Wandelen", "about": "Over", @@ -144,7 +145,7 @@ "error-copying-trail": "", "error-creating-user": "Fout bij aanmaken gebruiker", "error-deleting-token": "", - "error-disabling-strava-integration": "Fout bij het uitschakelen van Strava-integratie", + "error-disabling-strava-plugin": "Fout bij het uitschakelen van Strava-plug-in", "error-during-login": "Het inloggen is mislukt", "error-during-password-reset": "Kan geen e-mail voor wachtwoordherstel verzenden", "error-exporting-trail": "Fout bij exporteren van parcours", @@ -157,11 +158,11 @@ "error-reading-file": "Fout bij inlezen bestand", "error-saving-list": "Fout bij bewaren van lijst", "error-saving-trail": "Fout bij bewaren van route", - "error-setting-up-integration": "Fout bij opzetten {provider} integratie", - "error-updating-hammerhead-integration": "", - "error-updating-komoot-integration": "", + "error-setting-up-plugin": "Fout bij instellen van {provider}-plug-in", + "error-updating-hammerhead-plugin": "", + "error-updating-komoot-plugin": "", "error-updating-password": "Fout bij bijwerken van wachtwoord", - "error-updating-strava-integration": "Fout bij bijwerken van Komoot integratie", + "error-updating-strava-plugin": "Fout bij bijwerken van Strava-plug-in", "error-uploading-trail-to-hammerhead": "", "est-duration": "Geschatte duur", "everyone-with-the-link": "Iedereen met de link", @@ -202,7 +203,6 @@ "get-started": "Aan de slag", "grid": "Rooster", "grocery-store": "Kruidenier", - "hammerhead-integration-after-date-hint": "", "heading": "Titel", "height": "Hoogte", "help": "Help", @@ -224,14 +224,11 @@ "import-hint": "Selecteer of sleep GPX-, FIT-, KML- of TCX-bestanden hierheen...", "include-description": "Inclusief beschrijving", "include-waypoints": "Waypoints toevoegen", - "integration-description-hammerhead": "", - "integration-description-komoot": "Synchroniseert je Komoot-tochten met Wanderer op regelmatige tijdstippen.", - "integration-description-strava": "Synchroniseert je Strava-routes en -activiteiten met Wanderer op regelmatige tijdstippen.", - "integration-disabled": "Integratie uitgeschakeld", - "integration-enabled": "Integratie ingeschakeld", - "integration-privacy-hint-original": "", - "integration-privacy-hint-user": "", - "integrations": "Integraties", + "plugin-disabled": "Plug-in uitgeschakeld", + "plugin-enabled": "Plug-in ingeschakeld", + "plugin-privacy-hint-original": "", + "plugin-privacy-hint-user": "", + "plugins": "Plug-ins", "invalid-date": "Ongeldige datum", "invalid-username": "Ongeldige gebruikersnaam", "italian": "Italiaans", @@ -434,7 +431,6 @@ "statistics": "Statistieken", "stop-drawing": "Stop met tekenen", "stop-editing": "Stop met bewerken", - "strava-integration-after-date-hint": "Als uw account een grote hoeveelheid activiteiten heeft, kunt u op de API-limiet van Strava botsen voorkomend dat u alle activiteiten tegelijk synchroniseert. Om dit probleem te omzeilen kunt u een \"Later\" datum hieronder instellen, zodat alleen activiteiten die na deze datum werden opgenomen worden gesynchroniseerd.", "subway-stop": "Metro toegang", "summit": "Top", "summit-book": "Bergtopboek", diff --git a/web/src/lib/i18n/locales/no.json b/web/src/lib/i18n/locales/no.json index ab9360e3..b4a1943b 100644 --- a/web/src/lib/i18n/locales/no.json +++ b/web/src/lib/i18n/locales/no.json @@ -3,6 +3,7 @@ "Canoeing": "Padling", "Climbing": "Klatring", "Hiking": "Vandring", + "Other": "Annet", "Skiing": "Skisport", "Walking": "Gåtur", "about": "Om", @@ -144,7 +145,7 @@ "error-copying-trail": "Feil ved kopiering av sti", "error-creating-user": "Feil ved oppretting av bruker", "error-deleting-token": "Feil ved sletting av token", - "error-disabling-strava-integration": "Feil ved deaktivering av Strava-integrasjon", + "error-disabling-strava-plugin": "Feil ved deaktivering av Strava-programtillegg", "error-during-login": "Feil under innlogging", "error-during-password-reset": "Kunne ikke sende e-post for tilbakestilling av passord", "error-exporting-trail": "Feil ved eksport av sti", @@ -157,11 +158,11 @@ "error-reading-file": "Feil ved lesing av fil", "error-saving-list": "Feil ved lagring av liste", "error-saving-trail": "Feil ved lagring av sti", - "error-setting-up-integration": "Feil ved oppsett av {provider}-integrasjon", - "error-updating-hammerhead-integration": "Feil ved oppdatering av Hammerhead-integrasjon", - "error-updating-komoot-integration": "Feil ved oppdatering av Komoot-integrasjon", + "error-setting-up-plugin": "Feil ved oppsett av {provider}-programtillegg", + "error-updating-hammerhead-plugin": "Feil ved oppdatering av Hammerhead-programtillegg", + "error-updating-komoot-plugin": "Feil ved oppdatering av Komoot-programtillegg", "error-updating-password": "Feil ved oppdatering av passord", - "error-updating-strava-integration": "Feil ved oppdatering av Komoot-integrasjon", + "error-updating-strava-plugin": "Feil ved oppdatering av Strava-programtillegg", "error-uploading-trail-to-hammerhead": "Feil ved opplasting av sti til Hammerhead", "est-duration": "Est. varighet", "everyone-with-the-link": "Alle med lenken", @@ -202,7 +203,6 @@ "get-started": "Kom i gang", "grid": "Rutenett", "grocery-store": "Dagligvarebutikk", - "hammerhead-integration-after-date-hint": "Hvis Hammerhead-kontoen din allerede er synkronisert med andre stidatabaser, som Komoot eller Strava, kan synkronisering av Hammerhead-data føre til duplikater. For å unngå dette kan du angi en startdato nedenfor, slik at bare aktiviteter registrert etter denne datoen vil bli synkronisert.", "heading": "Overskrift", "height": "Høyde", "help": "Hjelp", @@ -224,14 +224,13 @@ "import-hint": "Velg eller dra GPX, FIT, KML eller TCX-filer hit...", "include-description": "Inkluder beskrivelse", "include-waypoints": "Inkluder veipunkter", - "integration-description-hammerhead": "Synkroniserer Hammerhead-turene dine med Wanderer med jevne mellomrom.", - "integration-description-komoot": "Synkroniserer dine Komoot-turer med Wanderer med jevne mellomrom.", - "integration-description-strava": "Synkroniserer dine Strava-ruter og aktiviteter med Wanderer med jevne mellomrom.", - "integration-disabled": "integrasjon deaktivert", - "integration-enabled": "integrasjon aktivert", - "integration-privacy-hint-original": "Importerte stier vil beholde samme synlighet som de har på den eksterne plattformen. For eksempel, hvis den opprinnelige stien var offentlig, vil den være offentlig i Wanderer, selv om stier er private som standard i henhold til personverninnstillingene dine.", - "integration-privacy-hint-user": "Den opprinnelige stiens synlighet blir forkastet. I stedet blir de lokale personverninnstillingene for stier brukt på alle importerte stier.", - "integrations": "Integrasjoner", + "plugin-disabled": "programtillegg deaktivert", + "plugin-enabled": "programtillegg aktivert", + "plugin-privacy-hint-original": "Importerte stier vil beholde samme synlighet som de har på den eksterne plattformen. For eksempel, hvis den opprinnelige stien var offentlig, vil den være offentlig i Wanderer, selv om stier er private som standard i henhold til personverninnstillingene dine.", + "plugin-privacy-hint-user": "Den opprinnelige stiens synlighet blir forkastet. I stedet blir de lokale personverninnstillingene for stier brukt på alle importerte stier.", + "plugins": "Programtillegg", + "plugin-type-trails": "Trails", + "plugin-type-trails-description": "Trail-programtillegg kobler Wanderer til eksterne tjenester og synkroniserer ruter eller aktiviteter automatisk i bakgrunnen.", "invalid-date": "Ugyldig dato", "invalid-username": "Ugyldig brukernavn", "italian": "Italiensk", @@ -434,7 +433,6 @@ "statistics": "Statistikk", "stop-drawing": "Stopp tegning", "stop-editing": "Stopp redigering", - "strava-integration-after-date-hint": "Hvis kontoen din har en stor mengde aktiviteter kan du støte på Stravas API-hastighetsgrense som hindrer deg i å synkronisere alle aktiviteter samtidig. For å redusere dette problemet kan du sette en \"Etter\" dato nedenfor slik at bare aktiviteter som ble registrert etter denne datoen blir synkronisert.", "subway-stop": "T-baneinngang", "summit": "Topp", "summit-book": "Toppbok", diff --git a/web/src/lib/i18n/locales/pl.json b/web/src/lib/i18n/locales/pl.json index 8f8dfd9c..88cb2ce6 100644 --- a/web/src/lib/i18n/locales/pl.json +++ b/web/src/lib/i18n/locales/pl.json @@ -3,6 +3,7 @@ "Canoeing": "Kajak", "Climbing": "Wspinaczka", "Hiking": "Wędrówka", + "Other": "Inne", "Skiing": "", "Walking": "Spacer", "about": "Na temat", @@ -144,7 +145,7 @@ "error-copying-trail": "", "error-creating-user": "Błąd tworzenia użytkownika", "error-deleting-token": "", - "error-disabling-strava-integration": "Błąd przy wyłączaniu integracji strava", + "error-disabling-strava-plugin": "Błąd przy wyłączaniu wtyczki strava", "error-during-login": "Błąd podczas logowania", "error-during-password-reset": "Nie udało się wysłać e-maila z resetowaniem hasła", "error-exporting-trail": "Błąd podczas eksportowania szlaku", @@ -157,11 +158,11 @@ "error-reading-file": "Błąd wczytywania pliku", "error-saving-list": "Błąd przy zapisywaniu listy", "error-saving-trail": "Błąd podczas zapisywania szlaku", - "error-setting-up-integration": "Error setting up strava integration", - "error-updating-hammerhead-integration": "", - "error-updating-komoot-integration": "", + "error-setting-up-plugin": "Błąd konfiguracji wtyczki {provider}", + "error-updating-hammerhead-plugin": "", + "error-updating-komoot-plugin": "", "error-updating-password": "Błąd podczas aktualizacji hasła", - "error-updating-strava-integration": "Błąd aktualizacji integracji kamoot", + "error-updating-strava-plugin": "Błąd aktualizacji wtyczki Strava", "error-uploading-trail-to-hammerhead": "", "est-duration": "Szacowany czas", "everyone-with-the-link": "Everyone with the link", @@ -202,7 +203,6 @@ "get-started": "Get started", "grid": "Siatka", "grocery-store": "Grocery store", - "hammerhead-integration-after-date-hint": "", "heading": "Heading", "height": "Wysokość", "help": "Pomoc", @@ -224,14 +224,11 @@ "import-hint": "Wybierz lub przeciągnij tutaj plik GPX, FIT, KML lub TCX...", "include-description": "Dołącz opis", "include-waypoints": "Uwzględnij punkty trasy", - "integration-description-hammerhead": "", - "integration-description-komoot": "Synchronizuje trasy kamoot z wanderer w równych odstępach.", - "integration-description-strava": "Synchronizuje trasy i aktywność z wanderer w równych odstępach.", - "integration-disabled": "integracja wyłączona", - "integration-enabled": "integracja włączona", - "integration-privacy-hint-original": "", - "integration-privacy-hint-user": "", - "integrations": "Integracje", + "plugin-disabled": "wtyczka wyłączona", + "plugin-enabled": "wtyczka włączona", + "plugin-privacy-hint-original": "", + "plugin-privacy-hint-user": "", + "plugins": "Wtyczki", "invalid-date": "Nieprawidłowa data", "invalid-username": "Błędna nazwa użytkownika", "italian": "Włoski", @@ -434,7 +431,6 @@ "statistics": "Statystyki", "stop-drawing": "Przestań rysować", "stop-editing": "Zakończ edycję", - "strava-integration-after-date-hint": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.", "subway-stop": "Subway entrance", "summit": "Summit", "summit-book": "Logbook", diff --git a/web/src/lib/i18n/locales/pt.json b/web/src/lib/i18n/locales/pt.json index 36f7af45..03a2d1c9 100644 --- a/web/src/lib/i18n/locales/pt.json +++ b/web/src/lib/i18n/locales/pt.json @@ -3,6 +3,7 @@ "Canoeing": "Canoagem", "Climbing": "Escalada", "Hiking": "Montanhismo", + "Other": "Outro", "Skiing": "", "Walking": "Caminhada", "about": "Sobre", @@ -144,7 +145,7 @@ "error-copying-trail": "", "error-creating-user": "Erro ao criar utilizador", "error-deleting-token": "", - "error-disabling-strava-integration": "Error disabling strava integration", + "error-disabling-strava-plugin": "Error disabling strava plugin", "error-during-login": "Erro durante o ‘login’", "error-during-password-reset": "Unable to send password reset email", "error-exporting-trail": "Erro na exportação do percurso", @@ -157,11 +158,11 @@ "error-reading-file": "Erro ao ler o arquivo", "error-saving-list": "Erro ao gravar lista", "error-saving-trail": "Erro ao gravar percurso", - "error-setting-up-integration": "Error setting up strava integration", - "error-updating-hammerhead-integration": "", - "error-updating-komoot-integration": "", + "error-setting-up-plugin": "Error setting up {provider} plugin", + "error-updating-hammerhead-plugin": "", + "error-updating-komoot-plugin": "", "error-updating-password": "Erro ao atualizar password", - "error-updating-strava-integration": "Error updating komoot integration", + "error-updating-strava-plugin": "Error updating Strava plugin", "error-uploading-trail-to-hammerhead": "", "est-duration": "Duração prevista", "everyone-with-the-link": "Everyone with the link", @@ -202,7 +203,6 @@ "get-started": "Get started", "grid": "Grelha", "grocery-store": "Grocery store", - "hammerhead-integration-after-date-hint": "", "heading": "Heading", "height": "Height", "help": "Ajuda", @@ -224,14 +224,11 @@ "import-hint": "Selecionar ou arrastar ficheiros GPX, FIT, KML ou TCX para aqui...", "include-description": "Incluir descrição", "include-waypoints": "Incluir pontos de passagem", - "integration-description-hammerhead": "", - "integration-description-komoot": "Syncs your komoot tours with wanderer in regular intervals.", - "integration-description-strava": "Syncs your strava routes & activities with wanderer in regular intervals.", - "integration-disabled": "integration disabled", - "integration-enabled": "integration enabled", - "integration-privacy-hint-original": "", - "integration-privacy-hint-user": "", - "integrations": "Integrations", + "plugin-disabled": "plugin disabled", + "plugin-enabled": "plugin enabled", + "plugin-privacy-hint-original": "", + "plugin-privacy-hint-user": "", + "plugins": "Plugins", "invalid-date": "Data inválida", "invalid-username": "Nome de usuário inválido", "italian": "Italiano", @@ -434,7 +431,6 @@ "statistics": "Statistics", "stop-drawing": "Parar desenho", "stop-editing": "Stop editing", - "strava-integration-after-date-hint": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.", "subway-stop": "Subway entrance", "summit": "Summit", "summit-book": "Livro da cimeira", diff --git a/web/src/lib/i18n/locales/ru.json b/web/src/lib/i18n/locales/ru.json index 371fe72a..4da97669 100644 --- a/web/src/lib/i18n/locales/ru.json +++ b/web/src/lib/i18n/locales/ru.json @@ -3,6 +3,7 @@ "Canoeing": "Каякинг", "Climbing": "Скалолазание", "Hiking": "Пеший туризм", + "Other": "Другое", "Skiing": "", "Walking": "Прогулка", "about": "О", @@ -144,7 +145,7 @@ "error-copying-trail": "", "error-creating-user": "Ошибка создания пользователя", "error-deleting-token": "", - "error-disabling-strava-integration": "Ошибка отключения Strava", + "error-disabling-strava-plugin": "Ошибка отключения Strava", "error-during-login": "Ошибка входа", "error-during-password-reset": "Не удалось отправить email сброса пароля", "error-exporting-trail": "Ошибка экспорта трека", @@ -157,11 +158,11 @@ "error-reading-file": "Ошибка чтения файла", "error-saving-list": "Ошибка сохранения списка", "error-saving-trail": "Ошибка сохранения трека", - "error-setting-up-integration": "Ошибка настройки {provider}", - "error-updating-hammerhead-integration": "", - "error-updating-komoot-integration": "", + "error-setting-up-plugin": "Ошибка настройки плагина {provider}", + "error-updating-hammerhead-plugin": "", + "error-updating-komoot-plugin": "", "error-updating-password": "Ошибка изменения пароля", - "error-updating-strava-integration": "Ошибка обновления Strava", + "error-updating-strava-plugin": "Ошибка обновления плагина Strava", "error-uploading-trail-to-hammerhead": "", "est-duration": "Продолжительность", "everyone-with-the-link": "Everyone with the link", @@ -202,7 +203,6 @@ "get-started": "Get started", "grid": "Сетка", "grocery-store": "Продуктовый магазин", - "hammerhead-integration-after-date-hint": "", "heading": "Heading", "height": "Высота", "help": "Помощь", @@ -224,14 +224,11 @@ "import-hint": "Перетащите GPX, FIT, KML или TCX файлы сюда...", "include-description": "Добавить описание", "include-waypoints": "Include waypoints", - "integration-description-hammerhead": "", - "integration-description-komoot": "Синхронизирует ваши данные с Komoot.", - "integration-description-strava": "Синхронизирует ваши данные со Strava.", - "integration-disabled": "интеграция отключена", - "integration-enabled": "интеграция включена", - "integration-privacy-hint-original": "", - "integration-privacy-hint-user": "", - "integrations": "Интеграции", + "plugin-disabled": "плагин отключен", + "plugin-enabled": "плагин включен", + "plugin-privacy-hint-original": "", + "plugin-privacy-hint-user": "", + "plugins": "Плагины", "invalid-date": "Неверная дата", "invalid-username": "Некорректное имя пользователя", "italian": "Итальянский", @@ -434,7 +431,6 @@ "statistics": "Статистика", "stop-drawing": "Закончить рисование", "stop-editing": "Закончить редактирование", - "strava-integration-after-date-hint": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.", "subway-stop": "Вход в метро", "summit": "Вершина", "summit-book": "История поездок", diff --git a/web/src/lib/i18n/locales/zh.json b/web/src/lib/i18n/locales/zh.json index e6181333..edc4e6a6 100644 --- a/web/src/lib/i18n/locales/zh.json +++ b/web/src/lib/i18n/locales/zh.json @@ -3,6 +3,7 @@ "Canoeing": "划艇", "Climbing": "攀岩", "Hiking": "徒步", + "Other": "其他", "Skiing": "", "Walking": "步行", "about": "关于", @@ -144,7 +145,7 @@ "error-copying-trail": "", "error-creating-user": "创建用户错误", "error-deleting-token": "", - "error-disabling-strava-integration": "禁用strava集成时出错", + "error-disabling-strava-plugin": "禁用strava 插件时出错", "error-during-login": "登录错误", "error-during-password-reset": "无法发送密码重置邮件", "error-exporting-trail": "导出路线失败", @@ -157,11 +158,11 @@ "error-reading-file": "读取文件错误", "error-saving-list": "保存列表失败", "error-saving-trail": "保存路线失败", - "error-setting-up-integration": "Error setting up strava integration", - "error-updating-hammerhead-integration": "", - "error-updating-komoot-integration": "", + "error-setting-up-plugin": "设置 {provider} 插件时出错", + "error-updating-hammerhead-plugin": "", + "error-updating-komoot-plugin": "", "error-updating-password": "更新密码失败", - "error-updating-strava-integration": "更新 komoot 集成出错", + "error-updating-strava-plugin": "更新 Strava 插件时出错", "error-uploading-trail-to-hammerhead": "", "est-duration": "预计时长", "everyone-with-the-link": "Everyone with the link", @@ -202,7 +203,6 @@ "get-started": "Get started", "grid": "网格", "grocery-store": "杂货店", - "hammerhead-integration-after-date-hint": "", "heading": "标题", "height": "高度", "help": "帮助", @@ -224,14 +224,11 @@ "import-hint": "在此选择或拖拽GPX、FIT、KML或TCX文件...", "include-description": "包含描述", "include-waypoints": "包括途径点", - "integration-description-hammerhead": "", - "integration-description-komoot": "定期与komoot同步您的wanderer。", - "integration-description-strava": "定期与strava同步您的wanderer路线和活动。", - "integration-disabled": "整合已停用", - "integration-enabled": "整合已启用", - "integration-privacy-hint-original": "", - "integration-privacy-hint-user": "", - "integrations": "整合", + "plugin-disabled": "插件已停用", + "plugin-enabled": "插件已启用", + "plugin-privacy-hint-original": "", + "plugin-privacy-hint-user": "", + "plugins": "插件", "invalid-date": "无效日期", "invalid-username": "无效用户名", "italian": "意大利语", @@ -434,7 +431,6 @@ "statistics": "统计", "stop-drawing": "停止绘制", "stop-editing": "停止编辑", - "strava-integration-after-date-hint": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.", "subway-stop": "地铁入口", "summit": "山峰", "summit-book": "详细日程", diff --git a/web/src/lib/models/api/integration_schema.ts b/web/src/lib/models/api/integration_schema.ts deleted file mode 100644 index 8ad8a765..00000000 --- a/web/src/lib/models/api/integration_schema.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { z, ZodType } from "zod"; -import type { Integration } from "../integration"; - -const IntegrationMergeSchema = z.object({ - enabled: z.boolean(), -}) - -const StravaSchema = z.object({ - clientId: z.number({ coerce: true }).int().nonnegative(), - clientSecret: z.string().length(40).optional().or(z.literal('')), - routes: z.boolean(), - activities: z.boolean(), - active: z.boolean(), - after: z.string().date().optional(), - privacy: z.enum(["original", "settings"]), - merge: IntegrationMergeSchema, -}) - -const KomootSchema = z.object({ - email: z.string().email(), - password: z.string(), - completed: z.boolean(), - planned: z.boolean(), - active: z.boolean(), - privacy: z.enum(["original", "settings"]), - merge: IntegrationMergeSchema, -}) - -const HammerheadSchema = z.object({ - email: z.string().email(), - password: z.string(), - completed: z.boolean(), - planned: z.boolean(), - active: z.boolean(), - after: z.string().date().optional(), - merge: IntegrationMergeSchema, -}) - -const IntegrationCreateSchema = z.object({ - user: z.string().length(15), - strava: StravaSchema.optional(), - komoot: KomootSchema.optional(), - hammerhead: HammerheadSchema.optional(), - -}) satisfies ZodType - -const IntegrationUpdateSchema = z.object({ - strava: StravaSchema.optional().nullable(), - komoot: KomootSchema.optional().nullable(), - hammerhead: HammerheadSchema.optional().nullable(), -}) satisfies ZodType> - -export { StravaSchema, IntegrationCreateSchema, IntegrationUpdateSchema, KomootSchema, HammerheadSchema }; diff --git a/web/src/lib/models/api/openapi_schemas.ts b/web/src/lib/models/api/openapi_schemas.ts index bfb8585f..aa2a218a 100644 --- a/web/src/lib/models/api/openapi_schemas.ts +++ b/web/src/lib/models/api/openapi_schemas.ts @@ -918,106 +918,6 @@ * type: string * description: Followee user ID (15 chars) * - * Integration: - * type: object - * required: - * - id - * - user - * properties: - * id: - * type: string - * description: Integration ID (15 chars) - * user: - * type: string - * description: User ID (15 chars) - * strava: - * type: object - * properties: - * clientId: - * type: integer - * clientSecret: - * type: string - * routes: - * type: boolean - * activities: - * type: boolean - * active: - * type: boolean - * after: - * type: string - * format: date - * privacy: - * type: string - * enum: [original, settings] - * komoot: - * type: object - * properties: - * email: - * type: string - * format: email - * password: - * type: string - * completed: - * type: boolean - * planned: - * type: boolean - * active: - * type: boolean - * privacy: - * type: string - * enum: [original, settings] - * hammerhead: - * type: object - * properties: - * email: - * type: string - * format: email - * password: - * type: string - * completed: - * type: boolean - * planned: - * type: boolean - * active: - * type: boolean - * after: - * type: string - * format: date - * created: - * type: string - * format: date-time - * updated: - * type: string - * format: date-time - * - * IntegrationInput: - * type: object - * required: - * - user - * properties: - * user: - * type: string - * description: User ID (15 chars) - * strava: - * type: object - * komoot: - * type: object - * hammerhead: - * type: object - * - * IntegrationUpdateInput: - * type: object - * properties: - * strava: - * type: object - * nullable: true - * komoot: - * type: object - * nullable: true - * hammerhead: - * type: object - * nullable: true - * * Notification: * type: object * required: diff --git a/web/src/lib/models/api/plugin_instance_schema.ts b/web/src/lib/models/api/plugin_instance_schema.ts new file mode 100644 index 00000000..74d797c4 --- /dev/null +++ b/web/src/lib/models/api/plugin_instance_schema.ts @@ -0,0 +1,59 @@ +import { z } from "zod"; + +const PluginInstanceStatusSchema = z.enum([ + "configured", + "needs_auth", + "needs_reauth", + "syncing", + "rate_limited", + "unavailable", + "unsupported_protocol", + "error", + "disabled", +]); + +const OptionalJsonRecordSchema = z.preprocess( + (value) => (value === null ? undefined : value), + z.record(z.string(), z.unknown()).optional(), +); +const OptionalAuthSchema = z.preprocess( + (value) => { + if (value === null) { + return undefined; + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + return value; + } + return Object.fromEntries( + Object.entries(value).map(([key, fieldValue]) => [ + key, + fieldValue == null ? "" : String(fieldValue), + ]), + ); + }, + z.record(z.string(), z.string()).optional(), +); + +const PluginInstanceCreateSchema = z.object({ + user: z.string().length(15), + plugin_id: z.string().min(1).max(64).regex(/^[a-z0-9][a-z0-9_-]*$/), + enabled: z.boolean().default(false), + auth: OptionalAuthSchema, + config: OptionalJsonRecordSchema, + state: OptionalJsonRecordSchema, + status: PluginInstanceStatusSchema.optional(), +}); + +const PluginInstanceUpdateSchema = z.object({ + enabled: z.boolean().optional(), + auth: OptionalAuthSchema, + config: OptionalJsonRecordSchema, + state: OptionalJsonRecordSchema, + status: PluginInstanceStatusSchema.optional(), +}); + +export { + PluginInstanceCreateSchema, + PluginInstanceUpdateSchema, + PluginInstanceStatusSchema, +}; diff --git a/web/src/lib/models/integration.ts b/web/src/lib/models/integration.ts deleted file mode 100644 index fdbf389e..00000000 --- a/web/src/lib/models/integration.ts +++ /dev/null @@ -1,55 +0,0 @@ - -export interface BaseIntegration { - active: boolean -} - -export interface IntegrationMergeSettings { - enabled: boolean; -} - -export interface StravaIntegration extends BaseIntegration { - clientId: string | number; - clientSecret?: string; - routes: boolean; - activities: boolean; - accessToken?: string; - refreshToken?: string; - expiresAt?: number; - after?: string - privacy: "original" | "settings" - merge: IntegrationMergeSettings; -} - -export interface KomootIntegration extends BaseIntegration { - email: string, - password: string, - completed: boolean, - planned: boolean - privacy: "original" | "settings" - merge: IntegrationMergeSettings; -} - -export interface HammerheadIntegration extends BaseIntegration { - email: string, - password: string, - completed: boolean, - planned: boolean, - after?: string - merge: IntegrationMergeSettings; -} - - -export class Integration { - id?: string; - user: string; - strava?: StravaIntegration | null; - komoot?: KomootIntegration | null; - hammerhead?: HammerheadIntegration | null; - - constructor(user: string, strava?: StravaIntegration, komoot?: KomootIntegration, hammerhead?: HammerheadIntegration) { - this.user = user; - this.strava = strava; - this.komoot = komoot; - this.hammerhead = hammerhead; - } -} diff --git a/web/src/lib/models/plugin_instance.ts b/web/src/lib/models/plugin_instance.ts new file mode 100644 index 00000000..e060dd36 --- /dev/null +++ b/web/src/lib/models/plugin_instance.ts @@ -0,0 +1,25 @@ +export interface PluginInstance { + id?: string; + user: string; + plugin_id: string; + enabled: boolean; + auth?: Record; + config?: Record; + state?: Record; + status: + | "configured" + | "needs_auth" + | "needs_reauth" + | "syncing" + | "rate_limited" + | "unavailable" + | "unsupported_protocol" + | "error" + | "disabled"; + last_error?: { + code?: string; + message?: string; + }; + last_sync_at?: string; + retry_not_before?: string; +} diff --git a/web/src/lib/models/plugin_provider.ts b/web/src/lib/models/plugin_provider.ts new file mode 100644 index 00000000..7527cb12 --- /dev/null +++ b/web/src/lib/models/plugin_provider.ts @@ -0,0 +1,57 @@ +export type LocalizedTextMap = Record; + +export interface ConfigFieldOption { + value: string; + label?: string; + labels?: LocalizedTextMap; +} + +export interface ConfigField { + key: string; + type: "boolean" | "date" | "select" | "text" | "url"; + label?: string; + labels?: LocalizedTextMap; + description?: string; + descriptions?: LocalizedTextMap; + options?: ConfigFieldOption[]; + default?: unknown; + required?: boolean; + hidden?: boolean; +} + +export interface PluginProvider { + id: string; + type: "trails"; + name: string; + displayName?: string; + displayNames?: LocalizedTextMap; + description?: string; + descriptions?: LocalizedTextMap; + icon?: string; + iconDark?: string; + version?: string; + protocolVersion?: string; + risk?: string; + auth: { + type: string; + fields?: string[]; + secretFields?: string[]; + authorizationUrl?: string; + tokenUrl?: string; + tokenRequestFormat?: "json" | "form"; + scopes?: string[]; + scopeSeparator?: string; + authorizationParams?: Record; + pkce?: boolean; + tokenAuth?: string; + }; + configSchema?: ConfigField[]; + hostConfig?: Record; + metadata?: Record; + capabilities?: string[]; + limits?: { + recommendedBatchSize?: number; + }; + status: "available" | "disabled" | "error"; + error?: string; +} diff --git a/web/src/lib/models/plugin_system.ts b/web/src/lib/models/plugin_system.ts new file mode 100644 index 00000000..2f0dc66f --- /dev/null +++ b/web/src/lib/models/plugin_system.ts @@ -0,0 +1,45 @@ +export interface PluginSystemCapability { + name: string; + version: string; + export: string; + requiredHostFunctions?: string[]; + job?: string; +} + +export interface PluginSystemManifest { + manifestVersion: string; + id: string; + type: "trails"; + name: string; + displayName?: string; + description?: string; + icon?: string; + iconDark?: string; + version: string; + runtime: { + type: string; + entrypoint: string; + }; + capabilities: PluginSystemCapability[]; + auth?: Record; + permissions?: Record; + configSchema?: unknown[]; + hostConfig?: Record; + metadata?: Record; +} + +export interface PluginSystemPlugin { + id: string; + type: "trails"; + name: string; + displayName?: string; + description?: string; + icon?: string; + iconDark?: string; + version: string; + runtime: string; + capabilities: string[]; + status: "available" | "disabled" | "error"; + error?: string; + manifest: PluginSystemManifest; +} diff --git a/web/src/lib/models/trail.ts b/web/src/lib/models/trail.ts index e1811d69..d1c7f58b 100644 --- a/web/src/lib/models/trail.ts +++ b/web/src/lib/models/trail.ts @@ -256,4 +256,3 @@ export const defaultTrailSearchAttributes = [ export { Trail }; export type { TrailBoundingBox, TrailFilter, TrailFilterValues, TrailSearchResult }; - diff --git a/web/src/lib/stores/category_store.ts b/web/src/lib/stores/category_store.ts index 64aa9fd5..5caae7df 100644 --- a/web/src/lib/stores/category_store.ts +++ b/web/src/lib/stores/category_store.ts @@ -6,7 +6,7 @@ import { writable, type Writable } from "svelte/store"; export const categories: Writable = writable([]) export async function categories_index(f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch) { - const r = await f('/api/v1/category', { + const r = await f('/api/v1/category?perPage=-1&sort=name', { method: 'GET', }) if (!r.ok) { @@ -19,4 +19,4 @@ export async function categories_index(f: (url: RequestInfo | URL, config?: Requ categories.set(response.items); return response.items as Category[]; -} \ No newline at end of file +} diff --git a/web/src/lib/stores/integration_store.ts b/web/src/lib/stores/integration_store.ts deleted file mode 100644 index 12f188db..00000000 --- a/web/src/lib/stores/integration_store.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { Integration } from "$lib/models/integration"; -import { APIError } from "$lib/util/api_util"; -import { type ListResult } from "pocketbase"; -import { get, writable, type Writable } from "svelte/store"; -import { currentUser } from "./user_store"; - -export const integrations: Writable = writable([]) - -export async function integrations_index(f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch) { - let r = await f('/api/v1/integration' + new URLSearchParams({ - }), { - method: 'GET', - }) - - if (!r.ok) { - const response = await r.json(); - throw new APIError(r.status, response.message, response.detail) - } - - const fetchedIntegrations: ListResult = await r.json(); - - integrations.set(fetchedIntegrations.items); - - return fetchedIntegrations.items; -} - -export async function integrations_create(integration: Integration) { - const user = get(currentUser) - if (!user) { - throw Error("Unauthenticated") - } - - integration.user = user.id; - - let r = await fetch('/api/v1/integration', { - method: 'PUT', - body: JSON.stringify(integration), - }) - - if (!r.ok) { - const response = await r.json(); - throw new APIError(r.status, response.message, response.detail) - } - - const model: Integration = await r.json(); - - return model; -} - -export async function uploadGpx(integrationName: string, file: File) { - const formData = new FormData(); - formData.append('file', file); - - let r = await fetch(`/api/v1/integration/${integrationName}/upload`, { - method: 'POST', - body: formData, - }) - - if (!r.ok) { - const response = await r.json(); - throw new APIError(r.status, response.message, response.detail) - } -} - -export async function integrations_update(integration: Integration, f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch) { - let r = await f('/api/v1/integration/' + integration.id, { - method: 'POST', - body: JSON.stringify(integration), - }) - - if (!r.ok) { - const response = await r.json(); - throw new APIError(r.status, response.message, response.detail) - } - - const model: Integration = await r.json(); - - return model; -} - -export async function integrations_delete(integration: Integration) { - const r = await fetch('/api/v1/integration/' + integration.id, { - method: 'DELETE', - }) - - if (!r.ok) { - const response = await r.json(); - throw new APIError(r.status, response.message, response.detail) - } -} \ No newline at end of file diff --git a/web/src/lib/stores/plugin_instance_store.ts b/web/src/lib/stores/plugin_instance_store.ts new file mode 100644 index 00000000..83fe6edd --- /dev/null +++ b/web/src/lib/stores/plugin_instance_store.ts @@ -0,0 +1,183 @@ +import type { PluginInstance } from "$lib/models/plugin_instance"; +import { APIError } from "$lib/util/api_util"; +import type { ListResult } from "pocketbase"; +import { get, writable, type Writable } from "svelte/store"; +import { currentUser } from "./user_store"; + +export const pluginInstances: Writable = writable([]); + +export async function plugin_instances_index( + f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch, +) { + const r = await f("/api/v1/plugin-instance?perPage=-1", { + method: "GET", + }); + + if (!r.ok) { + const response = await r.json(); + throw new APIError(r.status, response.message, response.detail); + } + + const fetchedInstances: ListResult = await r.json(); + pluginInstances.set(fetchedInstances.items); + + return fetchedInstances.items; +} + +export async function plugin_instances_create( + instance: Partial, +) { + const user = get(currentUser); + if (!user) { + throw Error("Unauthenticated"); + } + + const r = await fetch("/api/v1/plugin-instance", { + method: "PUT", + body: JSON.stringify({ + ...instance, + user: user.id, + }), + }); + + if (!r.ok) { + const response = await r.json(); + throw new APIError(r.status, response.message, response.detail); + } + + return (await r.json()) as PluginInstance; +} + +export async function plugin_instances_update( + instance: PluginInstance, + f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch, +) { + if (!instance.id) { + throw Error("Plugin instance has no id"); + } + + const r = await f("/api/v1/plugin-instance/" + instance.id, { + method: "POST", + body: JSON.stringify(instance), + }); + + if (!r.ok) { + const response = await r.json(); + throw new APIError(r.status, response.message, response.detail); + } + + return (await r.json()) as PluginInstance; +} + +export async function plugin_oauth_start( + data: { + pluginId: string; + instanceId?: string; + authContext?: string; + redirectUri: string; + }, + f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch, +) { + const r = await f("/api/v1/plugin-system/oauth/start", { + method: "POST", + body: JSON.stringify(data), + }); + + if (!r.ok) { + const response = await r.json(); + throw new APIError(r.status, response.message, response.detail); + } + + return (await r.json()) as { url: string; state: string; instanceId: string }; +} + +export async function plugin_auth_validate( + data: { + pluginId: string; + instanceId?: string; + authContext?: string; + auth: Record; + }, + f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch, +) { + const r = await f("/api/v1/plugin-system/auth/validate", { + method: "POST", + body: JSON.stringify(data), + }); + + if (!r.ok) { + const response = await r.json(); + throw new APIError(r.status, response.message, response.detail); + } + + return (await r.json()) as { ok: boolean; authContext?: string }; +} + +export async function plugin_oauth_callback( + data: { instanceId: string; code: string; state: string }, + f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch, +) { + const r = await f("/api/v1/plugin-system/oauth/callback", { + method: "POST", + body: JSON.stringify(data), + }); + + if (!r.ok) { + const response = await r.json(); + throw new APIError(r.status, response.message, response.detail); + } + + return (await r.json()) as { ok: boolean }; +} + +export async function plugin_oauth_revoke( + instanceId: string, + f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch, +) { + const r = await f("/api/v1/plugin-system/oauth/revoke", { + method: "POST", + body: JSON.stringify({ instanceId }), + }); + + if (!r.ok) { + const response = await r.json(); + throw new APIError(r.status, response.message, response.detail); + } + + return (await r.json()) as { ok: boolean }; +} + +export async function plugin_category_remap_preview( + instanceId: string, + config?: Record, + f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch, +) { + const r = await f("/api/v1/plugin-system/category-remap/preview", { + method: "POST", + body: JSON.stringify({ instanceId, config }), + }); + + if (!r.ok) { + const response = await r.json(); + throw new APIError(r.status, response.message, response.detail); + } + + return (await r.json()) as { count: number; backfilledSinceMapping?: number }; +} + +export async function plugin_category_remap_apply( + instanceId: string, + f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch, +) { + const r = await f("/api/v1/plugin-system/category-remap/apply", { + method: "POST", + body: JSON.stringify({ instanceId }), + }); + + if (!r.ok) { + const response = await r.json(); + throw new APIError(r.status, response.message, response.detail); + } + + return (await r.json()) as { count: number; remapped?: number }; +} diff --git a/web/src/lib/stores/plugin_store.ts b/web/src/lib/stores/plugin_store.ts new file mode 100644 index 00000000..9a28acfe --- /dev/null +++ b/web/src/lib/stores/plugin_store.ts @@ -0,0 +1,125 @@ +import type { LocalizedTextMap, PluginProvider } from "$lib/models/plugin_provider"; +import type { PluginSystemPlugin } from "$lib/models/plugin_system"; +import { APIError } from "$lib/util/api_util"; +import { derived, writable, type Readable, type Writable } from "svelte/store"; +import { + pluginInstances, + plugin_instances_index, +} from "./plugin_instance_store"; + +export const pluginProviders: Writable = writable([]); + +// True when the user has at least one enabled plugin instance whose plugin +// advertises the trail send capability. Used to gate the trail "send to" action. +export const hasSendCapablePlugin: Readable = derived( + [pluginProviders, pluginInstances], + ([$plugins, $instances]) => { + const enabledProviders = new Set( + $instances.filter((a) => a.enabled).map((a) => a.plugin_id), + ); + return $plugins.some( + (p) => + p.status === "available" && + (p.capabilities ?? []).includes("prepare_trail_send.v1") && + enabledProviders.has(p.id), + ); + }, +); + +let pluginDataLoaded = false; + +// Loads plugins and instances once per session so the derived gating +// stores are populated (e.g. for the trail "send to" action). Safe to call +// repeatedly; only the first call performs the requests. +export async function load_plugin_data_once() { + if (pluginDataLoaded) { + return; + } + pluginDataLoaded = true; + try { + await Promise.all([ + plugins_index(), + plugin_instances_index(), + ]); + } catch (e) { + pluginDataLoaded = false; + console.error(e); + } +} + +export async function plugins_index( + f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch, +) { + const r = await f("/api/v1/plugin-system/plugins", { + method: "GET", + }); + + if (!r.ok) { + const response = await r.json(); + throw new APIError(r.status, response.message, response.detail); + } + + const data: { items: PluginSystemPlugin[] } = await r.json(); + const items = data.items.map(pluginSystemToPluginProvider); + pluginProviders.set(items); + + return items; +} + +function pluginSystemToPluginProvider(plugin: PluginSystemPlugin): PluginProvider { + const contexts = Object.entries( + (plugin.manifest.auth?.contexts ?? {}) as Record, + ); + const primaryAuth = contexts[0]?.[1] ?? {}; + const fields = + primaryAuth.fields ?? + primaryAuth.secretFields ?? + (primaryAuth.secretField ? [primaryAuth.secretField] : []); + const metadata = plugin.manifest.metadata ?? {}; + + return { + id: plugin.id, + type: plugin.type ?? plugin.manifest.type ?? "trails", + name: plugin.name, + displayName: plugin.displayName, + displayNames: localizedMetadata(metadata, "displayNames"), + description: plugin.description, + descriptions: localizedMetadata(metadata, "descriptions"), + icon: plugin.icon, + iconDark: plugin.iconDark, + version: plugin.version, + auth: { + type: primaryAuth.type ?? "none", + fields, + secretFields: primaryAuth.secretFields ?? fields, + authorizationUrl: primaryAuth.authorizationUrl, + tokenUrl: primaryAuth.tokenUrl, + tokenRequestFormat: primaryAuth.tokenRequestFormat, + scopes: primaryAuth.scopes, + scopeSeparator: primaryAuth.scopeSeparator, + authorizationParams: primaryAuth.authorizationParams, + pkce: primaryAuth.pkce, + tokenAuth: primaryAuth.tokenAuth, + }, + configSchema: plugin.manifest.configSchema as PluginProvider["configSchema"], + hostConfig: plugin.manifest.hostConfig, + metadata, + capabilities: plugin.capabilities, + status: plugin.status, + error: plugin.error, + }; +} + +function localizedMetadata( + metadata: Record, + key: string, +): LocalizedTextMap | undefined { + const value = metadata[key]; + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + const entries = Object.entries(value as Record).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ); + return entries.length ? Object.fromEntries(entries) : undefined; +} diff --git a/web/src/lib/stores/theme_store.ts b/web/src/lib/stores/theme_store.ts index bcff9011..31267104 100644 --- a/web/src/lib/stores/theme_store.ts +++ b/web/src/lib/stores/theme_store.ts @@ -1,31 +1,34 @@ import { browser } from "$app/environment"; import { get, writable, type Writable } from "svelte/store"; -type Theme = "dark" | "light" +type Theme = "dark" | "light"; export const theme: Writable = writable(getDefaultTheme()); function getDefaultTheme(): Theme { - if (browser) { - if (localStorage.getItem("theme")) { - return localStorage.getItem("theme") as Theme; - } else if (document.documentElement.classList.contains("light")) { - return "light" - } else if (document.documentElement.classList.contains("dark")) { - return "dark"; - } + if (!browser) { + return "light"; } - return "light"; - + const stored = localStorage.getItem("theme"); + if (stored === "dark" || stored === "light") { + return stored; + } + if (document.documentElement.classList.contains("dark")) { + return "dark"; + } + if (document.documentElement.classList.contains("light")) { + return "light"; + } + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } export function toggleTheme() { const currentTheme = get(theme); const newTheme = currentTheme === "light" ? "dark" : "light"; - document.documentElement.classList.remove(currentTheme) - document.documentElement.classList.add(newTheme) - document.querySelector("meta[name='color-scheme']" )?.setAttribute("content", newTheme) - theme.set(newTheme) + document.documentElement.classList.remove(currentTheme); + document.documentElement.classList.add(newTheme); + document.querySelector("meta[name='color-scheme']")?.setAttribute("content", newTheme); + theme.set(newTheme); localStorage.setItem("theme", newTheme); } diff --git a/web/src/lib/util/api_util.ts b/web/src/lib/util/api_util.ts index bf1128d8..a13aeebd 100644 --- a/web/src/lib/util/api_util.ts +++ b/web/src/lib/util/api_util.ts @@ -25,7 +25,7 @@ export enum Collection { comments = "comments", feed = "feed", follows = "follows", - integrations = "integrations", + plugin_instances = "plugin_instances", list_share = "list_share", lists = "lists", notifications = "notifications", diff --git a/web/src/lib/util/plugin_error_i18n.ts b/web/src/lib/util/plugin_error_i18n.ts new file mode 100644 index 00000000..0132a00a --- /dev/null +++ b/web/src/lib/util/plugin_error_i18n.ts @@ -0,0 +1,112 @@ +import { get } from "svelte/store"; +import { _ } from "svelte-i18n"; +import { APIError } from "$lib/util/api_util"; + +type PluginErrorLike = { + code?: unknown; + message?: unknown; +}; + +const credentialErrorCodes = new Set(["auth_failed"]); +const reconnectErrorCodes = new Set(["invalid_grant", "unauthorized"]); +const unavailableErrorCodes = new Set(["provider_unavailable", "temporary_unavailable"]); +const internalErrorCodes = new Set(["internal_error", "plugin_error"]); + +const authErrorHints = [ + "auth_failed", + "login failed", + "email and password are required", +]; + +export function translatePluginError(code?: string, message?: string): string { + const normalizedCode = code?.trim(); + + if (normalizedCode && credentialErrorCodes.has(normalizedCode)) { + return get(_)("wrong-username-or-password"); + } + if (normalizedCode && reconnectErrorCodes.has(normalizedCode)) { + return get(_)("plugin-error-reconnect-required"); + } + if (normalizedCode === "rate_limited") { + return get(_)("plugin-error-rate-limited"); + } + if (normalizedCode && unavailableErrorCodes.has(normalizedCode)) { + return get(_)("plugin-error-provider-unavailable"); + } + if (normalizedCode === "invalid_request") { + return get(_)("plugin-error-invalid-request"); + } + if (normalizedCode && internalErrorCodes.has(normalizedCode)) { + return get(_)("plugin-error-internal"); + } + + return message?.trim() || get(_)("plugin-setup-error"); +} + +export function translatePluginAPIError(error: unknown, fallback: string): string { + if (error instanceof APIError) { + const pluginError = extractPluginError(error.detail); + if (pluginError?.code) { + return translatePluginError(String(pluginError.code), stringValue(pluginError.message)); + } + + const raw = `${error.message} ${stringValue(error.detail)}`.toLowerCase(); + if (authErrorHints.some((hint) => raw.includes(hint))) { + return get(_)("wrong-username-or-password"); + } + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return fallback; +} + +function extractPluginError(value: unknown): PluginErrorLike | undefined { + if (!value) { + return undefined; + } + + if (typeof value === "string") { + return extractPluginErrorFromString(value); + } + + if (typeof value !== "object") { + return undefined; + } + + const record = value as Record; + if (typeof record.code === "string") { + return record; + } + + for (const nested of Object.values(record)) { + const parsed = extractPluginError(nested); + if (parsed) { + return parsed; + } + } + + return undefined; +} + +function extractPluginErrorFromString(value: string): PluginErrorLike | undefined { + try { + const parsed = JSON.parse(value); + return extractPluginError(parsed); + } catch { + const match = value.match(/"code"\s*:\s*"([^"]+)"/); + return match ? { code: match[1] } : undefined; + } +} + +function stringValue(value: unknown): string | undefined { + if (typeof value === "string") { + return value; + } + if (value == null) { + return undefined; + } + return JSON.stringify(value); +} diff --git a/web/src/lib/util/plugin_i18n.ts b/web/src/lib/util/plugin_i18n.ts new file mode 100644 index 00000000..b26d30ed --- /dev/null +++ b/web/src/lib/util/plugin_i18n.ts @@ -0,0 +1,86 @@ +import type { ConfigField, ConfigFieldOption, LocalizedTextMap, PluginProvider } from "$lib/models/plugin_provider"; + +export function localizedText( + texts: LocalizedTextMap | undefined, + currentLocale: string | null | undefined, + fallback = "", +): string { + if (!texts) { + return fallback; + } + + const locale = normalizeLocale(currentLocale); + const language = locale.split("-")[0]; + const candidates = [locale, language, "en"]; + for (const candidate of candidates) { + const value = texts[candidate]?.trim(); + if (value) { + return value; + } + } + + const trimmedFallback = fallback.trim(); + if (trimmedFallback) { + return trimmedFallback; + } + + return ""; +} + +export function pluginTitle(plugin: PluginProvider, currentLocale: string | null | undefined): string { + return localizedText(plugin.displayNames, currentLocale, plugin.displayName || plugin.name); +} + +export function pluginDescription(plugin: PluginProvider, currentLocale: string | null | undefined): string { + return localizedText(plugin.descriptions, currentLocale, plugin.description ?? ""); +} + +export function configFieldLabel( + field: ConfigField, + currentLocale: string | null | undefined, + fallback: string, +): string { + return localizedText(field.labels, currentLocale, field.label || fallback); +} + +export function configFieldDescription( + field: ConfigField, + currentLocale: string | null | undefined, +): string | undefined { + return localizedText(field.descriptions, currentLocale, field.description ?? "") || undefined; +} + +export function configFieldOptionLabel( + option: ConfigFieldOption, + currentLocale: string | null | undefined, + fallback: string, +): string { + return localizedText(option.labels, currentLocale, option.label || fallback); +} + +export function providerCategoryLabel( + plugin: PluginProvider, + providerCategory: string, + currentLocale: string | null | undefined, +): string { + const providerCategories = plugin.metadata?.providerCategories; + if (!providerCategories || typeof providerCategories !== "object" || Array.isArray(providerCategories)) { + return providerCategory; + } + + const category = (providerCategories as Record)[providerCategory]; + if (!category || typeof category !== "object" || Array.isArray(category)) { + return providerCategory; + } + + const labels = (category as Record).labels; + if (!labels || typeof labels !== "object" || Array.isArray(labels)) { + return providerCategory; + } + + return localizedText(labels as LocalizedTextMap, currentLocale, providerCategory); +} + +function normalizeLocale(value: string | null | undefined): string { + return (value || "en").trim().toLowerCase().replace("_", "-"); +} diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 1f24fc39..cfed87bb 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -14,6 +14,7 @@ import PageLoadingBar from "$lib/components/page_loading_bar.svelte"; import UploadDialog from "$lib/components/settings/upload_dialog.svelte"; import { currentUser } from "$lib/stores/user_store"; + import { load_plugin_data_once } from "$lib/stores/plugin_store"; import { isRouteProtected } from "$lib/util/authorization_util"; import { onMount, type Snippet } from "svelte"; import { slide } from "svelte/transition"; @@ -42,6 +43,9 @@ if (page.data.origin != location.origin) { showWarning = true; } + if (data.user) { + load_plugin_data_once(); + } }); let hideDemoHint = $state(false); diff --git a/web/src/routes/api/v1/integration/+server.ts b/web/src/routes/api/v1/integration/+server.ts deleted file mode 100644 index 47d1180a..00000000 --- a/web/src/routes/api/v1/integration/+server.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { IntegrationCreateSchema } from "$lib/models/api/integration_schema"; -import type { Integration } from "$lib/models/integration"; -import { Collection, create, handleError, list } from "$lib/util/api_util"; -import { json, type RequestEvent } from "@sveltejs/kit"; - -/** - * @swagger - * /api/v1/integration: - * get: - * summary: List integrations - * tags: - * - Integrations - * parameters: - * - in: query - * name: page - * schema: - * type: integer - * - in: query - * name: perPage - * schema: - * type: integer - * - in: query - * name: sort - * schema: - * type: string - * - in: query - * name: filter - * schema: - * type: string - * - in: query - * name: expand - * schema: - * type: string - * responses: - * 200: - * description: List of integrations - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ListResult' - * 400: - * description: Bad Request - * 500: - * description: Internal Server Error - */ -export async function GET(event: RequestEvent) { - try { - const r = await list(event, Collection.integrations); - - return json(r) - } catch (e) { - return handleError(e) - } -} - -/** - * @swagger - * /api/v1/integration: - * put: - * summary: Create integration - * tags: - * - Integrations - * requestBody: - * required: true - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/IntegrationInput' - * responses: - * 201: - * description: Integration created - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Integration' - * 400: - * description: Bad Request - * 500: - * description: Internal Server Error - */ -export async function PUT(event: RequestEvent) { - try { - const r = await create(event, IntegrationCreateSchema, Collection.integrations) - return json(r); - } catch (e) { - return handleError(e) - } -} \ No newline at end of file diff --git a/web/src/routes/api/v1/integration/[id]/+server.ts b/web/src/routes/api/v1/integration/[id]/+server.ts deleted file mode 100644 index 2c620b98..00000000 --- a/web/src/routes/api/v1/integration/[id]/+server.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { IntegrationUpdateSchema } from "$lib/models/api/integration_schema"; -import type { Integration } from "$lib/models/integration"; -import { Collection, handleError, remove, show, update } from "$lib/util/api_util"; -import { json, type RequestEvent } from "@sveltejs/kit"; - -/** - * @swagger - * /api/v1/integration/{id}: - * get: - * summary: Get integration - * tags: - * - Integrations - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: string - * - in: query - * name: expand - * schema: - * type: string - * responses: - * 200: - * description: Integration details - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Integration' - * 404: - * description: Not Found - * 500: - * description: Internal Server Error - */ -export async function GET(event: RequestEvent) { - try { - const r = await show(event, Collection.integrations) - return json(r) - } catch (e: any) { - return handleError(e) - } -} - -/** - * @swagger - * /api/v1/integration/{id}: - * post: - * summary: Update integration - * tags: - * - Integrations - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: string - * requestBody: - * required: true - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/IntegrationUpdateInput' - * responses: - * 200: - * description: Integration updated - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Integration' - * 400: - * description: Bad Request - * 404: - * description: Not Found - * 500: - * description: Internal Server Error - */ -export async function POST(event: RequestEvent) { - try { - const r = await update(event, IntegrationUpdateSchema, Collection.integrations) - return json(r); - } catch (e: any) { - return handleError(e) - } -} - -/** - * @swagger - * /api/v1/integration/{id}: - * delete: - * summary: Delete integration - * tags: - * - Integrations - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Integration deleted - * 404: - * description: Not Found - * 500: - * description: Internal Server Error - */ -export async function DELETE(event: RequestEvent) { - try { - const r = await remove(event, Collection.integrations) - return json(r); - } catch (e: any) { - return handleError(e) - } -} diff --git a/web/src/routes/api/v1/integration/hammerhead/login/+server.ts b/web/src/routes/api/v1/integration/hammerhead/login/+server.ts deleted file mode 100644 index fc74a531..00000000 --- a/web/src/routes/api/v1/integration/hammerhead/login/+server.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { handleError } from "$lib/util/api_util"; -import { json, type RequestEvent } from "@sveltejs/kit"; - -/** - * @swagger - * /api/v1/integration/hammerhead/login: - * get: - * summary: Get Hammerhead login endpoint - * description: Proxies to backend to get Hammerhead login configuration - * tags: - * - Integrations - * responses: - * 200: - * description: Hammerhead login endpoint - * content: - * application/json: - * schema: - * type: object - * 500: - * description: Internal Server Error - */ -export async function GET(event: RequestEvent) { - try { - const r = await event.locals.pb.send("/integration/hammerhead/login", { - method: "GET", - }); - return json(r); - } catch (e: any) { - return handleError(e) - } -} \ No newline at end of file diff --git a/web/src/routes/api/v1/integration/komoot/login/+server.ts b/web/src/routes/api/v1/integration/komoot/login/+server.ts deleted file mode 100644 index 000e0975..00000000 --- a/web/src/routes/api/v1/integration/komoot/login/+server.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { handleError } from "$lib/util/api_util"; -import { json, type RequestEvent } from "@sveltejs/kit"; - -/** - * @swagger - * /api/v1/integration/komoot/login: - * get: - * summary: Get Komoot login endpoint - * description: Proxies to backend to get Komoot login configuration - * tags: - * - Integrations - * responses: - * 200: - * description: Komoot login endpoint - * content: - * application/json: - * schema: - * type: object - * 500: - * description: Internal Server Error - */ -export async function GET(event: RequestEvent) { - try { - const r = await event.locals.pb.send("/integration/komoot/login", { - method: "GET", - }); - return json(r); - } catch (e: any) { - return handleError(e) - } -} \ No newline at end of file diff --git a/web/src/routes/api/v1/plugin-instance/+server.ts b/web/src/routes/api/v1/plugin-instance/+server.ts new file mode 100644 index 00000000..7d35f7c0 --- /dev/null +++ b/web/src/routes/api/v1/plugin-instance/+server.ts @@ -0,0 +1,111 @@ +import { + PluginInstanceCreateSchema, +} from "$lib/models/api/plugin_instance_schema"; +import type { PluginInstance } from "$lib/models/plugin_instance"; +import { Collection, create, handleError, list } from "$lib/util/api_util"; +import { json, type RequestEvent } from "@sveltejs/kit"; + +/** + * @swagger + * /api/v1/plugin-instance: + * get: + * summary: List plugin instances + * description: Retrieves the authenticated user's configured plugin instances. + * tags: + * - Plugins + * parameters: + * - in: query + * name: page + * schema: + * type: integer + * - in: query + * name: perPage + * schema: + * type: integer + * - in: query + * name: sort + * schema: + * type: string + * - in: query + * name: filter + * schema: + * type: string + * responses: + * 200: + * description: ListResult + * 400: + * description: Bad Request + * 401: + * description: Unauthorized + * 500: + * description: Internal Server Error + */ +export async function GET(event: RequestEvent) { + try { + const r = await list(event, Collection.plugin_instances); + return json(r); + } catch (e) { + return handleError(e); + } +} + +/** + * @swagger + * /api/v1/plugin-instance: + * put: + * summary: Create plugin instance + * description: Creates a plugin instance for the authenticated user. + * tags: + * - Plugins + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - user + * - plugin_id + * properties: + * user: + * type: string + * description: User record ID + * plugin_id: + * type: string + * enabled: + * type: boolean + * auth: + * type: object + * additionalProperties: + * type: string + * config: + * type: object + * additionalProperties: true + * state: + * type: object + * additionalProperties: true + * status: + * type: string + * enum: [configured, needs_auth, needs_reauth, syncing, rate_limited, unavailable, unsupported_protocol, error, disabled] + * responses: + * 200: + * description: Plugin instance created + * 400: + * description: Bad Request + * 401: + * description: Unauthorized + * 500: + * description: Internal Server Error + */ +export async function PUT(event: RequestEvent) { + try { + const r = await create( + event, + PluginInstanceCreateSchema, + Collection.plugin_instances, + ); + return json(r); + } catch (e) { + return handleError(e); + } +} diff --git a/web/src/routes/api/v1/plugin-instance/[id]/+server.ts b/web/src/routes/api/v1/plugin-instance/[id]/+server.ts new file mode 100644 index 00000000..5e0fd868 --- /dev/null +++ b/web/src/routes/api/v1/plugin-instance/[id]/+server.ts @@ -0,0 +1,138 @@ +import { + PluginInstanceUpdateSchema, +} from "$lib/models/api/plugin_instance_schema"; +import type { PluginInstance } from "$lib/models/plugin_instance"; +import { Collection, handleError, remove, show, update } from "$lib/util/api_util"; +import { json, type RequestEvent } from "@sveltejs/kit"; + +/** + * @swagger + * /api/v1/plugin-instance/{id}: + * get: + * summary: Get plugin instance + * description: Retrieves a plugin instance by ID. + * tags: + * - Plugins + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: Plugin instance record ID + * responses: + * 200: + * description: Plugin instance details + * 400: + * description: Bad Request + * 401: + * description: Unauthorized + * 404: + * description: Not Found + * 500: + * description: Internal Server Error + */ +export async function GET(event: RequestEvent) { + try { + const r = await show(event, Collection.plugin_instances); + return json(r); + } catch (e: any) { + return handleError(e); + } +} + +/** + * @swagger + * /api/v1/plugin-instance/{id}: + * post: + * summary: Update plugin instance + * description: Updates plugin instance settings, auth, state, or status. + * tags: + * - Plugins + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: Plugin instance record ID + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * enabled: + * type: boolean + * auth: + * type: object + * additionalProperties: + * type: string + * config: + * type: object + * additionalProperties: true + * state: + * type: object + * additionalProperties: true + * status: + * type: string + * enum: [configured, needs_auth, needs_reauth, syncing, rate_limited, unavailable, unsupported_protocol, error, disabled] + * responses: + * 200: + * description: Plugin instance updated + * 400: + * description: Bad Request + * 401: + * description: Unauthorized + * 404: + * description: Not Found + * 500: + * description: Internal Server Error + */ +export async function POST(event: RequestEvent) { + try { + const r = await update( + event, + PluginInstanceUpdateSchema, + Collection.plugin_instances, + ); + return json(r); + } catch (e: any) { + return handleError(e); + } +} + +/** + * @swagger + * /api/v1/plugin-instance/{id}: + * delete: + * summary: Delete plugin instance + * description: Deletes a configured plugin instance. + * tags: + * - Plugins + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: Plugin instance record ID + * responses: + * 200: + * description: Success + * 401: + * description: Unauthorized + * 404: + * description: Not Found + * 500: + * description: Internal Server Error + */ +export async function DELETE(event: RequestEvent) { + try { + const r = await remove(event, Collection.plugin_instances); + return json(r); + } catch (e: any) { + return handleError(e); + } +} diff --git a/web/src/routes/api/v1/plugin-system/auth/validate/+server.ts b/web/src/routes/api/v1/plugin-system/auth/validate/+server.ts new file mode 100644 index 00000000..b981a65f --- /dev/null +++ b/web/src/routes/api/v1/plugin-system/auth/validate/+server.ts @@ -0,0 +1,63 @@ +import { handleError } from "$lib/util/api_util"; +import { json, type RequestEvent } from "@sveltejs/kit"; + +/** + * @swagger + * /api/v1/plugin-system/auth/validate: + * post: + * summary: Validate plugin session auth + * description: Validates session-style plugin credentials before saving them. + * tags: + * - Plugins + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - pluginId + * - auth + * properties: + * pluginId: + * type: string + * instanceId: + * type: string + * authContext: + * type: string + * auth: + * type: object + * additionalProperties: + * type: string + * responses: + * 200: + * description: Auth validation result + * content: + * application/json: + * schema: + * type: object + * properties: + * ok: + * type: boolean + * authContext: + * type: string + * 400: + * description: Bad Request + * 401: + * description: Unauthorized + * 500: + * description: Internal Server Error + */ +export async function POST(event: RequestEvent) { + try { + const body = await event.request.json(); + const r = await event.locals.pb.send("/plugins/auth/validate", { + method: "POST", + body, + fetch: event.fetch, + }); + return json(r); + } catch (e) { + return handleError(e); + } +} diff --git a/web/src/routes/api/v1/plugin-system/category-remap/apply/+server.ts b/web/src/routes/api/v1/plugin-system/category-remap/apply/+server.ts new file mode 100644 index 00000000..c13833e0 --- /dev/null +++ b/web/src/routes/api/v1/plugin-system/category-remap/apply/+server.ts @@ -0,0 +1,48 @@ +import { handleError } from "$lib/util/api_util"; +import { json, type RequestEvent } from "@sveltejs/kit"; + +/** + * @swagger + * /api/v1/plugin-system/category-remap/apply: + * post: + * summary: Apply plugin category remap + * description: Updates imported trails to the category configured for their stored provider category in the current plugin instance mapping. + * tags: + * - Plugins + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - instanceId + * properties: + * instanceId: + * type: string + * description: Plugin instance record ID + * responses: + * 200: + * description: Remap result + * 400: + * description: Bad Request + * 401: + * description: Unauthorized + * 404: + * description: Not Found + * 500: + * description: Internal Server Error + */ +export async function POST(event: RequestEvent) { + try { + const body = await event.request.json(); + const r = await event.locals.pb.send("/plugins/category-remap/apply", { + method: "POST", + body, + fetch: event.fetch, + }); + return json(r); + } catch (e: any) { + return handleError(e); + } +} diff --git a/web/src/routes/api/v1/plugin-system/category-remap/preview/+server.ts b/web/src/routes/api/v1/plugin-system/category-remap/preview/+server.ts new file mode 100644 index 00000000..80031802 --- /dev/null +++ b/web/src/routes/api/v1/plugin-system/category-remap/preview/+server.ts @@ -0,0 +1,48 @@ +import { handleError } from "$lib/util/api_util"; +import { json, type RequestEvent } from "@sveltejs/kit"; + +/** + * @swagger + * /api/v1/plugin-system/category-remap/preview: + * post: + * summary: Preview plugin category remap + * description: Counts imported trails whose stored provider category can be mapped by the current plugin instance category mapping. + * tags: + * - Plugins + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - instanceId + * properties: + * instanceId: + * type: string + * description: Plugin instance record ID + * responses: + * 200: + * description: Remap preview + * 400: + * description: Bad Request + * 401: + * description: Unauthorized + * 404: + * description: Not Found + * 500: + * description: Internal Server Error + */ +export async function POST(event: RequestEvent) { + try { + const body = await event.request.json(); + const r = await event.locals.pb.send("/plugins/category-remap/preview", { + method: "POST", + body, + fetch: event.fetch, + }); + return json(r); + } catch (e: any) { + return handleError(e); + } +} diff --git a/web/src/routes/api/v1/plugin-system/oauth/callback/+server.ts b/web/src/routes/api/v1/plugin-system/oauth/callback/+server.ts new file mode 100644 index 00000000..fb3a6a5a --- /dev/null +++ b/web/src/routes/api/v1/plugin-system/oauth/callback/+server.ts @@ -0,0 +1,58 @@ +import { handleError } from "$lib/util/api_util"; +import { json, type RequestEvent } from "@sveltejs/kit"; + +/** + * @swagger + * /api/v1/plugin-system/oauth/callback: + * post: + * summary: Complete plugin OAuth flow + * description: Exchanges an OAuth authorization code and stores the resulting plugin credentials. + * tags: + * - Plugins + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - instanceId + * - code + * - state + * properties: + * instanceId: + * type: string + * code: + * type: string + * state: + * type: string + * responses: + * 200: + * description: OAuth callback handled + * content: + * application/json: + * schema: + * type: object + * properties: + * ok: + * type: boolean + * 400: + * description: Bad Request + * 401: + * description: Unauthorized + * 500: + * description: Internal Server Error + */ +export async function POST(event: RequestEvent) { + try { + const body = await event.request.json(); + const r = await event.locals.pb.send("/plugins/oauth/callback", { + method: "POST", + body, + fetch: event.fetch, + }); + return json(r); + } catch (e) { + return handleError(e); + } +} diff --git a/web/src/routes/api/v1/integration/hammerhead/upload/+server.ts b/web/src/routes/api/v1/plugin-system/oauth/revoke/+server.ts similarity index 51% rename from web/src/routes/api/v1/integration/hammerhead/upload/+server.ts rename to web/src/routes/api/v1/plugin-system/oauth/revoke/+server.ts index c558d2cc..fd4e4299 100644 --- a/web/src/routes/api/v1/integration/hammerhead/upload/+server.ts +++ b/web/src/routes/api/v1/plugin-system/oauth/revoke/+server.ts @@ -3,52 +3,52 @@ import { json, type RequestEvent } from "@sveltejs/kit"; /** * @swagger - * /api/v1/integration/hammerhead/upload: + * /api/v1/plugin-system/oauth/revoke: * post: - * summary: Upload via Hammerhead integration - * description: Proxies file upload to backend Hammerhead integration + * summary: Revoke plugin OAuth credentials + * description: Revokes OAuth credentials stored for a plugin instance. * tags: - * - Integrations + * - Plugins * requestBody: * required: true * content: - * multipart/form-data: + * application/json: * schema: * type: object * required: - * - file + * - instanceId * properties: - * file: + * instanceId: + * type: string + * authContext: * type: string - * format: binary * responses: * 200: - * description: Upload result + * description: OAuth credentials revoked * content: * application/json: * schema: * type: object + * properties: + * ok: + * type: boolean * 400: * description: Bad Request + * 401: + * description: Unauthorized * 500: * description: Internal Server Error */ export async function POST(event: RequestEvent) { try { - const formData = await event.request.formData(); - const file = formData.get("file"); - - if (!(file instanceof Blob)) { - return json({ message: "missing_file" }, { status: 400 }); - } - - const r = await event.locals.pb.send("/integration/hammerhead/upload", { + const body = await event.request.json(); + const r = await event.locals.pb.send("/plugins/oauth/revoke", { method: "POST", - body: formData, + body, fetch: event.fetch, }); return json(r); - } catch (e: any) { - return handleError(e) + } catch (e) { + return handleError(e); } } diff --git a/web/src/routes/api/v1/plugin-system/oauth/start/+server.ts b/web/src/routes/api/v1/plugin-system/oauth/start/+server.ts new file mode 100644 index 00000000..d5db33b3 --- /dev/null +++ b/web/src/routes/api/v1/plugin-system/oauth/start/+server.ts @@ -0,0 +1,66 @@ +import { handleError } from "$lib/util/api_util"; +import { json, type RequestEvent } from "@sveltejs/kit"; + +/** + * @swagger + * /api/v1/plugin-system/oauth/start: + * post: + * summary: Start plugin OAuth flow + * description: Creates an OAuth authorization URL for a plugin instance. + * tags: + * - Plugins + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - pluginId + * - instanceId + * - redirectUri + * properties: + * pluginId: + * type: string + * instanceId: + * type: string + * authContext: + * type: string + * redirectUri: + * type: string + * format: uri + * responses: + * 200: + * description: OAuth authorization URL + * content: + * application/json: + * schema: + * type: object + * properties: + * url: + * type: string + * format: uri + * state: + * type: string + * instanceId: + * type: string + * 400: + * description: Bad Request + * 401: + * description: Unauthorized + * 500: + * description: Internal Server Error + */ +export async function POST(event: RequestEvent) { + try { + const body = await event.request.json(); + const r = await event.locals.pb.send("/plugins/oauth/start", { + method: "POST", + body, + fetch: event.fetch, + }); + return json(r); + } catch (e) { + return handleError(e); + } +} diff --git a/web/src/routes/api/v1/plugin-system/plugins/+server.ts b/web/src/routes/api/v1/plugin-system/plugins/+server.ts new file mode 100644 index 00000000..59c293c9 --- /dev/null +++ b/web/src/routes/api/v1/plugin-system/plugins/+server.ts @@ -0,0 +1,60 @@ +import { handleError } from "$lib/util/api_util"; +import { json, type RequestEvent } from "@sveltejs/kit"; + +/** + * @swagger + * /api/v1/plugin-system/plugins: + * get: + * summary: List installed plugins + * description: Refreshes the plugin cache and lists locally installed plugin providers with runtime status and manifest metadata. + * tags: + * - Plugins + * responses: + * 200: + * description: Installed plugin providers + * content: + * application/json: + * schema: + * type: object + * properties: + * items: + * type: array + * items: + * type: object + * properties: + * id: + * type: string + * type: + * type: string + * name: + * type: string + * version: + * type: string + * runtime: + * type: string + * capabilities: + * type: array + * items: + * type: string + * status: + * type: string + * enum: [available, disabled, error] + * error: + * type: string + * manifest: + * type: object + * 401: + * description: Unauthorized + * 500: + * description: Internal Server Error + */ +export async function GET(event: RequestEvent) { + try { + const r = await event.locals.pb.send("/plugins", { + method: "GET", + }); + return json(r); + } catch (e: any) { + return handleError(e); + } +} diff --git a/web/src/routes/api/v1/plugin-system/trail-send/+server.ts b/web/src/routes/api/v1/plugin-system/trail-send/+server.ts new file mode 100644 index 00000000..8bd17352 --- /dev/null +++ b/web/src/routes/api/v1/plugin-system/trail-send/+server.ts @@ -0,0 +1,62 @@ +import { handleError } from "$lib/util/api_util"; +import { json, type RequestEvent } from "@sveltejs/kit"; + +/** + * @swagger + * /api/v1/plugin-system/trail-send: + * post: + * summary: Send trail through plugin + * description: Sends an existing trail to an enabled plugin provider that supports trail transfer. + * tags: + * - Plugins + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - pluginId + * - trailId + * properties: + * pluginId: + * type: string + * trailId: + * type: string + * share: + * type: string + * description: Optional share token used to authorize sending a shared trail. + * responses: + * 200: + * description: Trail sent + * content: + * application/json: + * schema: + * type: object + * properties: + * ok: + * type: boolean + * 400: + * description: Bad Request + * 401: + * description: Unauthorized + * 403: + * description: Forbidden + * 404: + * description: Not Found + * 500: + * description: Internal Server Error + */ +export async function POST(event: RequestEvent) { + try { + const body = await event.request.json(); + const r = await event.locals.pb.send("/plugins/trail-send", { + method: "POST", + body, + fetch: event.fetch, + }); + return json(r); + } catch (e: any) { + return handleError(e); + } +} diff --git a/web/src/routes/settings/+layout.svelte b/web/src/routes/settings/+layout.svelte index 5791d7e4..d79fb789 100644 --- a/web/src/routes/settings/+layout.svelte +++ b/web/src/routes/settings/+layout.svelte @@ -21,7 +21,7 @@ }, { text: $_("notifications"), value: "/settings/notifications" }, { text: $_("map"), value: "/settings/map" }, - { text: $_("integrations"), value: "/settings/integrations" }, + { text: $_("plugins"), value: "/settings/plugins" }, { text: $_("similar-trails"), value: "/settings/maintenance/similar-trails" }, { text: `${$_("import")}/${$_("export")}`, value: "/settings/export" }, { diff --git a/web/src/routes/settings/integrations/+page.svelte b/web/src/routes/settings/integrations/+page.svelte deleted file mode 100644 index 88a3d16f..00000000 --- a/web/src/routes/settings/integrations/+page.svelte +++ /dev/null @@ -1,278 +0,0 @@ - - - - {$_("settings")} | wanderer - - -

{$_("integrations")}

-
- -
- stravaSettingsModal.openModal()} - ontoggle={onStravaToggle} - > - komootSettingsModal.openModal()} - ontoggle={onKomootToggle} - > - hammerheadSettingsModal.openModal()} - ontoggle={onHammerheadToggle} - > -
- - onSettingsSave(form, "strava")} -> - - onSettingsSave(form, "komoot")} -> - - onSettingsSave(form, "hammerhead")} -> diff --git a/web/src/routes/settings/integrations/+page.ts b/web/src/routes/settings/integrations/+page.ts deleted file mode 100644 index 866b53bb..00000000 --- a/web/src/routes/settings/integrations/+page.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { integrations_index } from "$lib/stores/integration_store"; -import { type Load } from "@sveltejs/kit"; - -export const load: Load = async ({ params, fetch }) => { - const integrations = await integrations_index(fetch) - return { integration: integrations.at(0) } -}; \ No newline at end of file diff --git a/web/src/routes/settings/integrations/callback/strava/+page.server.ts b/web/src/routes/settings/integrations/callback/strava/+page.server.ts deleted file mode 100644 index b7853de9..00000000 --- a/web/src/routes/settings/integrations/callback/strava/+page.server.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { error, redirect, type ServerLoad } from "@sveltejs/kit"; -import { ClientResponseError } from "pocketbase"; - -export const load: ServerLoad = async ({ url, fetch, locals }) => { - const oauthError = url.searchParams.get('error'); - if (oauthError) { - // user cancelled - if (oauthError == "access_denied") { - return redirect(302, '/settings/integrations') - } - return error(400, { - message: oauthError - }); - } - const code = url.searchParams.get('code'); - if (!code) { - return error(400, { - message: "No code provided" - }); - } - - try { - await locals.pb.send("/integration/strava/token", { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - code, - grant_type: 'authorization_code' - }) - }); - } catch (e) { - console.error(e) - - if (e instanceof ClientResponseError) { - return error(e.status, e.message); - - } - throw e - } - return redirect(302, '/settings/integrations') -} \ No newline at end of file diff --git a/web/src/routes/settings/plugins/+page.svelte b/web/src/routes/settings/plugins/+page.svelte new file mode 100644 index 00000000..afd1d669 --- /dev/null +++ b/web/src/routes/settings/plugins/+page.svelte @@ -0,0 +1,561 @@ + + + + {$_("settings")} | wanderer + + +

{$_("plugins")}

+
+ +{#if pluginGroups.length === 0} +
+ +

{$_("plugins-empty-title")}

+

+ {$_("plugins-empty-description")} +

+ + + {$_("plugins-empty-docs-link")} + +
+{:else} +
+ {#each pluginGroups as group (group.type)} +
+
+

{pluginTypeTitle(group.type)}

+

+ {pluginTypeDescription(group.type)} +

+
+
+ {#each group.plugins as plugin (plugin.id)} + {@const instance = instanceForPlugin(plugin)} + {@const settingsDisabled = plugin.status != "available"} + openPluginSettings(plugin)} + ontoggle={(value) => onPluginToggle(plugin, instance, value)} + > + {/each} +
+
+ {/each} +
+{/if} + +{#if pendingCategoryRemap} + +{/if} + +{#if selectedPlugin} + {#key pluginSettingsModalKey(selectedPlugin)} + + {/key} +{/if} diff --git a/web/src/routes/settings/plugins/+page.ts b/web/src/routes/settings/plugins/+page.ts new file mode 100644 index 00000000..0956b4fe --- /dev/null +++ b/web/src/routes/settings/plugins/+page.ts @@ -0,0 +1,13 @@ +import { plugin_instances_index } from "$lib/stores/plugin_instance_store"; +import { plugins_index } from "$lib/stores/plugin_store"; +import { categories_index } from "$lib/stores/category_store"; +import { type Load } from "@sveltejs/kit"; + +export const load: Load = async ({ fetch }) => { + const [pluginInstances, pluginProviders, categories] = await Promise.all([ + plugin_instances_index(fetch), + plugins_index(fetch), + categories_index(fetch), + ]); + return { pluginInstances, pluginProviders, categories }; +}; diff --git a/web/src/routes/settings/plugins/oauth/callback/+page.svelte b/web/src/routes/settings/plugins/oauth/callback/+page.svelte new file mode 100644 index 00000000..47635ff0 --- /dev/null +++ b/web/src/routes/settings/plugins/oauth/callback/+page.svelte @@ -0,0 +1,47 @@ + + + + {$_("plugins")} | wanderer + + +
+ {#if error} +

{$_("error")}

+

{error}

+ {$_("plugins")} + {:else} +

{$_("plugins")}

+ {/if} +