Skip to content

Commit ece1675

Browse files
committed
webui: add translation support
1 parent 30eb30d commit ece1675

11 files changed

Lines changed: 572 additions & 113 deletions

File tree

webui/index.html

Lines changed: 85 additions & 60 deletions
Large diffs are not rendered by default.

webui/index.js

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import '@material/web/all.js';
22
import { exec, toast } from 'kernelsu-alt';
33
import { setupRoute, navigateToHome } from './route.js';
4+
import { getString, loadTranslations } from './language.js';
45
import * as patchModule from './page/patch.js';
56
import * as kpmModule from './page/kpm.js';
67
import * as excludeModule from './page/exclude.js';
@@ -25,7 +26,7 @@ async function updateStatus() {
2526
document.querySelector('#superkey md-outlined-text-field').value = superkey;
2627
installedOnly.forEach(el => el.removeAttribute('hidden'));
2728
} else {
28-
versionText.textContent = 'Not installed';
29+
versionText.textContent = getString('status_not_installed');
2930
notInstalled.classList.remove('hidden');
3031
working.classList.add('hidden');
3132
installedOnly.forEach(el => el.setAttribute('hidden', ''));
@@ -97,12 +98,12 @@ function getMaxChunkSize() {
9798
}
9899

99100
export function linkRedirect(link) {
100-
toast("Redirecting to " + link);
101+
toast(getString('msg_redirecting_to', link));
101102
setTimeout(() => {
102103
exec(`am start -a android.intent.action.VIEW -d ${link}`)
103104
.then(({ errno }) => {
104105
if (errno !== 0) {
105-
toast("Failed to open link with exec");
106+
toast(getString('msg_failed_open_link'));
106107
window.open(link, "_blank");
107108
}
108109
});
@@ -116,6 +117,12 @@ document.addEventListener('DOMContentLoaded', async () => {
116117

117118
setupRoute();
118119

120+
// language
121+
const language = document.getElementById('language');
122+
const languageDialog = document.getElementById('language-dialog');
123+
language.onclick = () => languageDialog.show();
124+
languageDialog.querySelector('.cancel').onclick = () => languageDialog.close();
125+
119126
// visibility toggle for SuperKey text field
120127
document.querySelectorAll('.password-field').forEach(field => {
121128
const toggleBtn = field.querySelector('md-icon-button[toggle]');
@@ -185,11 +192,13 @@ document.addEventListener('DOMContentLoaded', async () => {
185192

186193
updateBtnState(superkey);
187194
getMaxChunkSize();
188-
excludeModule.initExcludePage();
189-
kpmModule.initKPMPage();
190195

196+
await loadTranslations();
191197
await Promise.all([updateStatus(), initInfo()]);
192198

199+
excludeModule.initExcludePage();
200+
kpmModule.initKPMPage();
201+
193202
// splash screen
194203
if (splash) {
195204
setTimeout(() => splash.classList.add('exit'), 50);

webui/language.js

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
const rtlLang = [
2+
'ar', // Arabic
3+
'fa', // Persian
4+
'he', // Hebrew
5+
'ur', // Urdu
6+
'ps', // Pashto
7+
'sd', // Sindhi
8+
'ku', // Kurdish
9+
'yi', // Yiddish
10+
'dv', // Dhivehi
11+
];
12+
13+
let translations = {};
14+
let baseTranslations = {};
15+
let availableLanguages = ['en'];
16+
let languageNames = {};
17+
18+
/**
19+
* Get a formatted string based on the language key and optional arguments
20+
* Supported formats: %s, %d, %f, %x, %1$s, %2$d, etc.
21+
* @param {string} id - The translation key
22+
* @param {...any} args - Arguments to format into the string
23+
* @returns {string} - The formatted translation
24+
*/
25+
export function getString(id, ...args) {
26+
let translation = translations[id] || (baseTranslations && baseTranslations[id]) || id;
27+
if (args.length === 0) return translation;
28+
29+
let argIndex = 0;
30+
return translation.replace(/%(?:(\d+)\$)?([%sdfx])/g, (match, index, type) => {
31+
if (type === '%') return '%';
32+
if (index) {
33+
const i = parseInt(index) - 1;
34+
return args[i] !== undefined ? args[i] : match;
35+
} else {
36+
return args[argIndex++] !== undefined ? args[argIndex - 1] : match;
37+
}
38+
});
39+
}
40+
41+
/**
42+
* Parse XML translation file into a JavaScript object
43+
* @param {string} xmlText - The XML content as string
44+
* @returns {Object} - Parsed translations
45+
*/
46+
function parseTranslationsXML(xmlText) {
47+
const parser = new DOMParser();
48+
const xmlDoc = parser.parseFromString(xmlText, 'text/xml');
49+
const strings = xmlDoc.getElementsByTagName('string');
50+
const translations = {};
51+
52+
for (let i = 0; i < strings.length; i++) {
53+
const string = strings[i];
54+
const name = string.getAttribute('name');
55+
const value = string.textContent.replace(/\\n/g, '\n');
56+
translations[name] = value;
57+
}
58+
59+
return translations;
60+
}
61+
62+
/**
63+
* Detect user's default language
64+
* @returns {Promise<string>} - Detected language code
65+
*/
66+
async function detectUserLanguage() {
67+
const userLang = navigator.language || navigator.userLanguage;
68+
const langCode = userLang.split('-')[0];
69+
70+
try {
71+
// Fetch available languages
72+
const availableResponse = await fetch('locales/languages.json');
73+
const availableData = await availableResponse.json();
74+
availableLanguages = Object.keys(availableData);
75+
languageNames = availableData;
76+
77+
// Fetch preferred language
78+
const prefered_language_code = localStorage.getItem('kp-next_language');
79+
80+
// Check if preferred language is valid
81+
if (prefered_language_code !== 'default' && availableLanguages.includes(prefered_language_code)) {
82+
return prefered_language_code;
83+
} else if (availableLanguages.includes(userLang)) {
84+
return userLang;
85+
} else if (availableLanguages.includes(langCode)) {
86+
return langCode;
87+
} else {
88+
localStorage.removeItem('kp-next_language');
89+
return 'en';
90+
}
91+
} catch (error) {
92+
console.error('Error detecting user language:', error);
93+
return 'en';
94+
}
95+
}
96+
97+
/**
98+
* Load translations dynamically based on the selected language
99+
* @returns {Promise<void>}
100+
*/
101+
export async function loadTranslations() {
102+
try {
103+
// load Englsih as base translations
104+
const baseResponse = await fetch('./locales/strings/en.xml');
105+
const baseXML = await baseResponse.text();
106+
baseTranslations = parseTranslationsXML(baseXML);
107+
108+
// load user's language if available
109+
const lang = await detectUserLanguage();
110+
if (lang !== 'en') {
111+
const response = await fetch(`locales/strings/${lang}.xml`);
112+
const userXML = await response.text();
113+
const userTranslations = parseTranslationsXML(userXML);
114+
translations = { ...baseTranslations, ...userTranslations };
115+
} else {
116+
translations = baseTranslations;
117+
}
118+
119+
// Support for rtl language
120+
const isRTL = rtlLang.includes(lang.split('-')[0]);
121+
const dir = isRTL ? 'rtl' : 'ltr';
122+
document.documentElement.setAttribute('dir', dir);
123+
document.querySelectorAll('[flip-icon-in-rtl="true"]').forEach(el => {
124+
el.style.transform = dir === 'rtl' ? 'scaleX(-1)' : 'scaleX(1)';
125+
});
126+
127+
// Generate language menu
128+
await generateLanguageMenu();
129+
} catch (error) {
130+
console.error('Error loading translations:', error);
131+
translations = baseTranslations;
132+
}
133+
applyTranslations();
134+
}
135+
136+
/**
137+
* Apply translations to all elements with data-i18n attributes
138+
* @returns {void}
139+
*/
140+
function applyTranslations() {
141+
document.querySelectorAll("[data-i18n]").forEach((el) => {
142+
const key = el.getAttribute("data-i18n");
143+
const translation = getString(key);
144+
if (translation !== key) {
145+
if (el.hasAttribute("placeholder")) {
146+
el.setAttribute("placeholder", translation);
147+
} else if (el.hasAttribute("label")) {
148+
el.setAttribute("label", translation);
149+
} else {
150+
el.textContent = translation;
151+
}
152+
}
153+
});
154+
}
155+
156+
/**
157+
* Function to set a language
158+
* @param {string} language - Target langauge to set
159+
* @returns {void}
160+
*/
161+
function setLanguage(language) {
162+
localStorage.setItem('kp-next_language', language);
163+
window.location.reload();
164+
}
165+
166+
/**
167+
* Generate the language menu dynamically
168+
* Refer available-lang.json in ./locales for list of languages
169+
* @returns {Promise<void>}
170+
*/
171+
async function generateLanguageMenu() {
172+
const languageForm = document.getElementById('language-form');
173+
languageForm.innerHTML = '';
174+
175+
const createOption = (lang, name) => {
176+
const label = document.createElement('label');
177+
label.className = 'language-option';
178+
label.innerHTML = `
179+
<md-radio name="language" value="${lang}"></md-radio>
180+
<span>${name}</span>
181+
`;
182+
183+
const radio = label.querySelector('md-radio');
184+
185+
const currentLang = localStorage.getItem('kp-next_language') || 'default';
186+
if (currentLang === lang) radio.checked = true;
187+
188+
radio.addEventListener('change', () => {
189+
if (radio.checked) setLanguage(lang);
190+
});
191+
192+
languageForm.appendChild(label);
193+
};
194+
195+
createOption('default', getString('label_system_default'));
196+
197+
const sortedLanguages = Object.entries(languageNames)
198+
.map(([lang, name]) => ({ lang, name }))
199+
.sort((a, b) => a.name.localeCompare(b.name));
200+
201+
sortedLanguages.forEach(({ lang, name }) => {
202+
createOption(lang, name);
203+
});
204+
205+
applyTranslations();
206+
}

webui/page/exclude.js

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { listPackages, getPackagesInfo, exec } from 'kernelsu-alt';
22
import { modDir, persistDir, superkey } from '../index.js';
3+
import { getString } from '../language.js';
34
import fallbackIcon from '../icon.png';
45

56
let allApps = [];
@@ -31,7 +32,7 @@ async function refreshAppList() {
3132
const appList = document.getElementById('app-list');
3233
const emptyMsg = document.getElementById('exclude-empty-msg');
3334
appList.innerHTML = '';
34-
emptyMsg.textContent = 'Loading...';
35+
emptyMsg.textContent = getString('status_loading');
3536
emptyMsg.classList.remove('hidden');
3637

3738
try {
@@ -50,7 +51,7 @@ async function refreshAppList() {
5051
}
5152
renderAppList();
5253
} catch (e) {
53-
emptyMsg.textContent = `Error loading apps: ${e.message}`;
54+
emptyMsg.textContent = getString('msg_error_loading_apps', e.message);
5455
}
5556
}
5657

@@ -144,11 +145,11 @@ async function renderAppList() {
144145
item.className = 'app-item';
145146
const userIdx = Math.floor(app.uid / 100000);
146147
const extraTags = [];
147-
if (userIdx > 0) extraTags.push(`USER ${userIdx}`);
148-
if (app.isSystem) extraTags.push('SYSTEM');
148+
if (userIdx > 0) extraTags.push(getString('info_user', userIdx));
149+
if (app.isSystem) extraTags.push(getString('info_system'));
149150
const extraTagsHtml = extraTags.length > 0 ? `
150151
<div class="tag-wrapper">
151-
${extraTags.map(tag => `<div class="tag ${tag.toLowerCase()}">${tag}</div>`).join('')}
152+
${extraTags.map(tag => `<div class="tag ${app.isSystem ? 'system' : ''}">${tag}</div>`).join('')}
152153
</div>
153154
` : '';
154155

@@ -159,8 +160,8 @@ async function renderAppList() {
159160
<img class="app-icon" data-package="${app.packageName || ''}" style="opacity: 0;">
160161
</div>
161162
<div class="app-info">
162-
<div class="app-label">${app.appLabel || 'Unknown'}</div>
163-
<div class="app-package">${app.packageName || 'Unknown'}</div>
163+
<div class="app-label">${app.appLabel || getString('msg_unknown')}</div>
164+
<div class="app-package">${app.packageName}</div>
164165
${extraTagsHtml}
165166
</div>
166167
<md-switch class="app-switch"></md-switch>
@@ -197,7 +198,7 @@ async function renderAppList() {
197198

198199
applyFilters();
199200
} catch (e) {
200-
emptyMsg.textContent = `Error rendering apps: ${e.message}`;
201+
emptyMsg.textContent = getString('msg_error_rendering_apps', e.message);
201202
emptyMsg.classList.remove('hidden');
202203
}
203204
}
@@ -221,7 +222,7 @@ function applyFilters() {
221222

222223
const emptyMsg = document.getElementById('exclude-empty-msg');
223224
if (visibleCount === 0) {
224-
emptyMsg.textContent = 'No app found';
225+
emptyMsg.textContent = getString('msg_no_app_found');
225226
emptyMsg.classList.remove('hidden');
226227
} else {
227228
emptyMsg.classList.add('hidden');

0 commit comments

Comments
 (0)