mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +00:00
FIX - complete phone locale fallback
Resolve configured and regional locales through a centralized English fallback for Lua and NUI consumers. Complete the German and English app translation keys, localize remaining picker and attachment copy, and add contract tests that detect missing locale entries.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import emojiData from 'emoji-picker-element-data/en/emojibase/data.json'
|
||||
import deEmojiData from 'emoji-picker-element-data/de/cldr/data.json'
|
||||
import enEmojiData from 'emoji-picker-element-data/en/cldr/data.json'
|
||||
import { Search, X } from 'lucide-vue-next'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
@@ -25,10 +26,13 @@ const activeGroup = ref(0)
|
||||
const skinTone = ref(0)
|
||||
const groups = ['😀', '👋', '🧑', '🐻', '🍔', '⚽', '🚗', '💡', '❤️', '🏳️']
|
||||
const skinTones = ['✋', '✋🏻', '✋🏼', '✋🏽', '✋🏾', '✋🏿']
|
||||
const localizedEmojiData = computed(() =>
|
||||
phone.lang.toLocaleLowerCase().startsWith('de') ? deEmojiData : enEmojiData,
|
||||
)
|
||||
|
||||
const emojis = computed(() => {
|
||||
const needle = query.value.trim().toLocaleLowerCase(phone.lang)
|
||||
return (emojiData as EmojiEntry[])
|
||||
return (localizedEmojiData.value as EmojiEntry[])
|
||||
.filter((entry) => {
|
||||
if (!needle) return entry.group === activeGroup.value
|
||||
return `${entry.annotation} ${entry.shortcodes?.join(' ') ?? ''} ${entry.tags?.join(' ') ?? ''}`
|
||||
@@ -40,17 +44,24 @@ const emojis = computed(() => {
|
||||
emoji:
|
||||
skinTone.value === 0
|
||||
? entry.emoji
|
||||
: entry.skins?.find((skin) => skin.tone === skinTone.value)?.emoji ??
|
||||
entry.emoji,
|
||||
: (entry.skins?.find((skin) => skin.tone === skinTone.value)?.emoji ??
|
||||
entry.emoji),
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="messages-full-emoji-picker" aria-label="Emoji picker">
|
||||
<section
|
||||
class="messages-full-emoji-picker"
|
||||
:aria-label="phone.t('Apps.messages.emojiPicker')"
|
||||
>
|
||||
<header>
|
||||
<strong>{{ phone.t('Apps.messages.emoji') }}</strong>
|
||||
<button type="button" :aria-label="phone.t('Common.done')" @click="emit('close')">
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t('Common.done')"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<X :size="18" />
|
||||
</button>
|
||||
</header>
|
||||
@@ -63,7 +74,7 @@ const emojis = computed(() => {
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
<nav v-if="!query" aria-label="Emoji categories">
|
||||
<nav v-if="!query" :aria-label="phone.t('Apps.messages.emojiCategories')">
|
||||
<button
|
||||
v-for="(group, index) in groups"
|
||||
:key="group"
|
||||
@@ -79,7 +90,7 @@ const emojis = computed(() => {
|
||||
:key="tone"
|
||||
type="button"
|
||||
:class="{ active: skinTone === index }"
|
||||
:title="`Skin tone ${index}`"
|
||||
:title="phone.t('Apps.messages.skinTone', { number: String(index) })"
|
||||
@click="skinTone = index"
|
||||
>
|
||||
{{ tone }}
|
||||
|
||||
@@ -32,12 +32,12 @@ const videoStyles: Record<string, string> = {
|
||||
'ocean-loop': 'linear-gradient(145deg, #0b132b, #26648e 55%, #67d5b5)',
|
||||
'sunset-loop': 'linear-gradient(145deg, #141e30, #5f2c82 55%, #ff9a62)',
|
||||
}
|
||||
const gifContent: Record<string, { emoji: string; label: string }> = {
|
||||
celebrate: { emoji: '🎉', label: 'Celebrate!' },
|
||||
hearts: { emoji: '💖', label: 'Love it' },
|
||||
party: { emoji: '🥳', label: 'Party time' },
|
||||
thumbs_up: { emoji: '👍', label: 'Perfect' },
|
||||
wow: { emoji: '🤯', label: 'WOW' },
|
||||
const gifContent: Record<string, { emoji: string; key: string }> = {
|
||||
celebrate: { emoji: '🎉', key: 'celebrate' },
|
||||
hearts: { emoji: '💖', key: 'hearts' },
|
||||
party: { emoji: '🥳', key: 'party' },
|
||||
thumbs_up: { emoji: '👍', key: 'thumbsUp' },
|
||||
wow: { emoji: '🤯', key: 'wow' },
|
||||
}
|
||||
|
||||
const background = computed(() =>
|
||||
@@ -139,13 +139,13 @@ function durationLabel(milliseconds: number | null): string {
|
||||
<img
|
||||
v-if="mediaUrl"
|
||||
:src="mediaUrl"
|
||||
alt="GIF"
|
||||
:alt="phone.t('Apps.messages.gif')"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
<template v-else>
|
||||
<span>{{ gif.emoji }}</span>
|
||||
<strong>{{ gif.label }}</strong>
|
||||
<strong>{{ phone.t(`Apps.messages.gifLabels.${gif.key}`) }}</strong>
|
||||
</template>
|
||||
</div>
|
||||
<p v-if="message.body?.trim()" class="messages-attachment-caption">
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { join, relative } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import ts from 'typescript'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
type LuaToken = {
|
||||
kind: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const frontendSourceDirectory = fileURLToPath(new URL('../', import.meta.url))
|
||||
const englishLocaleSource = readFileSync(
|
||||
new URL('../../../sky_phone/config/locales/en.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const germanLocaleSource = readFileSync(
|
||||
new URL('../../../sky_phone/config/locales/de.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const phoneStoreSource = readFileSync(
|
||||
new URL('./phone.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
function tokenizeLua(source: string): LuaToken[] {
|
||||
const tokens: LuaToken[] = []
|
||||
let index = 0
|
||||
while (index < source.length) {
|
||||
const character = source[index]
|
||||
if (/\s/.test(character)) {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
if (source.startsWith('--', index)) {
|
||||
const nextLine = source.indexOf('\n', index)
|
||||
if (nextLine < 0) break
|
||||
index = nextLine
|
||||
continue
|
||||
}
|
||||
if ('{}[]=,;'.includes(character)) {
|
||||
tokens.push({ kind: character, value: character })
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
if (character === '"' || character === "'") {
|
||||
const quote = character
|
||||
let value = ''
|
||||
index += 1
|
||||
while (index < source.length && source[index] !== quote) {
|
||||
if (source[index] === '\\' && index + 1 < source.length) {
|
||||
value += source[index + 1]
|
||||
index += 2
|
||||
} else {
|
||||
value += source[index]
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
tokens.push({ kind: 'string', value })
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
const match = source
|
||||
.slice(index)
|
||||
.match(/^[A-Za-z_][A-Za-z0-9_]*|^-?\d+(?:\.\d+)?/)
|
||||
if (match) {
|
||||
tokens.push({ kind: 'word', value: match[0] })
|
||||
index += match[0].length
|
||||
continue
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
function collectLuaLocalePaths(source: string): Set<string> {
|
||||
const tokens = tokenizeLua(source)
|
||||
let position =
|
||||
tokens.findIndex(
|
||||
(token, index) => token.kind === '=' && tokens[index + 1]?.kind === '{',
|
||||
) + 1
|
||||
const paths = new Set<string>()
|
||||
|
||||
function parseValue(path: string[]): void {
|
||||
if (tokens[position]?.kind === '{') {
|
||||
parseTable(path)
|
||||
return
|
||||
}
|
||||
if (path.length) paths.add(path.join('.'))
|
||||
position += 1
|
||||
}
|
||||
|
||||
function parseTable(path: string[]): void {
|
||||
position += 1
|
||||
while (position < tokens.length && tokens[position].kind !== '}') {
|
||||
let key: string | null = null
|
||||
if (
|
||||
tokens[position].kind === 'word' &&
|
||||
tokens[position + 1]?.kind === '='
|
||||
) {
|
||||
key = tokens[position].value
|
||||
position += 2
|
||||
} else if (
|
||||
tokens[position].kind === '[' &&
|
||||
tokens[position + 1]?.kind === 'string' &&
|
||||
tokens[position + 2]?.kind === ']' &&
|
||||
tokens[position + 3]?.kind === '='
|
||||
) {
|
||||
key = tokens[position + 1].value
|
||||
position += 4
|
||||
}
|
||||
parseValue(key === null ? [] : [...path, key])
|
||||
while (tokens[position]?.kind === ',' || tokens[position]?.kind === ';') {
|
||||
position += 1
|
||||
}
|
||||
}
|
||||
position += 1
|
||||
}
|
||||
|
||||
parseTable([])
|
||||
return paths
|
||||
}
|
||||
|
||||
function collectDefaultLocalePaths(source: string): Set<string> {
|
||||
const ast = ts.createSourceFile(
|
||||
'phone.ts',
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TS,
|
||||
)
|
||||
const declarations = new Map<string, ts.Expression>()
|
||||
const registerDeclarations = (node: ts.Node): void => {
|
||||
if (
|
||||
ts.isVariableDeclaration(node) &&
|
||||
ts.isIdentifier(node.name) &&
|
||||
node.initializer
|
||||
) {
|
||||
declarations.set(node.name.text, node.initializer)
|
||||
}
|
||||
ts.forEachChild(node, registerDeclarations)
|
||||
}
|
||||
registerDeclarations(ast)
|
||||
|
||||
const paths = new Set<string>()
|
||||
const collect = (node: ts.Expression, path: string[]): void => {
|
||||
if (
|
||||
ts.isParenthesizedExpression(node) ||
|
||||
ts.isAsExpression(node) ||
|
||||
ts.isSatisfiesExpression(node)
|
||||
) {
|
||||
collect(node.expression, path)
|
||||
return
|
||||
}
|
||||
if (ts.isIdentifier(node) && declarations.has(node.text)) {
|
||||
collect(declarations.get(node.text)!, path)
|
||||
return
|
||||
}
|
||||
if (!ts.isObjectLiteralExpression(node)) {
|
||||
if (path.length) paths.add(path.join('.'))
|
||||
return
|
||||
}
|
||||
for (const property of node.properties) {
|
||||
if (ts.isSpreadAssignment(property)) {
|
||||
collect(property.expression, path)
|
||||
continue
|
||||
}
|
||||
if (!ts.isPropertyAssignment(property)) continue
|
||||
const name = property.name
|
||||
const key =
|
||||
ts.isIdentifier(name) ||
|
||||
ts.isStringLiteral(name) ||
|
||||
ts.isNumericLiteral(name)
|
||||
? name.text
|
||||
: null
|
||||
if (key !== null) collect(property.initializer, [...path, key])
|
||||
}
|
||||
}
|
||||
|
||||
const defaultLocales = declarations.get('defaultLocales')
|
||||
expect(defaultLocales).toBeDefined()
|
||||
collect(defaultLocales!, [])
|
||||
return paths
|
||||
}
|
||||
|
||||
function collectFrontendFiles(directory: string): string[] {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(directory, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
return entry.name === 'development' ? [] : collectFrontendFiles(path)
|
||||
}
|
||||
return /\.(?:ts|vue)$/.test(entry.name) && !entry.name.includes('.test.')
|
||||
? [path]
|
||||
: []
|
||||
})
|
||||
}
|
||||
|
||||
describe('phone locale contract', () => {
|
||||
const englishPaths = collectLuaLocalePaths(englishLocaleSource)
|
||||
|
||||
it('keeps every bundled frontend fallback in en.lua', () => {
|
||||
const missing = [...collectDefaultLocalePaths(phoneStoreSource)].filter(
|
||||
(path) => !englishPaths.has(`Nui.${path}`),
|
||||
)
|
||||
|
||||
expect(missing).toEqual([])
|
||||
})
|
||||
|
||||
it('defines every static frontend translation key in en.lua', () => {
|
||||
const missing: string[] = []
|
||||
for (const file of collectFrontendFiles(frontendSourceDirectory)) {
|
||||
const source = readFileSync(file, 'utf8')
|
||||
for (const match of source.matchAll(
|
||||
/phone\.t\(\s*(['"])([^'"`]+)\1\s*[,)]/g,
|
||||
)) {
|
||||
if (!englishPaths.has(`Nui.${match[2]}`)) {
|
||||
missing.push(
|
||||
`${relative(frontendSourceDirectory, file)}: ${match[2]}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(missing).toEqual([])
|
||||
})
|
||||
|
||||
it('builds German on the complete English locale fallback', () => {
|
||||
expect(germanLocaleSource).toContain(
|
||||
'local german = translate_shared(clone(Locales["en"] or {}))',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -51,9 +51,7 @@ describe('phone locale fallback', () => {
|
||||
expect(phone.t('Apps.radio.providerFeatureUnavailable')).toBe(
|
||||
'Not supported by this voice service',
|
||||
)
|
||||
expect(phone.t('Apps.radio.displayNamePlaceholder')).toBe(
|
||||
'Character name',
|
||||
)
|
||||
expect(phone.t('Apps.radio.displayNamePlaceholder')).toBe('Character name')
|
||||
})
|
||||
|
||||
it('keeps Messages media controls translated with a partial server locale', () => {
|
||||
@@ -71,4 +69,21 @@ describe('phone locale fallback', () => {
|
||||
)
|
||||
expect(phone.t('Apps.messages.seekAudio')).toBe('Seek Audio')
|
||||
})
|
||||
|
||||
it('uses the English Lua payload before the bundled emergency fallback', () => {
|
||||
const phone = usePhoneStore()
|
||||
phone.open({
|
||||
fallbackLocales: {
|
||||
Common: {
|
||||
cancel: 'Cancel from en.lua',
|
||||
save: 'Save from en.lua',
|
||||
},
|
||||
},
|
||||
locales: { Common: { save: 'Speichern' } },
|
||||
})
|
||||
|
||||
expect(phone.t('Common.save')).toBe('Speichern')
|
||||
expect(phone.t('Common.cancel')).toBe('Cancel from en.lua')
|
||||
expect(phone.t('Common.close')).toBe('Close')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,6 +33,7 @@ export type PasscodeResponseData = {
|
||||
export type PhoneOpenPayload = {
|
||||
account?: DeviceBootstrap['account']
|
||||
device?: PhoneDevice
|
||||
fallbackLocales?: LocaleTree
|
||||
lang?: string
|
||||
locales?: LocaleTree
|
||||
memos?: DeviceBootstrap['memos']
|
||||
@@ -1887,6 +1888,16 @@ const defaultLocales: LocaleTree = {
|
||||
contactDeleteFailed: 'The contact could not be deleted.',
|
||||
callFailed: 'The call could not be started.',
|
||||
emoji: 'Emoji',
|
||||
emojiPicker: 'Emoji picker',
|
||||
emojiCategories: 'Emoji categories',
|
||||
skinTone: 'Skin tone {number}',
|
||||
gifLabels: {
|
||||
celebrate: 'Celebrate!',
|
||||
hearts: 'Love it',
|
||||
party: 'Party time',
|
||||
thumbsUp: 'Perfect',
|
||||
wow: 'WOW',
|
||||
},
|
||||
voiceMessage: 'Audio Message',
|
||||
recordVoice: 'Record Audio',
|
||||
playAudio: 'Play Audio',
|
||||
@@ -2127,6 +2138,7 @@ const defaultLocales: LocaleTree = {
|
||||
takePhoto: 'Camera',
|
||||
removePhoto: 'Remove Photo',
|
||||
call: 'Call',
|
||||
callFailed: 'The call could not be started.',
|
||||
message: 'Message',
|
||||
video: 'Video',
|
||||
mail: 'Mail',
|
||||
@@ -4671,6 +4683,7 @@ export const usePhoneStore = defineStore('phone', {
|
||||
isOpen: false,
|
||||
lang: 'en',
|
||||
launchOrigin: null as AppLaunchOrigin | null,
|
||||
fallbackLocales: defaultLocales,
|
||||
locales: defaultLocales,
|
||||
preferences: cloneJsonData(DEFAULT_PHONE_PREFERENCES),
|
||||
persistenceGeneration: 0,
|
||||
@@ -4709,7 +4722,8 @@ export const usePhoneStore = defineStore('phone', {
|
||||
}
|
||||
this.deviceSessionToken = nextToken
|
||||
this.lang = payload.lang ?? 'en'
|
||||
this.locales = payload.locales ?? defaultLocales
|
||||
this.fallbackLocales = payload.fallbackLocales ?? defaultLocales
|
||||
this.locales = payload.locales ?? this.fallbackLocales
|
||||
if (payload.device) this.hydrateDevice(payload.device)
|
||||
if (payload.player) this.player = payload.player
|
||||
this.security = payload.security ?? {
|
||||
@@ -4941,13 +4955,16 @@ export const usePhoneStore = defineStore('phone', {
|
||||
},
|
||||
t(path: string, replacements: Record<string, string> = {}): string {
|
||||
const translated = getByPath(this.locales, path)
|
||||
const fallback = getByPath(defaultLocales, path)
|
||||
const english = getByPath(this.fallbackLocales, path)
|
||||
const emergencyFallback = getByPath(defaultLocales, path)
|
||||
const value =
|
||||
typeof translated === 'string'
|
||||
? translated
|
||||
: typeof fallback === 'string'
|
||||
? fallback
|
||||
: path
|
||||
: typeof english === 'string'
|
||||
? english
|
||||
: typeof emergencyFallback === 'string'
|
||||
? emergencyFallback
|
||||
: path
|
||||
return Object.entries(replacements).reduce(
|
||||
(result, [key, replacement]) =>
|
||||
result.split(`{${key}}`).join(replacement),
|
||||
|
||||
Reference in New Issue
Block a user