UX: Render backend error responses in the current UI locale #5682

The i18n.Response envelope now carries the untranslated source id and params; the frontend (api.js) renders them via Tp in the user's UI locale, with the server-rendered error/message as the instance-locale fallback. Common not-found/conflict/bad-request handlers were switched from hardcoded literals and raw err.Error() to Abort/i18n.Message helpers, so they benefit automatically.
This commit is contained in:
Michael Mayer 2026-06-23 12:06:49 +00:00
parent 49c6535c81
commit f5373ccec6
11 changed files with 56 additions and 32 deletions

View file

@ -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;

View file

@ -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
}

View file

@ -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) {

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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) {

View file

@ -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
}

View file

@ -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": {

View file

@ -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
}

View file

@ -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())
})
})