Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
226 changes: 180 additions & 46 deletions scripts/update-translations
Original file line number Diff line number Diff line change
Expand Up @@ -6,76 +6,210 @@ cd "$SCRIPTS_DIR/.."

# Directory containing .po files
LOCALES_DIR="$PWD/src/frontend/src/lib/locales"
MODEL="openai/gpt-5"
MAX_PARALLEL="${MAX_PARALLEL:-2}"
MAX_RETRIES="${MAX_RETRIES:-5}"
INITIAL_BACKOFF_SECONDS="${INITIAL_BACKOFF_SECONDS:-3}"
GITHUB_TOKEN="$(gh auth token)"

INFERENCE_URL="https://models.github.ai/inference/chat/completions"
echo "Using model: $MODEL"

INSTRUCTIONS="SYSTEM: You are a silent \`.po\` file translation bot.
1. You will receive a \`.po\` file with missing translations.
2. Analyze the existing translations in the file to determine the established tone (e.g., formal/informal 'you'), style, and terminology.
3. Use these established decisions to translate *only* the entries where \`msgstr\` is empty.
4. Ensure all variables (like {variable}) and tags (like <0>...</0>) are preserved in the translation.
5. Apply the correct ICU plural categories required by the target language (e.g., one/other, one/few/many/other, or just other).
6. Your output must be *only* the plain text \`.po\` entries (from msgid ... to msgstr \"...\") that you have just translated.
7. DO NOT output any other text, greetings, explanations, or markdown. Your response must be *only* the plain text code snippet."
5. Even if a language is RTL, you should still keep tags like <0>...</0> in the same order as in the source text, and not reverse them.
6. Apply the correct ICU plural categories required by the target language (e.g., one/other, one/few/many/other, or just other).
7. Your output must be *only* the plain text \`.po\` entries (from msgid ... to msgstr \"...\") that you have just translated.
8. DO NOT output any other text, greetings, explanations, or markdown. Your response must be *only* the plain text code snippet."

# Make sure .po files are up to date
echo "Updating .po files with latest changes"
npm run extract > /dev/null # No need to output here

# Loop through all .po files
for po_file in "$LOCALES_DIR"/*.po; do
if [[ -f "$po_file" ]]; then
po_filename=$(basename "$po_file")

# Skip source file
if [[ "$po_filename" == "en.po" ]]; then
echo -e "\nSkipping file: $po_filename"
continue
fi

# Get entries without header
po_content=$(<"$po_file")
without_header=$(echo "$po_content" | awk '
BEGIN { skip=0; entry="" }
/^msgid ""$/ { skip=1; entry="" ; next } # start skipping
/^msgid / { if (!skip) { printf "%s", entry }; entry=$0 "\n"; skip=0; next }
{ entry = entry $0 "\n" } # accumulate lines
END { if (!skip) printf "%s", entry } # print last entry if not skipped
')

# Skip files without missing translations
if ! grep -q '^msgstr ""$' <<< "$without_header"; then
echo -e "\nSkipping file: $po_filename"
continue;
fi

# Use npx to invoke the Gemini CLI to translate missing translations
echo -e "\nTranslating file: $po_filename"
translated_entries=$(npx https://github.com/google-gemini/gemini-cli -p "$INSTRUCTIONS\n\nThe $po_filename file content:\n$po_content")

# Merge existing and new translations, while making sure that
# existing translations take priority (remain unchanged).
already_translated=$(echo "$without_header" | awk '
# Function to translate a single .po file
translate_po_file() {
local po_file="$1"
local po_filename
po_filename=$(basename "$po_file")

# Skip source file
if [[ "$po_filename" == "en.po" ]]; then
echo -e "\nSkipping file: $po_filename"
return
fi

# Get entries without header
local po_content
po_content=$(<"$po_file")
local without_header
without_header=$(echo "$po_content" | awk '
BEGIN { skip=0; entry="" }
/^msgid ""$/ { skip=1; entry="" ; next } # start skipping
/^msgid / { if (!skip) { printf "%s", entry }; entry=$0 "\n"; skip=0; next }
{ entry = entry $0 "\n" } # accumulate lines
END { if (!skip) printf "%s", entry } # print last entry if not skipped
')

# Skip files without missing translations
if ! grep -q '^msgstr ""$' <<< "$without_header"; then
echo -e "\nSkipping file: $po_filename"
return
fi

# Extract untranslated entries (msgstr is empty)
local untranslated_entries
untranslated_entries=$(echo "$without_header" | awk '
BEGIN {entry=""}
{
entry = entry $0 "\n"
if ($0 ~ /^msgstr /) {
if ($0 == "msgstr \"\"") {
# Empty translation, skip entry
entry = ""
} else {
# Non-empty msgstr, print entry
printf "%s", entry
entry = ""
}
entry = ""
}
}
')
merged_content="$po_content"$'\n'"$translated_entries"$'\n'"$already_translated"

# Overwrite file with merged content
echo "$merged_content" > "$po_file"

# Extract a snippet of already translated entries for context (up to 20 entries)
local translated_snippet
translated_snippet=$(echo "$without_header" | awk '
BEGIN {entry=""; count=0}
{
entry = entry $0 "\n"
if ($0 ~ /^msgstr /) {
if ($0 != "msgstr \"\"" && count < 20) {
printf "%s", entry
count++
}
entry = ""
}
}
')

# Use GitHub Models API (via gh CLI token) to translate missing translations
echo -e "\nTranslating file: $po_filename"
local prompt
prompt="$INSTRUCTIONS

Existing translations from $po_filename (for style/tone reference):
$translated_snippet

Entries to translate:
$untranslated_entries"

local request_payload
request_payload=$(jq -n --arg sys "$INSTRUCTIONS" --arg user "$prompt" --arg model "$MODEL" \
'{model:$model,messages:[{role:"system",content:$sys},{role:"user",content:$user}]}')

local response
local api_error
local attempt=1
local backoff_seconds="$INITIAL_BACKOFF_SECONDS"

while true; do
local curl_output
local http_code
curl_output=$(curl -sS -w $'\n%{http_code}' "$INFERENCE_URL" \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-H "X-GitHub-Api-Version: 2022-11-28" \
-H "Content-Type: application/json" \
-d "$request_payload")

http_code="${curl_output##*$'\n'}"
response="${curl_output%$'\n'*}"

if ! echo "$response" | jq -e . >/dev/null 2>&1; then
local response_snippet
response_snippet=$(echo "$response" | head -c 300 | tr '\n' ' ')
if [[ "$response_snippet" == *"Too many requests"* ]] && (( attempt < MAX_RETRIES )); then
echo "Rate limited translating $po_filename (attempt $attempt/$MAX_RETRIES). Retrying in ${backoff_seconds}s..." >&2
sleep "$backoff_seconds"
backoff_seconds=$((backoff_seconds * 2))
attempt=$((attempt + 1))
continue
fi
echo "Failed translating $po_filename with model '$MODEL': non-JSON response from API (HTTP $http_code): $response_snippet" >&2
return 1
fi

api_error=$(echo "$response" | jq -r '.error.message // empty')
if [[ "$http_code" == "429" || "$api_error" == *"Too many requests"* || "$api_error" == *"rate limit"* ]]; then
if (( attempt < MAX_RETRIES )); then
echo "Rate limited translating $po_filename (attempt $attempt/$MAX_RETRIES). Retrying in ${backoff_seconds}s..." >&2
sleep "$backoff_seconds"
backoff_seconds=$((backoff_seconds * 2))
attempt=$((attempt + 1))
continue
fi
fi

if [[ -n "$api_error" ]]; then
echo "Failed translating $po_filename with model '$MODEL': $api_error" >&2
return 1
fi

break
done

local translated_entries
translated_entries=$(echo "$response" | jq -r '
(.choices[0].message.content // empty) as $content
| if ($content | type) == "string" then $content
elif ($content | type) == "array" then
[ $content[]? | select(.type == "text") | .text ] | join("")
else ""
end
')

if [[ -z "${translated_entries//[[:space:]]/}" ]]; then
echo "Failed translating $po_filename with model '$MODEL': empty response content" >&2
return 1
fi

# Merge existing and new translations, while making sure that
# existing translations take priority (remain unchanged).
local already_translated
already_translated=$(echo "$without_header" | awk '
BEGIN {entry=""}
{
entry = entry $0 "\n"
if ($0 ~ /^msgstr /) {
if ($0 == "msgstr \"\"") {
# Empty translation, skip entry
entry = ""
} else {
# Non-empty msgstr, print entry
printf "%s", entry
entry = ""
}
}
}
')
local merged_content
merged_content="$po_content"$'\n'"$translated_entries"$'\n'"$already_translated"

# Overwrite file with merged content
echo "$merged_content" > "$po_file"
}

# Loop through all .po files and translate in parallel
for po_file in "$LOCALES_DIR"/*.po; do
if [[ -f "$po_file" ]]; then
while (( $(jobs -rp | wc -l | tr -d ' ') >= MAX_PARALLEL )); do
wait -n
done
translate_po_file "$po_file" &
fi
done

# Wait for all background translation jobs to finish
wait

# Make sure .po files are cleaned up after adding the translations
echo -e "\nCleaning up .po files with latest translations"
npm run extract # We do not silence the output here so we can check if there are still missing translations
7 changes: 7 additions & 0 deletions src/frontend/src/hooks.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { authenticationStore } from "$lib/stores/authentication.store";
import { sessionStore } from "$lib/stores/session.store";
import { initGlobals, canisterId, agentOptions } from "$lib/globals";
import { localeStore } from "$lib/stores/locale.store";
import { getLocaleDirection } from "$lib/constants/locale.constants";

const FEATURE_FLAG_PREFIX = "feature_flag_";

Expand Down Expand Up @@ -50,5 +51,11 @@ export const init: ClientInit = async () => {
localeStore.init(),
sessionStore.init({ canisterId, agentOptions }),
]);

localeStore.subscribe((locale) => {
document.documentElement.lang = locale;
document.documentElement.dir = getLocaleDirection(locale);
});

authenticationStore.init({ canisterId, agentOptions });
};
2 changes: 1 addition & 1 deletion src/frontend/src/lib/components/layout/Header.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
]}
>
<div class="flex h-16 flex-1 items-center gap-4">
<a href="/" class="flex items-center gap-4">
<a href="/" dir="ltr" class="flex items-center gap-4">
<Logo class="text-fg-primary h-5.5" />
<h1 class="text-text-primary hidden text-base font-semibold sm:block">
Internet Identity
Expand Down
10 changes: 5 additions & 5 deletions src/frontend/src/lib/components/ui/IdentitySwitcher.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,8 @@
class={[
"bg-bg-primary_alt border-border-secondary absolute flex items-center justify-center rounded-full border",
size === "lg"
? "-right-1 -bottom-1 size-6.5"
: "-right-1.25 -bottom-1.25 size-5",
? "-end-1 -bottom-1 size-6.5"
: "-end-1.25 -bottom-1.25 size-5",
]}
>
{#if logo !== undefined}
Expand Down Expand Up @@ -254,9 +254,9 @@
{:else}
<ArrowRightIcon
class={[
"text-fg-tertiary ms-auto mr-1 size-5 opacity-0 transition-all duration-200",
"group-enabled:group-hover:mr-0 group-enabled:group-hover:opacity-100",
"group-enabled:group-focus-visible:mr-0 group-enabled:group-focus-visible:opacity-100",
"text-fg-tertiary ms-auto me-1 size-5 transform opacity-0 transition-all duration-200 rtl:-scale-x-100",
"group-enabled:group-hover:me-0 group-enabled:group-hover:opacity-100",
"group-enabled:group-focus-visible:me-0 group-enabled:group-focus-visible:opacity-100",
]}
/>
{/if}
Expand Down
30 changes: 26 additions & 4 deletions src/frontend/src/lib/components/ui/Popover.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,22 @@
let popoverRef = $state<HTMLElement>();
let windowWidth = $state(window.innerWidth);

const getInlineAlign = (align: Align): Align => {
if (document.documentElement.dir !== "rtl") {
return align;
}

if (align === "start") {
return "end";
}

if (align === "end") {
return "start";
}

return align;
};

$effect(() => {
let tracking = true;

Expand All @@ -42,6 +58,10 @@
const anchorRect = anchorRef.getBoundingClientRect();
const popoverRect = popoverRef.getBoundingClientRect();

popoverRef.style.inset = "auto";
popoverRef.style.right = "auto";
popoverRef.style.bottom = "auto";

// Available space around the anchor
const spaceAbove = anchorRect.top;
const spaceBelow = window.innerHeight - anchorRect.bottom;
Expand Down Expand Up @@ -102,18 +122,20 @@
}[finalDirection];

// Compute left position
const inlineAlign = getInlineAlign(align);

popoverRef.style.left = {
up: {
start: `${anchorRect.left}px`,
center: `${anchorRect.left + anchorRect.width * 0.5 - popoverRect.width * 0.5}px`,
end: `${anchorRect.right - popoverRect.width}px`,
}[align],
}[inlineAlign],
right: `calc(${anchorRect.right}px + ${distance})`,
down: {
start: `${anchorRect.left}px`,
center: `${anchorRect.left + anchorRect.width * 0.5 - popoverRect.width * 0.5}px`,
end: `${anchorRect.right - popoverRect.width}px`,
}[align],
}[inlineAlign],
left: `calc(${anchorRect.left - popoverRect.width}px - ${distance})`,
}[finalDirection];
}
Expand Down Expand Up @@ -180,7 +202,7 @@
start: "origin-bottom-left",
center: "origin-bottom",
end: "origin-bottom-right",
}[align],
}[getInlineAlign(align)],
right: {
start: "origin-top-left",
center: "origin-left",
Expand All @@ -190,7 +212,7 @@
start: "origin-top-left",
center: "origin-top",
end: "origin-top-right",
}[align],
}[getInlineAlign(align)],
left: {
start: "origin-top-right",
center: "origin-right",
Expand Down
2 changes: 1 addition & 1 deletion src/frontend/src/lib/components/ui/Toggle.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
"bg-bg-quaternary",
"peer-checked:bg-bg-brand-solid peer-checked:hover:bg-bg-brand-solid",
"after:block after:rounded-full after:bg-white after:shadow-sm after:transition-transform after:duration-200",
"dark:peer-checked:after:bg-fg-primary-inversed peer-checked:after:translate-x-[100%]",
"dark:peer-checked:after:bg-fg-primary-inversed peer-checked:after:translate-x-[100%] rtl:peer-checked:after:-translate-x-[100%]",
"peer-disabled:bg-bg-disabled peer-disabled:after:bg-surface-light-50 dark:peer-disabled:after:bg-surface-dark-600",
"peer-focus-visible:ring-focus-ring peer-focus-visible:ring-offset-bg-primary outline-none peer-focus-visible:ring-2 peer-focus-visible:ring-offset-2",
{
Expand Down
Loading
Loading