Add ability for jump to be translated into other languages

This commit is contained in:
Dale Davies
2023-04-06 17:49:27 +01:00
parent 5414f2b644
commit 0c2c320c75
26 changed files with 267 additions and 37 deletions

View File

@@ -1 +1 @@
v1.3.2 (1679526752)
v1.3.2 (1680794802)

View File

@@ -14,13 +14,13 @@ import Clock from "./Clock";
export default class Greeting {
constructor(hour) {
constructor(hour, strings) {
this.hour = hour;
this.greetings = {
0 : 'morning',
12 : 'afternoon',
16 : 'evening',
19 : 'night'
0 : strings.greetings.goodmorning,
12 : strings.greetings.goodafternoon,
16 : strings.greetings.goodevening,
19 : strings.greetings.goodnight
};
}

View File

@@ -55,6 +55,9 @@ export default class Main {
keys: ['name', 'tags', 'url']
});
}
// Parse stringsforjs JSON object.
this.strings = JSON.parse(JUMP.strings)
}
/**
@@ -90,7 +93,7 @@ export default class Main {
siteelm.classList.add(status);
if (status !== 'online') {
const sitelinkelm = siteelm.querySelector('a');
sitelinkelm.title = '(Status: ' + status + ') ' + sitelinkelm.title;
sitelinkelm.title = '('+this.strings.status.status+': ' + this.strings.status[status] + ') ' + sitelinkelm.title;
}
}
}
@@ -141,7 +144,7 @@ export default class Main {
this.timeelm.innerHTML = clockdata.formatted_time;
}
if (this.greetingelm != null) {
let greeting = new Greeting(clockdata.hour);
let greeting = new Greeting(clockdata.hour, this.strings);
this.greetingelm.innerHTML = greeting.get_greeting();
}
});
@@ -180,7 +183,7 @@ export default class Main {
if (this.showsearchbuttonelm) {
const searchinput = document.querySelector('.search-form input');
this.searchsuggestions = new SearchSuggestions(JSON.parse(JUMP.searchengines), searchinput, this.showsearchbuttonelm, this.eventemitter);
this.searchsuggestions = new SearchSuggestions(JSON.parse(JUMP.searchengines), searchinput, this.showsearchbuttonelm, this.eventemitter, this.strings);
// When the search icon is licked, show the search bar and focus on it.
this.showsearchbuttonelm.addEventListener('click', e => {

View File

@@ -15,13 +15,14 @@
*/
export default class SearchSuggestions {
constructor(searchengines, inputelm, containerelm, eventemitter) {
constructor(searchengines, inputelm, containerelm, eventemitter, strings) {
this.containerelm = containerelm;
this.eventemitter = eventemitter;
this.inputelm = inputelm;
this.suggestionslistelm = containerelm.querySelector('.suggestion-list');
this.searchproviderlist = null;
this.searchengines = searchengines;
this.strings = strings;
}
build_searchprovider_list_elm(query) {
@@ -32,7 +33,7 @@ export default class SearchSuggestions {
const searchprovider = document.createElement('li');
searchprovider.setAttribute('tabindex', -1);
searchprovider.innerHTML = '<a target="_blank" rel="noopener" \
href="'+provider.url+encodeURIComponent(query)+'"><span>Search on</span> '+provider.name+'</a>';
href="'+provider.url+encodeURIComponent(query)+'">'+this.strings.search.searchon[provider.name]+'</a>';
searchproviderlist.appendChild(searchprovider);
});
searchproviderlist.addEventListener('keyup', e => {
@@ -102,7 +103,7 @@ export default class SearchSuggestions {
if (this.inputelm.value !== '') {
const searchtitle = document.createElement('span');
searchtitle.classList.add('suggestiontitle');
searchtitle.innerHTML = 'Search';
searchtitle.innerHTML = this.strings.search.search;
suggestionholder.appendChild(searchtitle);
this.searchproviderlist = this.build_searchprovider_list_elm(this.inputelm.value);
suggestionholder.appendChild(this.searchproviderlist);
@@ -111,7 +112,7 @@ export default class SearchSuggestions {
if (newsuggestionslist.childNodes.length > 0) {
const suggestiontitle = document.createElement('span');
suggestiontitle.classList.add('suggestiontitle');
suggestiontitle.innerHTML = 'Sites';
suggestiontitle.innerHTML = this.strings.search.sites;
suggestionholder.appendChild(suggestiontitle);
suggestionholder.appendChild(newsuggestionslist)
}

View File

@@ -19,6 +19,7 @@ abstract class AbstractAPI {
protected \Jump\Config $config,
protected \Jump\Cache $cache,
protected \Nette\Http\Session $session,
protected \Jump\Language $language,
protected ?array $routeparams
){}

View File

@@ -22,7 +22,7 @@ class Unsplash extends AbstractAPI {
$unsplashdata = $this->cache->load(cachename: 'unsplash');
if ($unsplashdata == null) {
$unsplashdata = \Jump\Unsplash::load_cache_unsplash_data($this->config);
$unsplashdata = \Jump\Unsplash::load_cache_unsplash_data($this->config, $this->language);
$this->cache->save(cachename: 'unsplash', data: $unsplashdata);
}

View File

@@ -41,6 +41,7 @@ class Weather extends AbstractAPI {
.'?units=' . $units
.'&lat=' . trim($latlong[0])
.'&lon=' . trim($latlong[1])
.'&lang='. substr($this->config->get('language'), 0, 2)
.'&appid=' . $this->config->get('owmapikey', false);
// Use the cache to store/retrieve data, make an md5 hash of latlong so it is not possible

View File

@@ -42,6 +42,13 @@ class Cache {
public function __construct(private Config $config) {
// Define the various caches used throughout the app.
$this->caches = [
'languages' => [
'cache' => null,
'expirationtype' => Caching\Cache::Files,
'expirationparams' => [
__DIR__.'/../config.php',
]
],
'searchengines' => [
'cache' => null,
'expirationtype' => Caching\Cache::Files,

View File

@@ -39,6 +39,7 @@ class Config {
'sitesdir' => '/sites',
'sitesfile' => '/sites/sites.json',
'templatedir' => '/templates',
'translationsdir' => '/translations'
];
/**

View File

@@ -0,0 +1,84 @@
<?php
/**
* ██ ██ ██ ███ ███ ██████
* ██ ██ ██ ████ ████ ██ ██
* ██ ██ ██ ██ ████ ██ ██████
* ██ ██ ██ ██ ██ ██ ██ ██
* █████ ██████ ██ ██ ██
*
* @author Dale Davies <dale@daledavies.co.uk>
* @copyright Copyright (c) 2022, Dale Davies
* @license MIT
*/
namespace Jump;
use \Jump\Exceptions\ConfigException;
/**
* Defines a class for loading language strings form available translations files, caching
* and fetching of language strings etc. Will fetch the appropriate strings based on the
* language code defined in config.php.
*
* @author Dale Davies <dale@daledavies.co.uk>
* @license MIT
*/
class Language {
private \Utopia\Locale\Locale $locale;
/**
* Automatically loads available language strings on instantiation, either from the
* cache or from available files in the translations dir.
*
* @param Config $config
* @param Cache $cache
*/
public function __construct(private Config $config, private Cache $cache) {
// Try to load the translations from cache.
$languages = $this->cache->load(cachename: 'languages');
// If they are not there or the cache has expired, then find all language files, load them up
// again and cache them.
if ($languages == null) {
$languages = [];
// Enumerate translation files and load their content.
$languagefiles = glob($this->config->get('translationsdir').'/*.json');
foreach ($languagefiles as $file) {
$rawjson = file_get_contents($file);
if ($rawjson === false) {
throw new ConfigException('There was a problem loading a translation file... ' . $file);
}
if ($rawjson === '') {
throw new ConfigException('The following translation file is empty... ' . $file);
}
$languages[pathinfo($file, PATHINFO_FILENAME)] = json_decode($rawjson, true);
}
// Save the content of translation files into the cache.
$this->cache->save(cachename: 'languages', data: $languages);
}
// For each translation file that has been loaded, set them as available locales.
foreach ($languages as $name => $strings) {
\Utopia\Locale\Locale::setLanguageFromArray($name, $strings);
}
// Initialise the locale defined in the config.php language setting.
try {
$locale = new \Utopia\Locale\Locale($this->config->get('language'));
} catch (\Exception) {
(new Pages\ErrorPage($this->cache, $this->config, 500, 'Provided language code has no corresponding translation file.'))->init();
}
$this->locale = $locale;
}
/**
* Retrieve a language string for the given key, substituting and placeholders
* that are provided.
*
* @param string $string
* @param array $placeholders
* @return mixed
*/
public function get(string $string, array $placeholders = []): mixed {
return $this->locale->getText($string, $placeholders);
}
}

View File

@@ -13,19 +13,20 @@
namespace Jump;
use Nette\Routing\RouteList;
class Main {
private Cache $cache;
private Config $config;
private Language $language;
private \Nette\Http\Request $request;
private \Nette\Routing\RouteList $router;
private \Nette\Http\Session $session;
public function __construct() {
$this->config = new Config();
$this->cache = new Cache($this->config);
$this->router = new RouteList;
$this->router = new \Nette\Routing\RouteList;
$this->language = new Language($this->config, $this->cache);
// Set up the routes that Jump expects.
$this->router->addRoute('/', [
@@ -65,7 +66,7 @@ class Main {
// Instantiate the correct class to build the requested page, get the
// content and return it.
$page = new $outputclass($this->config, $this->cache, $this->session, $matchedroute ?? null);
$page = new $outputclass($this->config, $this->cache, $this->session, $this->language, $matchedroute ?? null);
return $page->get_output();
}

View File

@@ -29,15 +29,21 @@ abstract class AbstractPage {
protected \Jump\Config $config,
protected \Jump\Cache $cache,
protected \Nette\Http\Session $session,
protected \Jump\Language $language,
protected ?array $routeparams
){
$this->hastags = false;
$this->mustache = new \Mustache_Engine([
'loader' => new \Mustache_Loader_FilesystemLoader($this->config->get('templatedir')),
// Create a urlencodde helper for use in template. E.g. using siteurl in icon.php query param.
'helpers' => array('urlencode' => function($text, $renderer) {
return urlencode($renderer($text));
}),
'helpers' => [
'urlencode' => function($text, $renderer) {
return urlencode($renderer($text));
},
'language' => function($text, $renderer) {
return $this->language->get($text);
},
],
]);
// Get a Nette session section for CSRF data.
$csrfsection = $this->session->getSection('csrf');

View File

@@ -38,12 +38,20 @@ class HomePage extends AbstractPage {
'wwwurl' => $this->config->get_wwwurl(),
'checkstatus' => $checkstatus,
];
$stringsforjs = \Jump\Status::get_strings_for_js($this->language);
$stringsforjs['greetings']['goodmorning'] = $this->language->get('greetings.goodmorning');
$stringsforjs['greetings']['goodafternoon'] = $this->language->get('greetings.goodafternoon');
$stringsforjs['greetings']['goodevening'] = $this->language->get('greetings.goodevening');
$stringsforjs['greetings']['goodnight'] = $this->language->get('greetings.goodnight');
if ($showsearch || $checkstatus) {
$templatecontext['sitesjson'] = json_encode((new \Jump\Sites($this->config, $this->cache))->get_sites_for_frontend());
if ($showsearch) {
$templatecontext['searchengines'] = json_encode((new \Jump\SearchEngines($this->config, $this->cache))->get_search_engines());
$searchengines = new \Jump\SearchEngines($this->config, $this->cache, $this->language);
$templatecontext['searchengines'] = json_encode($searchengines->get_search_engines());
$stringsforjs += $searchengines->get_strings_for_js();
}
}
$templatecontext['stringsforjs'] = json_encode($stringsforjs);
return $template->render($templatecontext);
}

View File

@@ -38,12 +38,20 @@ class TagPage extends AbstractPage {
'wwwurl' => $this->config->get_wwwurl(),
'checkstatus' => $checkstatus,
];
$stringsforjs = \Jump\Status::get_strings_for_js($this->language);
$stringsforjs['greetings']['goodmorning'] = $this->language->get('greetings.goodmorning');
$stringsforjs['greetings']['goodafternoon'] = $this->language->get('greetings.goodafternoon');
$stringsforjs['greetings']['goodevening'] = $this->language->get('greetings.goodevening');
$stringsforjs['greetings']['goodnight'] = $this->language->get('greetings.goodnight');
if ($showsearch || $checkstatus) {
$templatecontext['sitesjson'] = json_encode((new \Jump\Sites($this->config, $this->cache))->get_sites_for_frontend());
if ($showsearch) {
$templatecontext['searchengines'] = json_encode((new \Jump\SearchEngines($this->config, $this->cache))->get_search_engines());
$searchengines = new \Jump\SearchEngines($this->config, $this->cache, $this->language);
$templatecontext['searchengines'] = json_encode($searchengines->get_search_engines());
$stringsforjs += $searchengines->get_strings_for_js();
}
}
$templatecontext['stringsforjs'] = json_encode($stringsforjs);
return $template->render($templatecontext);
}

View File

@@ -26,7 +26,7 @@ class SearchEngines {
/**
* Automatically load searchengines.json on instantiation.
*/
public function __construct(private Config $config, private Cache $cache) {
public function __construct(private Config $config, private Cache $cache, private Language $language) {
$this->config = $config;
$this->loadedsearchengines = [];
$this->searchfilelocation = $this->config->get('searchenginesfile');
@@ -87,4 +87,22 @@ class SearchEngines {
public function get_search_engines() {
return $this->loadedsearchengines;
}
/**
* Return all the strings to be used by JS on the frontend.
*
* @return array
*/
public function get_strings_for_js(): array {
$strings = [
'search' => [
'search' => $this->language->get('search.search'),
'sites' => $this->language->get('search.sites'),
]
];
foreach ($this->loadedsearchengines as $searchengine) {
$strings['search']['searchon'][$searchengine->name] = $this->language->get('search.searchon', ['searchprovider' => $searchengine->name]);
}
return $strings;
}
}

View File

@@ -92,4 +92,21 @@ class Status {
return self::STATUS_UNKNOWN;
}
}
/**
* Return all the strings to be used by JS on the frontend.
*
* @return array
*/
public static function get_strings_for_js(Language $language): array {
return [
'status' => [
'status' => $language->get('status.status'),
'error' => $language->get('status.error'),
'offline' => $language->get('status.offline'),
'online' => $language->get('status.online'),
'unknown' => $language->get('status.unknown'),
]
];
}
}

View File

@@ -15,7 +15,7 @@ namespace Jump;
class Unsplash {
public static function load_cache_unsplash_data($config) {
public static function load_cache_unsplash_data($config, $language) {
\Unsplash\HttpClient::init([
'utmSource' => 'jump_startpage',
'applicationId' => $config->get('unsplashapikey'),
@@ -37,14 +37,14 @@ class Unsplash {
curl_setopt($ch, CURLOPT_FAILONERROR, true);
$response = curl_exec($ch);
// Create the response and return it.
$description = 'Photo';
$description = $language->get('unsplash.description.photoby', ['user' => $photo->user['name']]);
if ($photo->description !== null &&
strlen($photo->description) <= 45) {
$description = $photo->description;
$description = $language->get('unsplash.description.by', ['description' => $photo->description, 'user' => $photo->user['name']]);
}
$unsplashdata = new \stdClass();
$unsplashdata->color = $photo->color;
$unsplashdata->attribution = '<a target="_blank" rel="noopener" href="'.$photo->links['html'].'">'.$description.' by '.$photo->user['name'].'</a>';
$unsplashdata->attribution = '<a target="_blank" rel="noopener" href="'.$photo->links['html'].'">'.$description.'</a>';
$unsplashdata->imagedatauri = 'data: '.(new \finfo(FILEINFO_MIME_TYPE))->buffer($response).';base64,'.base64_encode($response);
return $unsplashdata;
}

View File

@@ -20,12 +20,13 @@ require __DIR__ .'/../vendor/autoload.php';
$config = new Jump\Config();
$cache = new Jump\Cache($config);
$language = new Jump\Language($this->config, $this->cache);
// If this script is run via CLI then clear the cache and repopulate it,
// otherwise if run via web then get image data from cache and run this
// script asynchronously to refresh the cache for next time.
if (http_response_code() === false) {
$unsplashdata = Jump\Unsplash::load_cache_unsplash_data($config);
$unsplashdata = Jump\Unsplash::load_cache_unsplash_data($config, $language);
$cache->save(cachename: 'unsplash', data: $unsplashdata);
die('Cached data from Unsplash');
}

View File

@@ -19,6 +19,7 @@
"nette/http": "^3.1",
"guzzlehttp/guzzle": "^7.0",
"unsplash/unsplash": "3.2.1",
"divineomega/array_undot": "^4.1"
"divineomega/array_undot": "^4.1",
"utopia-php/locale": "^0.6.0"
}
}

53
jumpapp/composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "8849ca2f3c80ed00c98055bf630ba1fb",
"content-hash": "5fcb97502e7722b9364b85cdd3a4f851",
"packages": [
{
"name": "arthurhoaro/favicon",
@@ -1558,6 +1558,57 @@
"description": "Wrapper to access the Unsplash API and photo library",
"time": "2022-01-17T20:32:58+00:00"
},
{
"name": "utopia-php/locale",
"version": "0.6.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/locale.git",
"reference": "9de05231484ab29f61e6557e7ae494cbcf31cf41"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/locale/zipball/9de05231484ab29f61e6557e7ae494cbcf31cf41",
"reference": "9de05231484ab29f61e6557e7ae494cbcf31cf41",
"shasum": ""
},
"require": {
"php": ">=7.4"
},
"require-dev": {
"phpunit/phpunit": "^9.3",
"vimeo/psalm": "4.0.1"
},
"type": "library",
"autoload": {
"psr-4": {
"Utopia\\Locale\\": "src/Locale"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Eldad Fux",
"email": "eldad@appwrite.io"
}
],
"description": "A simple locale library to manage application translations",
"keywords": [
"framework",
"locale",
"php",
"upf",
"utopia"
],
"support": {
"issues": "https://github.com/utopia-php/locale/issues",
"source": "https://github.com/utopia-php/locale/tree/0.6.0"
},
"time": "2021-09-19T20:15:14+00:00"
},
{
"name": "yosymfony/parser-utils",
"version": "v2.0.0",

View File

@@ -21,6 +21,8 @@ return [
'wwwroot' => getenv('WWWROOT') ?: '/var/www/html',
// Site URL - might help if just is hosted in a subdirectory.
'wwwurl' => getenv('WWWURL') ?: '',
// The language Jump should use for strings, uses ISO 639-1 language codes.
'language' => getenv('LANGUAGE') ?: 'en-gb',
// Stop retrieving items from the cache, useful for testing.
'cachebypass' => getenv('CACHEBYPASS') ?: false,

View File

@@ -24,9 +24,9 @@
</div>
{{# hastags}}
<div id="tags" class="tags">
<span class="header">Tags<span class="close"></span></span>
<span class="header">{{#language}}tags{{/language}}<span class="close"></span></span>
<ul>
<li><a href="{{{wwwurl}}}/">home</a></li>
<li><a href="{{{wwwurl}}}/">{{#language}}tags.home{{/language}}</a></li>
{{# tags}}<li><a href="{{{wwwurl}}}/tag/{{.}}/">{{.}}</a></li>{{/ tags}}
</ul>
</div>

View File

@@ -22,7 +22,8 @@
searchengines: '{{{searchengines}}}',
unsplash: '{{{unsplash}}}',
unsplashcolor: '{{unsplashcolor}}',
wwwurl: '{{{wwwurl}}}'
wwwurl: '{{{wwwurl}}}',
strings: '{{{stringsforjs}}}'
};
</script>
</head>
@@ -30,6 +31,6 @@
<div class="content fixed hidden {{# checkstatus}}status{{/ checkstatus}}">
<div class="greeting">
{{# greeting}}<span class="tagname"><span>#</span>{{greeting}}</span>{{/ greeting}}
{{^ greeting}}Good <span class="chosen"></span>{{/ greeting}}
{{^ greeting}}<span class="chosen"></span>{{/ greeting}}
</div>

View File

@@ -1 +1 @@
<script defer src="{{{wwwurl}}}/assets/js/index.fe3fa6c8305604e7c627.min.js"></script>
<script defer src="{{{wwwurl}}}/assets/js/index.7f32838f57d5988f5cfa.min.js"></script>

View File

@@ -0,0 +1,18 @@
{
"greetings.goodafternoon": "Good afternoon",
"greetings.goodevening": "Good evening",
"greetings.goodmorning": "Good morning",
"greetings.goodnight": "Good night",
"search.search": "Search",
"search.searchon": "<span>Search on</span> {{searchprovider}}",
"search.sites": "Sites",
"status.status": "Status",
"status.error": "error",
"status.offline": "offline",
"status.online": "online",
"status.unknown": "unknown",
"tags": "Tags",
"tags.home": "home",
"unsplash.description.photoby": "Photo by {{user}}",
"unsplash.description.by": "{{description}} by {{user}}"
}