diff --git a/frontend/src/common/api.js b/frontend/src/common/api.js index 4e17cac92..00d8b36f0 100644 --- a/frontend/src/common/api.js +++ b/frontend/src/common/api.js @@ -25,7 +25,7 @@ Additional information can be found in our Developer Guide: import Axios from "axios"; import $notify from "common/notify"; -import { $gettext } from "common/gettext"; +import { $gettext, Tp } from "common/gettext"; import $event from "common/event"; import { getAppStorage } from "common/storage"; @@ -111,7 +111,10 @@ $api.interceptors.response.use( code = data.code; } - if (data.message) { + if (data.id) { + // Render the backend message in the current UI locale from its source id and params. + errorMessage = Tp(data.id, data.params); + } else if (data.message) { errorMessage = data.message; } else if (data.error) { errorMessage = data.error; diff --git a/internal/api/download.go b/internal/api/download.go index 5fb90e9a6..601d584cf 100644 --- a/internal/api/download.go +++ b/internal/api/download.go @@ -13,6 +13,7 @@ import ( "github.com/photoprism/photoprism/internal/photoprism/get" "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" + "github.com/photoprism/photoprism/pkg/i18n" "github.com/photoprism/photoprism/pkg/rnd" ) @@ -73,7 +74,7 @@ func GetDownload(router *gin.RouterGroup) { f, err := query.FileByHash(id) if err != nil { - c.AbortWithStatusJSON(404, gin.H{"error": err.Error()}) + Abort(c, http.StatusNotFound, i18n.ErrFileNotFound) return } diff --git a/internal/api/download_test.go b/internal/api/download_test.go index 7d7d9b5a4..c34ec9bbf 100644 --- a/internal/api/download_test.go +++ b/internal/api/download_test.go @@ -51,7 +51,7 @@ func TestGetDownload(t *testing.T) { GetDownload(router) r := PerformRequest(app, "GET", "/api/v1/dl/123xxx?t="+conf.DownloadToken()) val := gjson.Get(r.Body.String(), "error") - assert.Equal(t, "record not found", val.String()) + assert.Equal(t, "File not found", val.String()) assert.Equal(t, http.StatusNotFound, r.Code) }) t.Run("MissingOriginal", func(t *testing.T) { diff --git a/internal/api/labels.go b/internal/api/labels.go index c7f23753d..261ace7cd 100644 --- a/internal/api/labels.go +++ b/internal/api/labels.go @@ -108,7 +108,7 @@ func LikeLabel(router *gin.RouterGroup) { label, err := query.LabelByUID(id) if err != nil { - c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": txt.UpperFirst(err.Error())}) + Abort(c, http.StatusNotFound, i18n.ErrLabelNotFound) return } @@ -151,7 +151,7 @@ func DislikeLabel(router *gin.RouterGroup) { label, err := query.LabelByUID(id) if err != nil { - c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": txt.UpperFirst(err.Error())}) + Abort(c, http.StatusNotFound, i18n.ErrLabelNotFound) return } diff --git a/internal/api/links.go b/internal/api/links.go index 34c5dd0fa..f293052b1 100644 --- a/internal/api/links.go +++ b/internal/api/links.go @@ -12,7 +12,6 @@ import ( "github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/i18n" - "github.com/photoprism/photoprism/pkg/txt" ) // UpdateLink updates a share link and return it as JSON. @@ -53,13 +52,13 @@ func UpdateLink(c *gin.Context) { if frm.Password != "" { if err := link.SetPassword(frm.Password); err != nil { - c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": txt.UpperFirst(err.Error())}) + Abort(c, http.StatusConflict, i18n.ErrSaveFailed) return } } if err := link.Save(); err != nil { - c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": txt.UpperFirst(err.Error())}) + Abort(c, http.StatusConflict, i18n.ErrSaveFailed) return } @@ -84,7 +83,7 @@ func DeleteLink(c *gin.Context) { link := entity.FindLink(clean.Token(c.Param("link"))) if err := link.Delete(); err != nil { - c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": txt.UpperFirst(err.Error())}) + Abort(c, http.StatusConflict, i18n.ErrDeleteFailed) return } @@ -135,13 +134,13 @@ func CreateLink(c *gin.Context) { if frm.Password != "" { if err := link.SetPassword(frm.Password); err != nil { - c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": txt.UpperFirst(err.Error())}) + Abort(c, http.StatusConflict, i18n.ErrSaveFailed) return } } if err := link.Save(); err != nil { - c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": txt.UpperFirst(err.Error())}) + Abort(c, http.StatusConflict, i18n.ErrSaveFailed) return } diff --git a/internal/api/photo_label.go b/internal/api/photo_label.go index f9604945e..6b8bcb7ed 100644 --- a/internal/api/photo_label.go +++ b/internal/api/photo_label.go @@ -163,7 +163,7 @@ func RemovePhotoLabel(router *gin.RouterGroup) { labelId, err := strconv.Atoi(clean.Token(c.Param("id"))) if err != nil { - c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": txt.UpperFirst(err.Error())}) + Abort(c, http.StatusNotFound, i18n.ErrLabelNotFound) return } @@ -175,7 +175,7 @@ func RemovePhotoLabel(router *gin.RouterGroup) { label, err := query.PhotoLabel(m.ID, uint(labelId)) if err != nil { - c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": txt.UpperFirst(err.Error())}) + Abort(c, http.StatusNotFound, i18n.ErrLabelNotFound) return } @@ -255,7 +255,7 @@ func UpdatePhotoLabel(router *gin.RouterGroup) { labelId, err := strconv.Atoi(clean.Token(c.Param("id"))) if err != nil { - c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": txt.UpperFirst(err.Error())}) + Abort(c, http.StatusNotFound, i18n.ErrLabelNotFound) return } @@ -267,7 +267,7 @@ func UpdatePhotoLabel(router *gin.RouterGroup) { label, err := query.PhotoLabel(m.ID, uint(labelId)) if err != nil { - c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": txt.UpperFirst(err.Error())}) + Abort(c, http.StatusNotFound, i18n.ErrLabelNotFound) return } diff --git a/internal/api/photo_label_test.go b/internal/api/photo_label_test.go index 1414bb05e..a2bade9b1 100644 --- a/internal/api/photo_label_test.go +++ b/internal/api/photo_label_test.go @@ -84,7 +84,7 @@ func TestRemovePhotoLabel(t *testing.T) { RemovePhotoLabel(router) r := PerformRequest(app, "DELETE", "/api/v1/photos/ps6sg6be2lvl0yh7/label/1000000") val := gjson.Get(r.Body.String(), "error") - assert.Equal(t, "Record not found", val.String()) + assert.Equal(t, "Label not found", val.String()) assert.Equal(t, http.StatusNotFound, r.Code) }) t.Run("NotExistingPhoto", func(t *testing.T) { diff --git a/internal/api/places_search.go b/internal/api/places_search.go index c98c45837..a473954e6 100644 --- a/internal/api/places_search.go +++ b/internal/api/places_search.go @@ -24,7 +24,7 @@ import ( // @Param locale query string false "Locale for results (default: en)" // @Param count query int false "Maximum number of results (default: 10, max: 50)" // @Success 200 {object} places.SearchResults -// @Failure 400 {object} gin.H "Missing search query" +// @Failure 400 {object} i18n.Response // @Failure 401 {object} i18n.Response // @Failure 500 {object} gin.H "Search service error" // @Router /api/v1/places/search [get] @@ -52,7 +52,7 @@ func GetPlacesSearch(router *gin.RouterGroup) { count := txt.IntVal(c.Query("count"), 1, 50, 10) if query == "" { - c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Missing search query"}) + AbortBadRequest(c) return } diff --git a/internal/api/swagger.json b/internal/api/swagger.json index 1ddf98d18..17e47c2ac 100644 --- a/internal/api/swagger.json +++ b/internal/api/swagger.json @@ -3435,8 +3435,15 @@ "error": { "type": "string" }, + "id": { + "type": "string" + }, "message": { "type": "string" + }, + "params": { + "items": {}, + "type": "array" } }, "type": "object" @@ -11008,9 +11015,9 @@ } }, "400": { - "description": "Missing search query", + "description": "Bad Request", "schema": { - "$ref": "#/definitions/gin.H" + "$ref": "#/definitions/i18n.Response" } }, "401": { diff --git a/pkg/i18n/response.go b/pkg/i18n/response.go index 39f70208f..35e815b51 100644 --- a/pkg/i18n/response.go +++ b/pkg/i18n/response.go @@ -3,11 +3,17 @@ package i18n import "strings" // Response represents an i18n-aware response payload. +// +// Err/Msg carry the server-rendered string in the instance locale (a fallback for non-browser +// consumers); ID and Params carry the untranslated source id and its parameters so the Web UI can +// render the message in each user's current UI locale. type Response struct { Code int `json:"code"` Err string `json:"error,omitempty"` Msg string `json:"message,omitempty"` Details string `json:"details,omitempty"` + ID string `json:"id,omitempty"` + Params []any `json:"params,omitempty"` } func (r Response) String() string { @@ -33,10 +39,14 @@ func (r Response) Success() bool { } // NewResponse builds a Response with the given code, message ID, and optional parameters. +// It carries the untranslated source string (ID) and Params so the frontend can render the message +// in the current UI locale, in addition to the server-rendered Err/Msg fallback. func NewResponse(code int, id Message, params ...any) Response { + r := Response{Code: code, ID: Source(id), Params: params} if code < 400 { - return Response{Code: code, Msg: Msg(id, params...)} + r.Msg = Msg(id, params...) } else { - return Response{Code: code, Err: Msg(id, params...)} + r.Err = Msg(id, params...) } + return r } diff --git a/pkg/i18n/response_test.go b/pkg/i18n/response_test.go index bfddc686e..58c5dece9 100644 --- a/pkg/i18n/response_test.go +++ b/pkg/i18n/response_test.go @@ -14,35 +14,39 @@ func TestNewResponse(t *testing.T) { assert.Equal(t, http.StatusConflict, resp.Code) assert.Equal(t, "A cat already exists", resp.Err) assert.Equal(t, "", resp.Msg) + assert.Equal(t, "%s already exists", resp.ID) + assert.Equal(t, []any{"A cat"}, resp.Params) }) t.Run("UnexpectedError", func(t *testing.T) { resp := NewResponse(http.StatusInternalServerError, ErrUnexpected, "A cat") assert.Equal(t, http.StatusInternalServerError, resp.Code) assert.Equal(t, "Something went wrong, try again", resp.Err) assert.Equal(t, "", resp.Msg) + assert.Equal(t, "Something went wrong, try again", resp.ID) }) t.Run("ChangesSaved", func(t *testing.T) { resp := NewResponse(http.StatusOK, MsgChangesSaved) assert.Equal(t, http.StatusOK, resp.Code) assert.Equal(t, "", resp.Err) assert.Equal(t, "Changes successfully saved", resp.Msg) + assert.Equal(t, "Changes successfully saved", resp.ID) if s, err := json.Marshal(resp); err != nil { t.Fatal(err) } else { - assert.Equal(t, `{"code":200,"message":"Changes successfully saved"}`, string(s)) + assert.Equal(t, `{"code":200,"message":"Changes successfully saved","id":"Changes successfully saved"}`, string(s)) } }) } func TestResponse_String(t *testing.T) { t.Run("Error", func(t *testing.T) { - resp := Response{404, "Not found", "page not found", "xyz"} + resp := Response{Code: 404, Err: "Not found", Msg: "page not found", Details: "xyz"} assert.Equal(t, "Not found", resp.String()) }) t.Run("NoError", func(t *testing.T) { t.Run("Error", func(t *testing.T) { - resp := Response{200, "", "Ok", "xyz"} + resp := Response{Code: 200, Msg: "Ok", Details: "xyz"} assert.Equal(t, "Ok", resp.String()) }) }) @@ -50,12 +54,12 @@ func TestResponse_String(t *testing.T) { func TestResponse_LowerString(t *testing.T) { t.Run("Error", func(t *testing.T) { - resp := Response{404, "Not found", "page not found", "xyz"} + resp := Response{Code: 404, Err: "Not found", Msg: "page not found", Details: "xyz"} assert.Equal(t, "not found", resp.LowerString()) }) t.Run("NoError", func(t *testing.T) { t.Run("Error", func(t *testing.T) { - resp := Response{200, "", "Ok", "xyz"} + resp := Response{Code: 200, Msg: "Ok", Details: "xyz"} assert.Equal(t, "ok", resp.LowerString()) }) }) @@ -63,12 +67,12 @@ func TestResponse_LowerString(t *testing.T) { func TestResponse_Error(t *testing.T) { t.Run("Error", func(t *testing.T) { - resp := Response{404, "Not found", "page not found", "xyz"} + resp := Response{Code: 404, Err: "Not found", Msg: "page not found", Details: "xyz"} assert.Equal(t, "Not found", resp.Error()) }) t.Run("NoError", func(t *testing.T) { t.Run("Error", func(t *testing.T) { - resp := Response{200, "", "Ok", "xyz"} + resp := Response{Code: 200, Msg: "Ok", Details: "xyz"} assert.Equal(t, "", resp.Error()) }) }) @@ -76,12 +80,12 @@ func TestResponse_Error(t *testing.T) { func TestResponse_Success(t *testing.T) { t.Run("Error", func(t *testing.T) { - resp := Response{404, "Not found", "page not found", "xyz"} + resp := Response{Code: 404, Err: "Not found", Msg: "page not found", Details: "xyz"} assert.Equal(t, false, resp.Success()) }) t.Run("NoError", func(t *testing.T) { t.Run("Error", func(t *testing.T) { - resp := Response{200, "", "Ok", "xyz"} + resp := Response{Code: 200, Msg: "Ok", Details: "xyz"} assert.Equal(t, true, resp.Success()) }) })