From 02d8525ade105ef5fb1fb5b0a73cb93a2b06a989 Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Thu, 3 Jul 2025 12:58:20 +0200 Subject: [PATCH] Places: Add config option to specify location details locale #465 #883 Signed-off-by: Michael Mayer --- NOTICE | 10 +++---- go.mod | 4 +-- go.sum | 12 ++++---- internal/api/api_request.go | 2 +- internal/api/places_reverse.go | 23 +++++++++------ internal/api/places_search.go | 2 +- internal/api/swagger.json | 14 ++++++++- internal/config/config.go | 10 +------ internal/config/config_places.go | 20 +++++++++++++ internal/config/config_places_test.go | 30 ++++++++++++++++++++ internal/config/config_test.go | 8 ------ internal/config/flags.go | 7 +++++ internal/config/options.go | 1 + internal/config/report.go | 1 + internal/entity/auth_session.go | 4 +-- internal/service/hub/config.go | 4 +-- internal/service/hub/feedback.go | 6 ++-- internal/service/hub/places/cell.go | 9 ++++-- internal/service/hub/places/cell_test.go | 12 ++++---- internal/service/hub/places/latlng.go | 9 ++++-- internal/service/hub/places/latlng_test.go | 30 ++++++++++++++------ internal/service/hub/places/locale.go | 16 +++++++++++ internal/service/hub/places/location.go | 1 + internal/service/hub/places/request.go | 13 +++++++-- internal/service/hub/places/search.go | 12 ++++---- internal/service/hub/places/search_result.go | 2 +- internal/service/maps/location.go | 2 +- pkg/clean/locale_test.go | 14 +++++++++ pkg/media/http/header/request.go | 6 ++-- pkg/media/http/header/request_test.go | 2 +- 30 files changed, 203 insertions(+), 83 deletions(-) create mode 100644 internal/config/config_places.go create mode 100644 internal/config/config_places_test.go create mode 100644 internal/service/hub/places/locale.go diff --git a/NOTICE b/NOTICE index eecfe3144..22701b62a 100644 --- a/NOTICE +++ b/NOTICE @@ -9,7 +9,7 @@ The following 3rd-party software packages may be used by or distributed with PhotoPrism. Any information relevant to third-party vendors listed below are collected using common, reasonable means. -Date generated: 2025-07-02 +Date generated: 2025-07-03 ================================================================================ @@ -2343,8 +2343,8 @@ SOFTWARE. -------------------------------------------------------------------------------- Package: github.com/golang/geo -Version: v0.0.0-20250627182359-f4b81656db99 -License: Apache-2.0 (https://github.com/golang/geo/blob/f4b81656db99/LICENSE) +Version: v0.0.0-20250702194635-55051eb0594d +License: Apache-2.0 (https://github.com/golang/geo/blob/55051eb0594d/LICENSE) Apache License @@ -6414,8 +6414,8 @@ License: Apache-2.0 (https://github.com/zitadel/logging/blob/v0.6.2/LICENSE) -------------------------------------------------------------------------------- Package: github.com/zitadel/oidc/v3/pkg -Version: v3.39.0 -License: Apache-2.0 (https://github.com/zitadel/oidc/blob/v3.39.0/LICENSE) +Version: v3.39.1 +License: Apache-2.0 (https://github.com/zitadel/oidc/blob/v3.39.1/LICENSE) Apache License Version 2.0, January 2004 diff --git a/go.mod b/go.mod index 49cc43688..710112c9a 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/esimov/pigo v1.4.6 github.com/gin-contrib/gzip v1.2.3 github.com/gin-gonic/gin v1.10.1 - github.com/golang/geo v0.0.0-20250630213057-fac2d31592dd + github.com/golang/geo v0.0.0-20250702194635-55051eb0594d github.com/google/open-location-code/go v0.0.0-20250620134813-83986da0156b github.com/gorilla/websocket v1.5.3 github.com/gosimple/slug v1.15.0 @@ -86,7 +86,7 @@ require ( github.com/ugjka/go-tz/v2 v2.2.6 github.com/urfave/cli/v2 v2.27.7 github.com/wamuir/graft v0.10.0 - github.com/zitadel/oidc/v3 v3.39.0 + github.com/zitadel/oidc/v3 v3.39.1 golang.org/x/mod v0.25.0 golang.org/x/sys v0.33.0 ) diff --git a/go.sum b/go.sum index 394b80748..b34f4f83b 100644 --- a/go.sum +++ b/go.sum @@ -133,8 +133,8 @@ github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= -github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= -github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= +github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-co-op/gocron/v2 v2.16.2 h1:r08P663ikXiulLT9XaabkLypL/W9MoCIbqgQoAutyX4= github.com/go-co-op/gocron/v2 v2.16.2/go.mod h1:4YTLGCCAH75A5RlQ6q+h+VacO7CgjkgP0EJ+BEOXRSI= github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= @@ -184,8 +184,8 @@ github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGw github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI= github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI= github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI= -github.com/golang/geo v0.0.0-20250630213057-fac2d31592dd h1:4BU/Fy2KwDucgY0al91L0wJI05rb1Y4hQKucQkybjT8= -github.com/golang/geo v0.0.0-20250630213057-fac2d31592dd/go.mod h1:Vaw7L5b+xa3Rj4/pRtrQkymn3lSBRB/NAEdbF9YEVLA= +github.com/golang/geo v0.0.0-20250702194635-55051eb0594d h1:AzT7tYHcPLcUlo6224yQJvhL1A9GjT5g1cYbyDSYt/w= +github.com/golang/geo v0.0.0-20250702194635-55051eb0594d/go.mod h1:Vaw7L5b+xa3Rj4/pRtrQkymn3lSBRB/NAEdbF9YEVLA= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -419,8 +419,8 @@ github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBi github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zitadel/logging v0.6.2 h1:MW2kDDR0ieQynPZ0KIZPrh9ote2WkxfBif5QoARDQcU= github.com/zitadel/logging v0.6.2/go.mod h1:z6VWLWUkJpnNVDSLzrPSQSQyttysKZ6bCRongw0ROK4= -github.com/zitadel/oidc/v3 v3.39.0 h1:WK3eNqmgshiYo1oEqONfXXbPbve+Qzgjl8KhKDFUvxc= -github.com/zitadel/oidc/v3 v3.39.0/go.mod h1:JwdgdU/WxkmBtWuE8/pEjAbDTWXxJGqBix/gUoeEig4= +github.com/zitadel/oidc/v3 v3.39.1 h1:6QwGwI3yxh4somT7fwRCeT1KOn/HOGv0PA0dFciwJjE= +github.com/zitadel/oidc/v3 v3.39.1/go.mod h1:aH8brOrzoliAybVdfq2xIdGvbtl0j/VsKRNa7WE72gI= github.com/zitadel/schema v1.3.1 h1:QT3kwiRIRXXLVAs6gCK/u044WmUVh6IlbLXUsn6yRQU= github.com/zitadel/schema v1.3.1/go.mod h1:071u7D2LQacy1HAN+YnMd/mx1qVE2isb0Mjeqg46xnU= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= diff --git a/internal/api/api_request.go b/internal/api/api_request.go index 5708cfac0..5a29cf921 100644 --- a/internal/api/api_request.go +++ b/internal/api/api_request.go @@ -13,5 +13,5 @@ func ClientIP(c *gin.Context) (ip string) { // UserAgent returns the user agent from the request context or an empty string if it is unknown. func UserAgent(c *gin.Context) string { - return header.UserAgent(c) + return header.ClientUserAgent(c) } diff --git a/internal/api/places_reverse.go b/internal/api/places_reverse.go index 6da3b00d7..36a5c3fb5 100644 --- a/internal/api/places_reverse.go +++ b/internal/api/places_reverse.go @@ -8,6 +8,7 @@ import ( "github.com/photoprism/photoprism/internal/auth/acl" "github.com/photoprism/photoprism/internal/photoprism/get" "github.com/photoprism/photoprism/internal/service/hub/places" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/txt" ) @@ -19,12 +20,13 @@ import ( // @Id GetPlacesReverse // @Tags Places // @Produce json -// @Param lat query string true "Latitude" -// @Param lng query string true "Longitude" -// @Success 200 {object} places.Location -// @Failure 400 {object} gin.H "Missing latitude or longitude" -// @Failure 401 {object} i18n.Response -// @Failure 500 {object} gin.H "Geocoding service error" +// @Param lat query string true "Latitude" +// @Param lng query string true "Longitude" +// @Param locale query string false "Locale" +// @Success 200 {object} places.Location +// @Failure 400 {object} gin.H "Missing latitude or longitude" +// @Failure 401 {object} i18n.Response +// @Failure 500 {object} gin.H "Geocoding service error" // @Router /api/v1/places/reverse [get] func GetPlacesReverse(router *gin.RouterGroup) { handler := func(c *gin.Context) { @@ -44,7 +46,7 @@ func GetPlacesReverse(router *gin.RouterGroup) { return } - // Get latitude and longitude from query parameters. + // Get latitude, longitude, and locale from query parameters. var lat, lng string if lat = txt.Numeric(c.Query("lat")); lat == "" { @@ -61,14 +63,19 @@ func GetPlacesReverse(router *gin.RouterGroup) { return } - result, err := places.LatLng(txt.Float64(lat), txt.Float64(lng)) + locale := clean.WebLocale(c.Query("locale"), conf.PlacesLocale()) + // Perform service request. + result, err := places.LatLng(txt.Float64(lat), txt.Float64(lng), locale) + + // Return error if request was not successful. if err != nil { log.Errorf("places: failed to resolve location at lat %s, lng %s", lat, lng) c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err}) return } + // Return location details. c.JSON(http.StatusOK, result) } diff --git a/internal/api/places_search.go b/internal/api/places_search.go index 75c668c57..c98c45837 100644 --- a/internal/api/places_search.go +++ b/internal/api/places_search.go @@ -48,7 +48,7 @@ func GetPlacesSearch(router *gin.RouterGroup) { // Get the search string, locale, and result count limit from the query parameters. query := clean.SearchString(c.Query("q")) - locale := clean.WebLocale(c.Query("locale"), conf.DefaultLocale()) + locale := clean.WebLocale(c.Query("locale"), conf.PlacesLocale()) count := txt.IntVal(c.Query("count"), 1, 50, 10) if query == "" { diff --git a/internal/api/swagger.json b/internal/api/swagger.json index 44b66b7d8..4e984f54c 100644 --- a/internal/api/swagger.json +++ b/internal/api/swagger.json @@ -3873,6 +3873,12 @@ "name": "lng", "in": "query", "required": true + }, + { + "type": "string", + "description": "Locale", + "name": "locale", + "in": "query" } ], "responses": { @@ -5665,6 +5671,9 @@ "OriginalsLimit": { "type": "integer" }, + "PlacesLocale": { + "type": "string" + }, "PngSize": { "type": "integer" }, @@ -7534,6 +7543,9 @@ "lng": { "type": "number" }, + "locale": { + "type": "string" + }, "name": { "type": "string" }, @@ -7580,7 +7592,7 @@ "places.SearchResult": { "type": "object", "properties": { - "boundingbox": { + "bbox": { "type": "array", "items": { "type": "number" diff --git a/internal/config/config.go b/internal/config/config.go index b543a7a85..6e980e989 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -306,6 +306,7 @@ func (c *Config) Propagate() { // Set geocoding parameters. places.UserAgent = c.UserAgent() + places.DefaultLocale = c.PlacesLocale() entity.GeoApi = c.GeoApi() // Set session cache duration. @@ -698,15 +699,6 @@ func (c *Config) AutoImport() time.Duration { return time.Duration(c.options.AutoImport) * time.Second } -// GeoApi returns the preferred geocoding api (places, or none). -func (c *Config) GeoApi() string { - if c.options.DisablePlaces { - return "" - } - - return "places" -} - // OriginalsLimit returns the maximum size of originals in MB. func (c *Config) OriginalsLimit() int { if c.options.OriginalsLimit <= 0 || c.options.OriginalsLimit > 100000 { diff --git a/internal/config/config_places.go b/internal/config/config_places.go new file mode 100644 index 000000000..8f5579534 --- /dev/null +++ b/internal/config/config_places.go @@ -0,0 +1,20 @@ +package config + +import ( + "github.com/photoprism/photoprism/internal/service/hub/places" + "github.com/photoprism/photoprism/pkg/clean" +) + +// GeoApi returns the preferred geocoding api (places, or none). +func (c *Config) GeoApi() string { + if c.options.DisablePlaces { + return "" + } + + return "places" +} + +// PlacesLocale returns the locale name used for geocoding. +func (c *Config) PlacesLocale() string { + return clean.WebLocale(c.options.PlacesLocale, places.LocalLocale) +} diff --git a/internal/config/config_places_test.go b/internal/config/config_places_test.go new file mode 100644 index 000000000..8798d8615 --- /dev/null +++ b/internal/config/config_places_test.go @@ -0,0 +1,30 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestConfig_GeoApi(t *testing.T) { + c := NewConfig(CliTestContext()) + + assert.Equal(t, "places", c.GeoApi()) + c.options.DisablePlaces = true + assert.Equal(t, "", c.GeoApi()) +} + +func TestConfig_PlacesLocale(t *testing.T) { + c := NewConfig(CliTestContext()) + + c.options.PlacesLocale = "" + assert.Equal(t, "local", c.PlacesLocale()) + c.options.PlacesLocale = "local" + assert.Equal(t, "local", c.PlacesLocale()) + c.options.PlacesLocale = "EN" + assert.Equal(t, "en", c.PlacesLocale()) + c.options.PlacesLocale = "EN_US" + assert.Equal(t, "en-US", c.PlacesLocale()) + c.options.PlacesLocale = "" + assert.Equal(t, "local", c.PlacesLocale()) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 063d7ed86..2de4a44f6 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -316,14 +316,6 @@ func TestConfig_AutoImport(t *testing.T) { assert.Equal(t, 2*time.Hour, c.AutoImport()) } -func TestConfig_GeoApi(t *testing.T) { - c := NewConfig(CliTestContext()) - - assert.Equal(t, "places", c.GeoApi()) - c.options.DisablePlaces = true - assert.Equal(t, "", c.GeoApi()) -} - func TestConfig_OriginalsLimit(t *testing.T) { c := NewConfig(CliTestContext()) diff --git a/internal/config/flags.go b/internal/config/flags.go index 8eeabd975..5ab77fb65 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -10,6 +10,7 @@ import ( "github.com/photoprism/photoprism/internal/config/ttl" "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/ffmpeg/encode" + "github.com/photoprism/photoprism/internal/service/hub/places" "github.com/photoprism/photoprism/internal/thumb" "github.com/photoprism/photoprism/pkg/authn" "github.com/photoprism/photoprism/pkg/i18n" @@ -510,6 +511,12 @@ var Flags = CliFlags{ Usage: "default user interface theme `NAME`", EnvVars: EnvVars("DEFAULT_THEME"), }}, { + Flag: &cli.StringFlag{ + Name: "places-locale", + Usage: "location details language `CODE`, e.g. en, de, or local", + Value: places.LocalLocale, + EnvVars: EnvVars("PLACES_LOCALE"), + }}, { Flag: &cli.StringFlag{ Name: "app-name", Usage: "progressive web app `NAME` when installed on a device", diff --git a/internal/config/options.go b/internal/config/options.go index 855bc23df..526224baa 100644 --- a/internal/config/options.go +++ b/internal/config/options.go @@ -118,6 +118,7 @@ type Options struct { DefaultLocale string `yaml:"DefaultLocale" json:"DefaultLocale" flag:"default-locale"` DefaultTimezone string `yaml:"DefaultTimezone" json:"DefaultTimezone" flag:"default-timezone"` DefaultTheme string `yaml:"DefaultTheme" json:"DefaultTheme" flag:"default-theme"` + PlacesLocale string `yaml:"PlacesLocale" json:"PlacesLocale" flag:"places-locale"` AppName string `yaml:"AppName" json:"AppName" flag:"app-name"` AppMode string `yaml:"AppMode" json:"AppMode" flag:"app-mode"` AppIcon string `yaml:"AppIcon" json:"AppIcon" flag:"app-icon"` diff --git a/internal/config/report.go b/internal/config/report.go index e87ef11ba..35d58af0f 100644 --- a/internal/config/report.go +++ b/internal/config/report.go @@ -139,6 +139,7 @@ func (c *Config) Report() (rows [][]string, cols []string) { {"default-locale", c.DefaultLocale()}, {"default-timezone", c.DefaultTimezone().String()}, {"default-theme", c.DefaultTheme()}, + {"places-locale", c.PlacesLocale()}, {"app-name", c.AppName()}, {"app-mode", c.AppMode()}, {"app-icon", c.AppIcon()}, diff --git a/internal/entity/auth_session.go b/internal/entity/auth_session.go index 392cef5b3..3a66962ef 100644 --- a/internal/entity/auth_session.go +++ b/internal/entity/auth_session.go @@ -662,7 +662,7 @@ func (m *Session) SetContext(c *gin.Context) *Session { } // Set client user agent from request context. - if ua := header.UserAgent(c); ua != "" { + if ua := header.ClientUserAgent(c); ua != "" { m.SetUserAgent(ua) } @@ -688,7 +688,7 @@ func (m *Session) UpdateContext(c *gin.Context) *Session { } // Set client user agent from request context. - if ua := header.UserAgent(c); ua != "" && ua != m.UserAgent { + if ua := header.ClientUserAgent(c); ua != "" && ua != m.UserAgent { m.SetUserAgent(ua) changed = true } diff --git a/internal/service/hub/config.go b/internal/service/hub/config.go index dbd3a1b9f..fa07bdb65 100644 --- a/internal/service/hub/config.go +++ b/internal/service/hub/config.go @@ -245,9 +245,9 @@ func (c *Config) ReSync(token string) (err error) { // Set user agent. if c.UserAgent != "" { - req.Header.Set("User-Agent", c.UserAgent) + req.Header.Set(header.UserAgent, c.UserAgent) } else { - req.Header.Set("User-Agent", "PhotoPrism/Test") + req.Header.Set(header.UserAgent, "PhotoPrism/Test") } // Add Content-Type header. diff --git a/internal/service/hub/feedback.go b/internal/service/hub/feedback.go index 94b90b697..492d87237 100644 --- a/internal/service/hub/feedback.go +++ b/internal/service/hub/feedback.go @@ -82,12 +82,12 @@ func (c *Config) SendFeedback(frm form.Feedback) (err error) { // Set user agent. if c.UserAgent != "" { - req.Header.Set("User-Agent", c.UserAgent) + req.Header.Set(header.UserAgent, c.UserAgent) } else { - req.Header.Set("User-Agent", "PhotoPrism/Test") + req.Header.Set(header.UserAgent, "PhotoPrism/Test") } - req.Header.Add("Accept-Language", frm.UserLocales) + req.Header.Add(header.AcceptLanguage, frm.UserLocales) req.Header.Add(header.ContentType, header.ContentTypeJson) var r *http.Response diff --git a/internal/service/hub/places/cell.go b/internal/service/hub/places/cell.go index 748a0d7c4..98a872c6e 100644 --- a/internal/service/hub/places/cell.go +++ b/internal/service/hub/places/cell.go @@ -11,7 +11,7 @@ import ( ) // Cell returns location details based on the specified S2 cell ID. -func Cell(id string) (result Location, err error) { +func Cell(id string, locale string) (result Location, err error) { // Normalize S2 Cell ID. id = s2.NormalizeToken(id) @@ -34,8 +34,11 @@ func Cell(id string) (result Location, err error) { return result, fmt.Errorf("skipping lat %f, lng %f", lat, lng) } + // Get request locale. + locale = Locale(locale) + // Create cache key based on query parameters. - cacheKey := fmt.Sprintf("id:%s", id) + cacheKey := fmt.Sprintf("id:%s:%s", id, locale) // Location details cached? if hit, ok := clientCache.Get(cacheKey); ok { @@ -50,7 +53,7 @@ func Cell(id string) (result Location, err error) { // Query the specified places service URLs. for _, serviceUrl := range LocationServiceUrls { reqUrl := fmt.Sprintf(serviceUrl, id) - if r, err = GetRequest(reqUrl); err == nil { + if r, err = GetRequest(reqUrl, locale); err == nil { break } } diff --git a/internal/service/hub/places/cell_test.go b/internal/service/hub/places/cell_test.go index 2da7a4364..94ac41fd8 100644 --- a/internal/service/hub/places/cell_test.go +++ b/internal/service/hub/places/cell_test.go @@ -14,7 +14,7 @@ func TestCell(t *testing.T) { lng := 13.40806264572578 id := s2.Token(lat, lng) - l, err := Cell(id) + l, err := Cell(id, DefaultLocale) if err != nil { t.Fatal(err) @@ -25,17 +25,17 @@ func TestCell(t *testing.T) { assert.Equal(t, "de", l.CountryCode()) }) t.Run("MissingId", func(t *testing.T) { - l, err := Cell("") + l, err := Cell("", DefaultLocale) assert.Error(t, err, "places: invalid location id ") t.Log(l) }) t.Run("WrongId", func(t *testing.T) { - l, err := Cell("2") + l, err := Cell("2", DefaultLocale) assert.Error(t, err, "places: skipping lat 0.000000, lng 0.000000") t.Log(l) }) t.Run("ShortId", func(t *testing.T) { - l, err := Cell("ab") + l, err := Cell("ab", DefaultLocale) assert.Error(t, err, "places: skipping lat 0.000000, lng 0.000000") t.Log(l) }) @@ -52,12 +52,12 @@ func TestCell(t *testing.T) { Cached: true, } - l, err := Cell(location.ID) + l, err := Cell(location.ID, DefaultLocale) if err != nil { t.Fatal(err) } assert.Equal(t, false, l.Cached) - l2, err2 := Cell("1e95998417cc") + l2, err2 := Cell("1e95998417cc", DefaultLocale) if err2 != nil { t.Fatal(err2) diff --git a/internal/service/hub/places/latlng.go b/internal/service/hub/places/latlng.go index e9c03de1e..c83a1e45b 100644 --- a/internal/service/hub/places/latlng.go +++ b/internal/service/hub/places/latlng.go @@ -12,7 +12,7 @@ import ( ) // LatLng returns location details based on the specified latitude and longitude. -func LatLng(lat, lng float64) (result Location, err error) { +func LatLng(lat, lng float64, locale string) (result Location, err error) { if lat == 0.0 || lng == 0.0 { return result, ErrMissingCoordinates } @@ -24,9 +24,12 @@ func LatLng(lat, lng float64) (result Location, err error) { values := url.Values{"lat": {fmt.Sprintf("%f", lat)}, "lng": {fmt.Sprintf("%f", lng)}} params := values.Encode() + // Get request locale. + locale = Locale(locale) + // Create cache key based on query parameters. id := s2.Token(lat, lng) - cacheKey := fmt.Sprintf("id:%s", id) + cacheKey := fmt.Sprintf("id:%s:%s", id, locale) // Are location results cached? if hit, ok := clientCache.Get(cacheKey); ok { @@ -41,7 +44,7 @@ func LatLng(lat, lng float64) (result Location, err error) { // Query the specified places service URLs. for _, serviceUrl := range ReverseServiceUrls { reqUrl := fmt.Sprintf("%s?%s", serviceUrl, params) - if r, err = GetRequest(reqUrl); err == nil { + if r, err = GetRequest(reqUrl, locale); err == nil { break } } diff --git a/internal/service/hub/places/latlng_test.go b/internal/service/hub/places/latlng_test.go index 8a2a1c840..4d37031da 100644 --- a/internal/service/hub/places/latlng_test.go +++ b/internal/service/hub/places/latlng_test.go @@ -7,26 +7,40 @@ import ( ) func TestLatLng(t *testing.T) { - lat := 52.51961810676184 - lng := 13.40806264572578 + lat := 52.5208 + lng := 13.40953 - t.Run("Success", func(t *testing.T) { - l, err := LatLng(lat, lng) + t.Run("Local", func(t *testing.T) { + l, err := LatLng(lat, lng, LocalLocale) if err != nil { t.Fatal(err) } + assert.Equal(t, LocalLocale, l.Locale) + assert.Equal(t, "Berliner Fernsehturm", l.Name()) + assert.Equal(t, "Berlin", l.City()) + assert.Equal(t, "de", l.CountryCode()) + }) + t.Run("Englisb", func(t *testing.T) { + l, err := LatLng(lat, lng, "en") + + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, "en", l.Locale) + assert.Equal(t, "Fernsehturm Berlin", l.Name()) assert.Equal(t, "Berlin", l.City()) assert.Equal(t, "de", l.CountryCode()) }) t.Run("MissingLng", func(t *testing.T) { - l, err := LatLng(1, 0) + l, err := LatLng(1, 0, LocalLocale) assert.Error(t, err, "places: skipping lat 0.000000, lng 0.000000") t.Log(l) }) t.Run("MissingLat", func(t *testing.T) { - l, err := LatLng(0, 1) + l, err := LatLng(0, 1, LocalLocale) assert.Error(t, err, "places: skipping lat 0.000000, lng 0.000000") t.Log(l) }) @@ -43,13 +57,13 @@ func TestLatLng(t *testing.T) { Cached: true, } - _, err := LatLng(location.LocLat, location.LocLng) + _, err := LatLng(location.LocLat, location.LocLng, LocalLocale) if err != nil { t.Fatal(err) } - cachedLoc, cacheErr := LatLng(location.LocLat, location.LocLng) + cachedLoc, cacheErr := LatLng(location.LocLat, location.LocLng, LocalLocale) if cacheErr != nil { t.Fatal(cacheErr) diff --git a/internal/service/hub/places/locale.go b/internal/service/hub/places/locale.go new file mode 100644 index 000000000..4aa6daaac --- /dev/null +++ b/internal/service/hub/places/locale.go @@ -0,0 +1,16 @@ +package places + +import ( + "github.com/photoprism/photoprism/pkg/clean" +) + +// LocalLocale specifies the locale name to return results in the local language. +const LocalLocale = "local" + +// DefaultLocale specifies the default places query locale. +var DefaultLocale = LocalLocale + +// Locale returns the places query locale string. +func Locale(locale string) string { + return clean.WebLocale(locale, DefaultLocale) +} diff --git a/internal/service/hub/places/location.go b/internal/service/hub/places/location.go index a183d1ab0..be6a149a6 100644 --- a/internal/service/hub/places/location.go +++ b/internal/service/hub/places/location.go @@ -10,6 +10,7 @@ import ( // Location represents a specific geolocation identified by its S2 ID. type Location struct { ID string `json:"id"` + Locale string `json:"locale,omitempty"` LocLat float64 `json:"lat"` LocLng float64 `json:"lng"` LocName string `json:"name"` diff --git a/internal/service/hub/places/request.go b/internal/service/hub/places/request.go index 0752ca135..170021226 100644 --- a/internal/service/hub/places/request.go +++ b/internal/service/hub/places/request.go @@ -5,10 +5,12 @@ import ( "fmt" "net/http" "time" + + "github.com/photoprism/photoprism/pkg/media/http/header" ) // GetRequest fetches the cell ID data from the service URL. -func GetRequest(reqUrl string) (r *http.Response, err error) { +func GetRequest(reqUrl string, locale string) (r *http.Response, err error) { var req *http.Request // Log request URL. @@ -25,9 +27,14 @@ func GetRequest(reqUrl string) (r *http.Response, err error) { // Set user agent. if UserAgent != "" { - req.Header.Set("User-Agent", UserAgent) + req.Header.Set(header.UserAgent, UserAgent) } else { - req.Header.Set("User-Agent", "PhotoPrism/Test") + req.Header.Set(header.UserAgent, "PhotoPrism/Test") + } + + // Set requested result locale. + if locale != "" { + req.Header.Set(header.AcceptLanguage, locale) } // Add API key? diff --git a/internal/service/hub/places/search.go b/internal/service/hub/places/search.go index f7e6d25a3..fa7306160 100644 --- a/internal/service/hub/places/search.go +++ b/internal/service/hub/places/search.go @@ -31,15 +31,13 @@ func Search(q, locale string, count int) (results SearchResults, err error) { // Generate query parameter string. values := url.Values{"q": {q}, "count": {strconv.Itoa(count)}} - - if locale != "" { - values.Add("locale", locale) - } - params := values.Encode() + // Get request locale. + locale = Locale(locale) + // Create cache key based on query parameters. - cacheKey := fmt.Sprintf("search:%s", params) + cacheKey := fmt.Sprintf("search:%s:%s", params, locale) // Are location results cached? if hit, ok := clientCache.Get(cacheKey); ok { @@ -53,7 +51,7 @@ func Search(q, locale string, count int) (results SearchResults, err error) { // Query the specified places service URLs. for _, serviceUrl := range SearchServiceUrls { reqUrl := fmt.Sprintf("%s?%s", serviceUrl, params) - if r, err = GetRequest(reqUrl); err == nil { + if r, err = GetRequest(reqUrl, locale); err == nil { break } } diff --git a/internal/service/hub/places/search_result.go b/internal/service/hub/places/search_result.go index eab44c666..4314c99ba 100644 --- a/internal/service/hub/places/search_result.go +++ b/internal/service/hub/places/search_result.go @@ -7,7 +7,7 @@ type SearchResult struct { Country string `json:"country"` Lat float64 `json:"lat"` Lng float64 `json:"lng"` - Boundingbox []float64 `json:"boundingbox,omitempty"` + BoundingBox []float64 `json:"bbox,omitempty"` Importance float64 `json:"importance,omitempty"` Licence string `json:"licence,omitempty"` } diff --git a/internal/service/maps/location.go b/internal/service/maps/location.go index b86deba8c..1ec4a1c36 100644 --- a/internal/service/maps/location.go +++ b/internal/service/maps/location.go @@ -52,7 +52,7 @@ func (l *Location) QueryApi(api string) error { } func (l *Location) QueryPlaces() error { - s, err := places.Cell(l.ID) + s, err := places.Cell(l.ID, places.DefaultLocale) if err != nil { return err diff --git a/pkg/clean/locale_test.go b/pkg/clean/locale_test.go index 82d245583..c7c9ce8be 100644 --- a/pkg/clean/locale_test.go +++ b/pkg/clean/locale_test.go @@ -43,6 +43,13 @@ func TestPosixLocale(t *testing.T) { assert.Equal(t, "de", PosixLocale("und", "de")) assert.Equal(t, "cs", PosixLocale("cs", "und")) }) + t.Run("Local", func(t *testing.T) { + assert.Equal(t, "local", PosixLocale("", "local")) + assert.Equal(t, "Local", PosixLocale("", "Local")) + assert.Equal(t, "", PosixLocale("local", "")) + assert.Equal(t, "", PosixLocale("Local", "")) + assert.Equal(t, "local", PosixLocale("local", "local")) + }) t.Run("Territory", func(t *testing.T) { assert.Equal(t, "cs_CZ", PosixLocale("cs_CZ", "")) assert.Equal(t, "cs_CZ", PosixLocale("cs-CZ", "")) @@ -68,6 +75,13 @@ func TestWebLocale(t *testing.T) { assert.Equal(t, "de", WebLocale("und", "de")) assert.Equal(t, "cs", WebLocale("cs", "und")) }) + t.Run("Local", func(t *testing.T) { + assert.Equal(t, "local", WebLocale("", "local")) + assert.Equal(t, "Local", WebLocale("", "Local")) + assert.Equal(t, "", WebLocale("local", "")) + assert.Equal(t, "", WebLocale("Local", "")) + assert.Equal(t, "local", WebLocale("local", "local")) + }) t.Run("Territory", func(t *testing.T) { assert.Equal(t, "cs-CZ", WebLocale("cs-CZ", "")) assert.Equal(t, "cs-CZ", WebLocale("cs_CZ", "")) diff --git a/pkg/media/http/header/request.go b/pkg/media/http/header/request.go index 892e47691..36b56daaa 100644 --- a/pkg/media/http/header/request.go +++ b/pkg/media/http/header/request.go @@ -11,6 +11,7 @@ const ( Browser = "Sec-Ch-Ua" Platform = "Sec-Ch-Ua-Platform" FetchMode = "Sec-Fetch-Mode" + UserAgent = "User-Agent" ) // Standard IP addresses and placeholders. @@ -36,8 +37,9 @@ func ClientIP(c *gin.Context) (ip string) { return UnknownIP } -// UserAgent returns the user agent from the request context or an empty string if it is unknown. -func UserAgent(c *gin.Context) string { +// ClientUserAgent returns the client user agent string +// from the request context, or an empty string if unknown. +func ClientUserAgent(c *gin.Context) string { if c == nil { // Should never happen. return "" diff --git a/pkg/media/http/header/request_test.go b/pkg/media/http/header/request_test.go index 7367b48ac..e4064ea4d 100644 --- a/pkg/media/http/header/request_test.go +++ b/pkg/media/http/header/request_test.go @@ -32,7 +32,7 @@ func TestRequest(t *testing.T) { Cookie: []string{"CockpitLang=en-us; Foo=Bar"}, }, } - assert.Equal(t, "TEST", UserAgent(c)) + assert.Equal(t, "TEST", ClientUserAgent(c)) assert.Equal(t, "\"Chromium\";v=\"130\", \"Google Chrome\";v=\"130\", \"Not?A_Brand\";v=\"99\"", c.GetHeader(Browser)) assert.Equal(t, "\"Linux\"", c.GetHeader(Platform)) assert.Equal(t, "navigate", c.GetHeader(FetchMode))