mirror of
https://github.com/photoprism/photoprism.git
synced 2026-07-17 16:49:04 +00:00
UX: Render backend notifications in the current UI locale #5682
Backend *Msg helpers now publish the untranslated message id and params alongside the rendered fallback; the frontend translates the id and applies a positional-placeholder shim so notifications render in each user's UI language.
This commit is contained in:
parent
544a4cfef2
commit
9ecdb0ae26
7 changed files with 177 additions and 6 deletions
|
|
@ -21,6 +21,35 @@ function interpolate(message, params = {}) {
|
|||
});
|
||||
}
|
||||
|
||||
// interpolatePositional substitutes Go printf verbs (%s, %d, %v, %%, …) sequentially from an
|
||||
// ordered params array, bridging the backend's positional placeholders to the frontend runtime.
|
||||
function interpolatePositional(message, params) {
|
||||
if (message === null || message === undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const text = String(message);
|
||||
|
||||
if (!Array.isArray(params) || params.length === 0) {
|
||||
return text;
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
|
||||
return text.replace(/%(%|[-+ 0#]*\d*(?:\.\d+)?[a-zA-Z])/g, (match, verb) => {
|
||||
if (verb === "%") {
|
||||
return "%";
|
||||
}
|
||||
|
||||
if (i >= params.length) {
|
||||
return match;
|
||||
}
|
||||
|
||||
const value = params[i++];
|
||||
return value === undefined || value === null ? "" : String(value);
|
||||
});
|
||||
}
|
||||
|
||||
export let gettext = {
|
||||
$gettext: (msgid, params) => interpolate(msgid, params),
|
||||
$ngettext: (msgid, plural, n, params) => interpolate(n > 1 ? plural : msgid, params),
|
||||
|
|
@ -32,6 +61,12 @@ export function T(msgid, params) {
|
|||
return gettext.$gettext(msgid, params);
|
||||
}
|
||||
|
||||
// Tp translates a backend message by its English source id, then substitutes the ordered
|
||||
// positional params, so backend notifications render in the current UI language.
|
||||
export function Tp(msgid, params) {
|
||||
return interpolatePositional(gettext.$gettext(msgid), params);
|
||||
}
|
||||
|
||||
export function $gettext(msgid, params) {
|
||||
return gettext.$gettext(msgid, params);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@
|
|||
</teleport>
|
||||
</template>
|
||||
<script>
|
||||
import { Tp } from "common/gettext";
|
||||
|
||||
export default {
|
||||
name: "PNotify",
|
||||
data() {
|
||||
|
|
@ -59,8 +61,9 @@ export default {
|
|||
onNotify(ev, data) {
|
||||
const type = ev.split(".")[1];
|
||||
|
||||
// Get the message.
|
||||
let m = data.message;
|
||||
// Render in the current UI language when the backend sent a message id (the English source
|
||||
// string); otherwise fall back to the pre-rendered message string as-is.
|
||||
let m = data.id ? Tp(data.id, data.params) : data.message;
|
||||
|
||||
// Skip empty messages.
|
||||
if (!m || !m.length) {
|
||||
|
|
|
|||
39
frontend/tests/vitest/common/gettext.test.js
Normal file
39
frontend/tests/vitest/common/gettext.test.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// Targets the positional-interpolation bridge in common/gettext.js (Tp), which renders backend
|
||||
// notification messages — addressed by their English source id with Go printf placeholders — in
|
||||
// the current UI language. The vue3-gettext runtime itself is third-party and not retested here.
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { Tp } from "common/gettext";
|
||||
|
||||
describe("common/gettext", () => {
|
||||
describe("Tp", () => {
|
||||
it("substitutes a single %s placeholder", () => {
|
||||
expect(Tp("Indexing files in %s", ["/photos"])).toBe("Indexing files in /photos");
|
||||
});
|
||||
it("substitutes a single %d placeholder", () => {
|
||||
expect(Tp("Indexing completed in %d s", [5])).toBe("Indexing completed in 5 s");
|
||||
});
|
||||
it("substitutes multiple ordered placeholders of mixed type", () => {
|
||||
expect(Tp("%d entries added to %s", [3, "Holiday"])).toBe("3 entries added to Holiday");
|
||||
});
|
||||
it("substitutes repeated same-type placeholders positionally", () => {
|
||||
expect(Tp("Removed %d files and %d photos", [10, 4])).toBe("Removed 10 files and 4 photos");
|
||||
});
|
||||
it("returns the message unchanged when there are no params", () => {
|
||||
expect(Tp("Album created", [])).toBe("Album created");
|
||||
expect(Tp("Album created")).toBe("Album created");
|
||||
});
|
||||
it("un-escapes %% to a literal percent and does not consume a param", () => {
|
||||
expect(Tp("100%% complete in %d s", [5])).toBe("100% complete in 5 s");
|
||||
});
|
||||
it("leaves a placeholder literal when its param is missing", () => {
|
||||
expect(Tp("Indexing files in %s", [])).toBe("Indexing files in %s");
|
||||
});
|
||||
it("renders an empty string for null or undefined param values", () => {
|
||||
expect(Tp("Indexing files in %s", [null])).toBe("Indexing files in ");
|
||||
});
|
||||
it("returns an empty string for a null message", () => {
|
||||
expect(Tp(null, [1])).toBe("");
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue