Skip to content

Commit 37dd305

Browse files
committed
feat(translation): added translation verification, closes #3317
1 parent d87f25a commit 37dd305

File tree

9 files changed

+187
-32
lines changed

9 files changed

+187
-32
lines changed

phpmyfaq/admin/assets/src/dashboard.ts

Lines changed: 29 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@
1616
import Masonry from 'masonry-layout';
1717
import { Chart, registerables } from 'chart.js';
1818
import { getRemoteHashes, verifyHashes } from './api';
19-
import { addElement } from '../../../assets/src/utils';
19+
import { addElement, TranslationService } from '../../../assets/src/utils';
2020

21-
export const renderVisitorCharts = async () => {
21+
export const renderVisitorCharts = async (): Promise<void> => {
2222
const context = document.getElementById('pmf-chart-visits') as HTMLCanvasElement | null;
2323

2424
if (context) {
@@ -63,15 +63,12 @@ export const renderVisitorCharts = async () => {
6363
display: true,
6464
text: 'Visitors',
6565
},
66-
ticks: {
67-
beginAtZero: true,
68-
},
6966
},
7067
},
7168
},
7269
});
7370

74-
const getData = async () => {
71+
const getData = async (): Promise<void> => {
7572
try {
7673
const response = await fetch('./api/dashboard/visits', {
7774
method: 'GET',
@@ -102,7 +99,7 @@ export const renderVisitorCharts = async () => {
10299
}
103100
};
104101

105-
export const renderTopTenCharts = async () => {
102+
export const renderTopTenCharts = async (): Promise<void> => {
106103
const context = document.getElementById('pmf-chart-topten') as HTMLCanvasElement | null;
107104

108105
if (context) {
@@ -150,22 +147,19 @@ export const renderTopTenCharts = async () => {
150147
title: {
151148
display: false,
152149
},
153-
ticks: {
154-
beginAtZero: true,
155-
},
156150
},
157151
},
158152
},
159153
});
160154

161155
const dynamicColors = (): string => {
162-
const r = Math.floor(Math.random() * 255);
163-
const g = Math.floor(Math.random() * 255);
164-
const b = Math.floor(Math.random() * 255);
156+
const r: number = Math.floor(Math.random() * 255);
157+
const g: number = Math.floor(Math.random() * 255);
158+
const b: number = Math.floor(Math.random() * 255);
165159
return 'rgb(' + r + ',' + g + ',' + b + ')';
166160
};
167161

168-
const getData = async () => {
162+
const getData = async (): Promise<void> => {
169163
try {
170164
const response = await fetch('./api/dashboard/topten', {
171165
method: 'GET',
@@ -180,7 +174,7 @@ export const renderTopTenCharts = async () => {
180174
if (response.status === 200) {
181175
const topTen: { question: string; visits: number }[] = await response.json();
182176

183-
topTen.forEach((faq) => {
177+
topTen.forEach((faq: { question: string; visits: number }): void => {
184178
doughnutChart.data.labels!.push(faq.question);
185179
doughnutChart.data.datasets[0].data.push(faq.visits);
186180
colors.push(dynamicColors());
@@ -197,9 +191,9 @@ export const renderTopTenCharts = async () => {
197191
}
198192
};
199193

200-
export const getLatestVersion = async () => {
201-
const loader = document.getElementById('version-loader');
202-
const versionText = document.getElementById('phpmyfaq-latest-version');
194+
export const getLatestVersion = async (): Promise<void> => {
195+
const loader = document.getElementById('version-loader') as HTMLDivElement;
196+
const versionText = document.getElementById('phpmyfaq-latest-version') as HTMLDivElement;
203197

204198
if (loader && versionText) {
205199
loader.classList.remove('d-none');
@@ -263,28 +257,31 @@ export const getLatestVersion = async () => {
263257
}
264258
};
265259

266-
export const handleVerificationModal = async () => {
267-
const verificationModal = document.getElementById('verificationModal');
260+
export const handleVerificationModal = async (): Promise<void> => {
261+
const verificationModal = document.getElementById('verificationModal') as HTMLDivElement;
262+
const Translator = new TranslationService();
268263
if (verificationModal) {
269-
verificationModal.addEventListener('show.bs.modal', async () => {
270-
const spinner = document.getElementById('pmf-verification-spinner');
271-
const version = verificationModal.getAttribute('data-pmf-current-version');
272-
const updates = document.getElementById('pmf-verification-updates');
264+
verificationModal.addEventListener('show.bs.modal', async (): Promise<void> => {
265+
const spinner = document.getElementById('pmf-verification-spinner') as HTMLDivElement;
266+
const version = verificationModal.getAttribute('data-pmf-current-version') as string;
267+
const updates = document.getElementById('pmf-verification-updates') as HTMLDivElement;
268+
const language: string = document.documentElement.lang;
269+
await Translator.loadTranslations(language);
273270
if (spinner && updates && version) {
274271
spinner.classList.remove('d-none');
275-
updates.innerText = 'Fetching verification hashes from api.phpmyfaq.de...';
276-
const remoteHashes = await getRemoteHashes(version);
277-
updates.innerText = 'Checking hashes with installation files...';
272+
updates.innerText = Translator.translate('msgFetchingHashes');
273+
const remoteHashes = (await getRemoteHashes(version)) as Record<string, string>;
274+
updates.innerText = Translator.translate('msgCheckHashes');
278275
const issues = await verifyHashes(remoteHashes);
279276

280277
if (typeof issues !== 'object') {
281278
console.error('Invalid JSON data provided.');
282279
}
283280

284-
const ul = document.createElement('ul');
281+
const ul = document.createElement('ul') as HTMLUListElement;
285282
for (const [filename, hashValue] of Object.entries(issues)) {
286-
const li = document.createElement('li');
287-
li.textContent = `Filename: ${filename}, Hash Value: ${hashValue}`;
283+
const li = document.createElement('li') as HTMLLIElement;
284+
li.textContent = `${Translator.translate('msgAttachmentsFilename')}: ${filename}, Hash: ${hashValue}`;
288285
ul.appendChild(li);
289286
}
290287

@@ -295,8 +292,8 @@ export const handleVerificationModal = async () => {
295292
}
296293
};
297294

298-
window.onload = () => {
299-
const masonryElement = document.querySelector('.masonry-grid');
295+
window.onload = (): void => {
296+
const masonryElement = document.querySelector('.masonry-grid') as HTMLElement;
300297
if (masonryElement) {
301298
new Masonry(masonryElement, { columnWidth: 0 });
302299
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { beforeEach, describe, expect, test, vi } from 'vitest';
2+
import { fetchTranslations } from './translations';
3+
import createFetchMock, { FetchMock } from 'vitest-fetch-mock';
4+
5+
const fetchMocker: FetchMock = createFetchMock(vi);
6+
7+
fetchMocker.enableMocks();
8+
9+
describe('fetchTranslations', () => {
10+
beforeEach(() => {
11+
fetchMocker.resetMocks();
12+
});
13+
14+
test('should return translations when the response is successful', async (): Promise<void> => {
15+
const mockTranslations = { hello: 'Hello', goodbye: 'Goodbye' };
16+
17+
fetchMocker.mockResponseOnce(JSON.stringify(mockTranslations));
18+
19+
const locale = 'en';
20+
const data: Record<string, string> = await fetchTranslations(locale);
21+
22+
expect(data).toEqual(mockTranslations);
23+
expect(fetch).toHaveBeenCalledTimes(1);
24+
expect(fetch).toHaveBeenCalledWith(`/api/translations/${locale}`, {
25+
method: 'GET',
26+
cache: 'no-cache',
27+
headers: {
28+
'Content-Type': 'application/json',
29+
},
30+
redirect: 'follow',
31+
referrerPolicy: 'no-referrer',
32+
});
33+
});
34+
35+
test('should throw an error when the response is not successful', async (): Promise<void> => {
36+
fetchMocker.mockResponseOnce(null, { status: 500 });
37+
38+
const locale = 'en';
39+
40+
await expect(fetchTranslations(locale)).rejects.toThrow('Unexpected end of JSON input');
41+
expect(fetch).toHaveBeenCalledTimes(1);
42+
expect(fetch).toHaveBeenCalledWith(`/api/translations/${locale}`, {
43+
method: 'GET',
44+
cache: 'no-cache',
45+
headers: {
46+
'Content-Type': 'application/json',
47+
},
48+
redirect: 'follow',
49+
referrerPolicy: 'no-referrer',
50+
});
51+
});
52+
53+
test('should handle fetch error', async (): Promise<void> => {
54+
fetchMocker.mockRejectOnce(new Error('API is down'));
55+
56+
const locale = 'en';
57+
58+
await expect(fetchTranslations(locale)).rejects.toThrow('API is down');
59+
expect(fetch).toHaveBeenCalledTimes(1);
60+
});
61+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* Private Translations API functionality
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla Public License,
5+
* v. 2.0. If a copy of the MPL was not distributed with this file, You can
6+
* obtain one at https://mozilla.org/MPL/2.0/.
7+
*
8+
* @package phpMyFAQ
9+
* @author Thorsten Rinne <[email protected]>
10+
* @copyright 2025 phpMyFAQ Team
11+
* @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
12+
* @link https://www.phpmyfaq.de
13+
* @since 2025-03-17
14+
*/
15+
16+
export const fetchTranslations = async (locale: string): Promise<Record<string, string>> => {
17+
const response: Response = await fetch(`/api/translations/${locale}`, {
18+
method: 'GET',
19+
cache: 'no-cache',
20+
headers: {
21+
'Content-Type': 'application/json',
22+
},
23+
redirect: 'follow',
24+
referrerPolicy: 'no-referrer',
25+
});
26+
27+
return response.json();
28+
};
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { describe, expect, test, vi, beforeEach, Mock } from 'vitest';
2+
import { TranslationService } from './TranslationService';
3+
import { fetchTranslations } from '../api/translations';
4+
5+
vi.mock('../api/translations', () => ({
6+
fetchTranslations: vi.fn(),
7+
}));
8+
9+
describe('TranslationService', (): void => {
10+
let service: TranslationService;
11+
12+
beforeEach((): void => {
13+
service = new TranslationService();
14+
});
15+
16+
test('should load translations successfully', async (): Promise<void> => {
17+
const mockTranslations = { hello: 'Hello', goodbye: 'Goodbye' };
18+
(fetchTranslations as Mock).mockResolvedValue(mockTranslations);
19+
20+
await service.loadTranslations('en');
21+
22+
expect(fetchTranslations).toHaveBeenCalledWith('en');
23+
expect(service.translate('hello')).toBe('Hello');
24+
expect(service.translate('goodbye')).toBe('Goodbye');
25+
});
26+
27+
test('should handle error when loading translations', async (): Promise<void> => {
28+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((): void => {});
29+
(fetchTranslations as Mock).mockRejectedValue(new Error('Failed to fetch'));
30+
31+
await service.loadTranslations('en');
32+
33+
expect(fetchTranslations).toHaveBeenCalledWith('en');
34+
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to load translations:', expect.any(Error));
35+
expect(service.translate('hello')).toBe('hello');
36+
37+
consoleErrorSpy.mockRestore();
38+
});
39+
40+
test('should return key if translation is not found', (): void => {
41+
expect(service.translate('nonexistent')).toBe('nonexistent');
42+
});
43+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { fetchTranslations } from '../api/translations';
2+
3+
export class TranslationService {
4+
private translations: Record<string, string> = {};
5+
6+
// Load translations from JSON
7+
async loadTranslations(locale: string): Promise<void> {
8+
try {
9+
this.translations = await fetchTranslations(locale);
10+
} catch (error) {
11+
console.error('Failed to load translations:', error);
12+
}
13+
}
14+
15+
// Get translated string by key
16+
translate(key: string): string {
17+
return this.translations[key] || key;
18+
}
19+
}

phpmyfaq/assets/src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export * from './notifications';
44
export * from './password';
55
export * from './reading-time';
66
export * from './tooltip';
7+
export * from './TranslationService';

phpmyfaq/translations/language_de.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1476,5 +1476,7 @@
14761476
$PMF_LANG['msgImageCouldNotBeUploaded'] = 'Das Bild konnte nicht hochgeladen werden.';
14771477
$PMF_LANG['msgImageTooLarge'] = 'Das Bild ist zu groß.';
14781478
$PMF_LANG['msgNoImagesForUpload'] = 'Es gibt keine Bilder zum Hochladen.';
1479+
$PMF_LANG['msgFetchingHashes'] = 'Lade Verifikationsdaten von api.phpmyfaq.de ...';
1480+
$PMF_LANG['msgCheckHashes'] = 'Überprüfe Hash-Werte der installierten Dateien ...';
14791481

14801482
return $PMF_LANG;

phpmyfaq/translations/language_en.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1495,5 +1495,7 @@
14951495
$PMF_LANG['msgImageCouldNotBeUploaded'] = 'The image could not be uploaded.';
14961496
$PMF_LANG['msgImageTooLarge'] = 'The image is too large.';
14971497
$PMF_LANG['msgNoImagesForUpload'] = 'No images for upload.';
1498+
$PMF_LANG['msgFetchingHashes'] = 'Fetching verification hashes from api.phpmyfaq.de...';
1499+
$PMF_LANG['msgCheckHashes'] = 'Checking hashes with installation files...';
14981500

14991501
return $PMF_LANG;

phpmyfaq/translations/language_es.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1428,5 +1428,7 @@
14281428
$PMF_LANG['msgImageCouldNotBeUploaded'] = 'No se pudo subir la imagen.';
14291429
$PMF_LANG['msgImageTooLarge'] = 'La imagen es demasiado grande.';
14301430
$PMF_LANG['msgNoImagesForUpload'] = 'No hay imágenes para subir.';
1431+
$PMF_LANG['msgFetchingHashes'] = 'Obteniendo hashes de verificación de api.phpmyfaq.de...';
1432+
$PMF_LANG['msgCheckHashes'] = 'Verificando hashes con los archivos de instalación...';
14311433

14321434
return $PMF_LANG;

0 commit comments

Comments
 (0)