Finalizing the translation system.

Needs some tests and a final translation generation.
As well a handy translation mapper or editor would be likely.
May source this out into another project?
This commit is contained in:
WolverinDEV 2018-12-15 00:09:47 +01:00
parent 24b220a966
commit 6e82161334
8 changed files with 733 additions and 63 deletions

View file

@ -97,4 +97,151 @@
}
}
}
}
.modal .settings-translations {
margin: 5px;
.setting-list {
user-select: none;
display: flex;
flex-direction: column;
.list {
display: flex;
flex-direction: column;
justify-content: start;
overflow-y: auto;
border: solid 1px lightgray;
padding: 2px;
background: #33333318;
height: 50%;
min-height: 50%;
max-height: 50%;
.entry {
display: flex;
flex-direction: row;
justify-content: stretch;
.default { }
.name {
flex-grow: 1;
flex-shrink: 1;
}
&.translation:not(.default) {
padding-left: 15px;
}
&.translation {
cursor: pointer;
}
&.repository {
.name {
font-weight: bold;
}
}
&.selected {
background: #0000FF77;
}
.button {
cursor: pointer;
&:hover {
background-color: #00000010;
}
}
}
}
.management {
width: 100%;
display: flex;
flex-direction: row;
justify-content: stretch;
margin-top: 5px;
float: right;
.space {
flex-grow: 1;
}
}
.restart-note {
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
margin-top: 5px;
p {
margin: 0;
}
}
}
}
/* The info modal for the translations */
.entry-info-container {
display: flex;
flex-direction: column;
.property {
display: flex;
flex-direction: row;
justify-content: stretch;
.key {
width: 100px;
}
.value {
display: flex;
flex-direction: row;
flex-grow: 1;
}
&.property-repository {
p {
margin: 0;
}
.button {
cursor: pointer;
display: flex;
flex-direction: column;
justify-content: center;
margin-right: 5px;
&:hover {
background: #00000011;
}
}
}
&.property-contributors {
.value {
display: flex;
flex-direction: column;
}
.contributor {
display: block;
}
}
}
}

View file

@ -13,16 +13,16 @@
<div style="height: 45px; width: 100%; border-radius: 2px 0px 0px 0px; border-bottom-width: 0px; background-color: lightgrey"
class="main_container">
<div id="control_bar" class="control_bar">
<div class="button btn_connect" title="Connect to a server">
<div class="button btn_connect" title="{{tr 'Connect to a server' /}}">
<div class="icon_x32 client-connect"></div>
</div>
<div class="button btn_disconnect" title="Disconnect from server" style="display: none">
<div class="button btn_disconnect" title="{{tr 'Disconnect from server' /}}" style="display: none">
<div class="icon_x32 client-disconnect"></div>
</div>
<!--<div class="button btn_disconnect"><div class="icon_x32 client-disconnect"></div></div>-->
<div class="divider"></div>
<div class="button-dropdown btn_away" title="Toggle away status">
<div class="button-dropdown btn_away" title="{{tr 'Toggle away status' /}}">
<div class="buttons">
<div class="button icon_x32 client-away btn_away_toggle"></div>
<div class="button-dropdown">
@ -30,19 +30,19 @@
</div>
</div>
<div class="dropdown">
<div class="btn_away_toggle"><div class="icon client-away"></div><a>Toggle away status</a></div>
<div class="btn_away_message"><div class="icon client-away"></div><a>Set away message</a></div>
<div class="btn_away_toggle"><div class="icon client-away"></div><a>{{tr "Toggle away status" /}}</a></div>
<div class="btn_away_message"><div class="icon client-away"></div><a>{{ŧr "Set away message" /}}</a></div>
</div>
</div>
<div class="button btn_mute_input">
<div class="icon_x32 client-input_muted" title="Mute/unmute microphone"></div>
<div class="icon_x32 client-input_muted" title="{{tr 'Mute/unmute microphone' /}}"></div>
</div>
<div class="button btn_mute_output">
<div class="icon_x32 client-output_muted" title="Mute/unmute headphones"></div>
<div class="icon_x32 client-output_muted" title="{{tr 'Mute/unmute headphones' /}}"></div>
</div>
<div class="divider"></div>
<div class="button-dropdown btn_token" title="Use token">
<div class="button-dropdown btn_token" title="{{tr 'Use token' /}}">
<div class="buttons">
<div class="button icon_x32 client-token btn_token_use"></div>
<div class="button-dropdown">
@ -50,20 +50,20 @@
</div>
</div>
<div class="dropdown">
<div class="btn_token_list"><div class="icon client-token"></div><a>List tokens</a></div>
<div class="btn_token_use"><div class="icon client-token_use"></div><a>Use token</a></div>
<div class="btn_token_list"><div class="icon client-token"></div><a>{{tr "List tokens" /}}</a></div>
<div class="btn_token_use"><div class="icon client-token_use"></div><a>{{tr "Use token" /}}</a></div>
</div>
</div>
<div style="width: 100%"></div>
<div class="button btn_banlist" title="Banlist">
<div class="button btn_banlist" title="{{tr 'Banlist' /}}">
<div class="icon_x32 client-ban_list"></div>
</div>
<div class="button btn_permissions" title="View/edit permissions">
<div class="button btn_permissions" title="{{tr 'View/edit permissions' /}}">
<div class="icon_x32 client-permission_overview"></div>
</div>
<div class="divider"></div>
<div class="button btn_open_settings" title="Edit global client settings">
<div class="button btn_open_settings" title="{{tr 'Edit global client settings' /}}">
<div class="icon_x32 client-settings"></div>
</div>
</div>
@ -661,8 +661,124 @@
</div>
</x-content>
</x-entry>
<x-entry>
<x-tag>
{{tr "Translations" /}}
</x-tag>
<x-content>
<div class="settings-translations">
<div class="group_box">
<div class="header">{{tr "Available translations" /}}</div>
<div class="content settings-microphone">
<div class="setting-list">
<div class="list">
<!--
<div class="entry default">{{tr "English (Default / Fallback)" /}}</div>
<div class="entry repository">
<div class="name">TeaSpeak Official</div>
<div class="button button-delete"><div class="icon client-delete"></div></div>
<div class="button button-info"><div class="icon client-about"></div></div>
</div>
<div class="entry translation selected">
<div class="name">German (Google Translate)</div>
<div class="button button-info"><div class="icon client-about"></div></div>
</div>
-->
</div>
<div class="management">
<div class="loading">Loading...</div><div class="space"></div><button class="button-add-repository">{{tr "Add repository" /}}</button>
</div>
<div class="restart-note">
<p>
{{tr "Attention: These settings get only affected after a restart or reload!" /}}
</p>
<button class="button-reload">{{tr "reload now" /}}</button>
</div>
</div>
</div>
</div>
</div>
</x-content>
</x-entry>
</x-tab>
</script>
<script class="jsrender-template" id="settings-translations-list-entry" type="text/html">
{{if type == "repository" }}
<div class="entry repository" repository="{{:id}}">
<div class="name">{{> name}}</div>
<div class="button button-delete"><div class="icon client-delete"></div></div>
<div class="button button-info"><div class="icon client-about"></div></div>
</div>
{{else type == "default" }}
<div class="entry default {{if selected}}selected{{/if}}">{{tr "English (Default / Fallback)" /}}</div>
{{else}}
<div class="entry translation {{if selected}}selected{{/if}}" parent-repository="{{:id}}">
<div class="name">{{> name}}</div>
<div class="button button-info"><div class="icon client-about"></div></div>
</div>
{{/if}}
</script>
<script class="jsrender-template" id="settings-translations-list-entry-info" type="text/html">
<div class="entry-info-container">
{{if type == "repository" }}
<!--
unique_id: string;
url: string;
name?: string;
contact?: string;
translations?: RepositoryTranslation[];
load_timestamp?: number;
-->
<div class="property property-name"><div class="key">Name:</div><div class="value">{{>name}}</div></div>
<div class="property property-url"><div class="key">URL:</div><div class="value">{{>url}}</div></div>
<div class="property property-contact"><div class="key">Contact:</div><div class="value">{{>contact}}</div></div>
<div class="property property-translations"><div class="key">Translations:</div><div class="value">{{:translations.length}}</div></div>
{{else}}
<!--
name: string;
contributors: Contributor[];
-->
<div class="property property-name"><div class="key">Name:</div><div class="value">{{>name}}</div></div>
<div class="property property-url"><div class="key">URL:</div><div class="value">{{>url}}</div></div>
<div class="property property-repository">
<div class="key">Repository:</div>
<div class="value">
<p>{{>repository_name}}</p>
<div class="button button-info"><div class="icon client-about"></div></div>
</div>
</div>
{{if !contributors }}
{{else contributors.length == 1}}
<div class="property property-contributor">
<div class="key">Contributor:</div>
<div class="value">
{{>contributors[0].name}}
{{if contributors[0].email}}
&nbsp&lt;{{>contributors[0].email}}&gt;
{{/if}}
</div>
</div>
{{else}}
<div class="property property-contributors">
<div class="key">Contributors:</div>
<div class="value">
{{for contributors}}
<div class="contributor">
{{>name}}
{{if email}}
&nbsp&lt;{{>email}}&gt;
{{/if}}
</div>
{{/for}}
</div>
</div>
{{/if}}
{{/if}}
</div>
</script>
<script class="jsrender-template" id="tmpl_change_volume" type="text/html">
<div style="display: flex; justify-content: center; vertical-align: center">
<input type="range" min="0" max="200" value="100" class="volume_slider" style="width: 100%">

View file

@ -1,10 +1,14 @@
{
"info": {
"name": "Auto translated messages for language de",
"name": "Auto translated messages for language de by the google translator",
"contributors": [
{
"name": "Google Translate, via script by Markus Hadenfeldt",
"email": "gtr.i18n.client@teaspeak.de"
},
{
"name": "Markus Hadenfeldt",
"email": "i18n.client@teaspeak.de"
}
]
},

View file

@ -3,6 +3,12 @@
{
"key": "de_DE",
"path": "de_DE.translation"
},
{
"key": "de_DE_gt",
"path": "de_DE_google_translate.translation"
}
]
],
"name": "Default TeaSpeak repository",
"contact": "i18n@teaspeak.de"
}

View file

@ -323,6 +323,8 @@ class ChatBox {
}
globalClient.serverConnection.sendMessage(text, ChatType.SERVER);
};
this.serverChat().name = tr("Server chat");
this.createChat("chat_channel", ChatType.CHANNEL).onMessageSend = (text: string) => {
if(!globalClient.serverConnection) {
chat.channelChat().appendError(tr("Could not send chant message (Not connected)"));
@ -331,6 +333,7 @@ class ChatBox {
globalClient.serverConnection.sendMessage(text, ChatType.CHANNEL, globalClient.getClient().currentChannel());
};
this.channelChat().name = tr("Channel chat");
globalClient.permissions.initializedListener.push(flag => {
if(flag) this.activeChat0(this._activeChat);

View file

@ -11,6 +11,15 @@
"verified"
]
*/
function guid() {
function s4() {
return Math.floor((1 + Math.random()) * 0x10000)
.toString(16)
.substring(1);
}
return s4() + s4() + '-' + s4() + '-' + s4() + '-' + s4() + '-' + s4() + s4() + s4();
}
namespace i18n {
interface TranslationKey {
message: string;
@ -36,10 +45,26 @@ namespace i18n {
}
interface TranslationFile {
url: string;
info: FileInfo;
translations: Translation[];
}
export interface RepositoryTranslation {
key: string;
path: string;
}
export interface TranslationRepository {
unique_id: string;
url: string;
name?: string;
contact?: string;
translations?: RepositoryTranslation[];
load_timestamp?: number;
}
let translations: Translation[] = [];
let fast_translate: { [key:string]:string; } = {};
export function tr(message: string, key?: string) {
@ -60,65 +85,238 @@ namespace i18n {
return translated;
}
export function load_file(url: string) : Promise<void> {
return new Promise<void>((resolve, reject) => {
async function load_translation_file(url: string) : Promise<TranslationFile> {
return new Promise<TranslationFile>((resolve, reject) => {
$.ajax({
url: url,
async: true,
success: result => {
console.dir(result);
const file = (typeof(result) === "string" ? JSON.parse(result) : result) as TranslationFile;
if(!file) {
reject("Invalid json");
return;
}
try {
console.dir(result);
const file = (typeof(result) === "string" ? JSON.parse(result) : result) as TranslationFile;
if(!file) {
reject("Invalid json");
return;
}
//TODO validate file
translations = file.translations;
log.info(LogCategory.I18N, tr("Successfully initialized up translation file from %s"), url);
resolve();
file.url = url;
//TODO validate file
resolve(file);
} catch(error) {
log.warn(LogCategory.I18N, tr("Failed to load translation file %s. Failed to parse or process json: %o"), url, error);
reject(tr("Failed to process or parse json!"));
}
},
error: (xhr, error) => {
log.warn(LogCategory.I18N, "Failed to load translation file from \"%s\". Error: %o", url, error);
reject("Failed to load file: " + error);
reject(tr("Failed to load file: ") + error);
}
})
});
}
export function load_file(url: string) : Promise<void> {
return load_translation_file(url).then(result => {
log.info(LogCategory.I18N, tr("Successfully initialized up translation file from %s"), url);
translations = result.translations;
return Promise.resolve();
}).catch(error => {
log.warn(LogCategory.I18N, tr("Failed to load translation file from \"%s\". Error: %o"), url, error);
return Promise.reject(error);
});
}
async function load_repository0(repo: TranslationRepository, reload: boolean) {
if(!repo.load_timestamp || repo.load_timestamp < 1000 || reload) {
const info_json = await new Promise((resolve, reject) => {
$.ajax({
url: repo.url + "/info.json",
async: true,
cache: !reload,
success: result => {
const file = (typeof(result) === "string" ? JSON.parse(result) : result) as TranslationFile;
if(!file) {
reject("Invalid json");
return;
}
resolve(file);
},
error: (xhr, error) => {
reject(tr("Failed to load file: ") + error);
}
})
});
Object.assign(repo, info_json);
}
if(!repo.unique_id)
repo.unique_id = guid();
repo.translations = repo.translations || [];
repo.load_timestamp = Date.now();
}
export async function load_repository(url: string) : Promise<TranslationRepository> {
const result = {} as TranslationRepository;
result.url = url;
await load_repository0(result, false);
return result;
}
export namespace config {
export interface TranslationConfig {
current_repository_url?: string;
current_language?: string;
current_translation_url: string;
}
export interface RepositoryConfig {
repositories?: {
url?: string;
repository?: TranslationRepository;
}[];
}
const repository_config_key = "i18n.repository";
let _cached_repository_config: RepositoryConfig;
export function repository_config() {
if(_cached_repository_config)
return _cached_repository_config;
const config_string = localStorage.getItem(repository_config_key);
const config: RepositoryConfig = config_string ? JSON.parse(config_string) : {};
config.repositories = config.repositories || [];
for(const repo of config.repositories)
(repo.repository || {load_timestamp: 0}).load_timestamp = 0;
return _cached_repository_config = config;
}
export function save_repository_config() {
localStorage.setItem(repository_config_key, JSON.stringify(_cached_repository_config));
}
const translation_config_key = "i18n.translation";
let _cached_translation_config: TranslationConfig;
export function translation_config() : TranslationConfig {
if(_cached_translation_config)
return _cached_translation_config;
const config_string = localStorage.getItem(translation_config_key);
_cached_translation_config = config_string ? JSON.parse(config_string) : {};
return _cached_translation_config;
}
export function save_translation_config() {
localStorage.setItem(translation_config_key, JSON.stringify(_cached_translation_config));
}
}
export function register_repository(repository: TranslationRepository) {
if(!repository) return;
for(const repo of config.repository_config().repositories)
if(repo.url == repository.url) return;
config.repository_config().repositories.push(repository);
config.save_repository_config();
}
export function registered_repositories() : TranslationRepository[] {
return config.repository_config().repositories.map(e => e.repository || {url: e.url, load_timestamp: 0} as TranslationRepository);
}
export function delete_repository(repository: TranslationRepository) {
if(!repository) return;
for(const repo of [...config.repository_config().repositories])
if(repo.url == repository.url) {
config.repository_config().repositories.remove(repo);
}
config.save_repository_config();
}
export function iterate_translations(callback_entry: (repository: TranslationRepository, entry: TranslationFile) => any, callback_finish: () => any) {
let count = 0;
const update_finish = () => {
console.error(count);
if(count == 0 && callback_finish)
callback_finish();
};
for(const repo of registered_repositories()) {
count++;
load_repository0(repo, false).then(() => {
for(const translation of repo.translations || []) {
const translation_path = repo.url + "/" + translation.path;
count++;
load_translation_file(translation_path).then(file => {
if(callback_entry) {
try {
callback_entry(repo, file);
} catch (error) {
console.error(error);
//TODO more error handling?
}
}
count--;
update_finish();
}).catch(error => {
log.warn(LogCategory.I18N, tr("Failed to load translation file for repository %s. Translation: %s (%s) Error: %o"), repo.name, translation.key, translation_path, error);
count--;
update_finish();
});
}
count--;
update_finish();
}).catch(error => {
log.warn(LogCategory.I18N, tr("Failed to load repository while iteration: %s (%s). Error: %o"), (repo || {name: "unknown"}).name, (repo || {url: "unknown"}).url, error);
count--;
update_finish();
});
}
update_finish();
}
export function select_translation(repository: TranslationRepository, entry: TranslationFile) {
const cfg = config.translation_config();
if(entry && repository) {
cfg.current_language = entry.info.name;
cfg.current_repository_url = repository.url;
cfg.current_translation_url = entry.url;
} else {
cfg.current_language = undefined;
cfg.current_repository_url = undefined;
cfg.current_translation_url = undefined;
}
config.save_translation_config();
}
export async function initialize() {
// await load_file("http://localhost/home/TeaSpeak/TeaSpeak/Web-Client/web/environment/development/i18n/de_DE.translation");
await load_file("http://localhost/home/TeaSpeak/TeaSpeak/Web-Client/web/environment/development/i18n/test.json");
const cfg = config.translation_config();
if(cfg.current_translation_url) {
try {
await load_file(cfg.current_translation_url);
} catch (error) {
createErrorModal(tr("Translation System"), tr("Failed to load current selected translation file.") + "<br>File: " + cfg.current_translation_url + "<br>Error: " + error + "<br>" + tr("Using default fallback translations.")).open();
}
}
// await load_file("http://localhost/home/TeaSpeak/TeaSpeak/Web-Client/web/environment/development/i18n/de_DE.translation");
// await load_file("http://localhost/home/TeaSpeak/TeaSpeak/Web-Client/web/environment/development/i18n/test.json");
}
}
const tr: typeof i18n.tr = i18n.tr;
/*
{
"info": {
"contributors": [
{
"name": "Markus Hadenfeldt",
"email": "i18n.client@teaspeak.de"
}
],
"name": "German translations"
},
"translations": [
{
"key": {
"message": "Show permission description",
"line": 374,
"character": 30,
"filename": "/home/wolverindev/TeaSpeak/TeaSpeak/Web-Client/shared/js/ui/modal/ModalPermissionEdit.ts"
},
"translated": "Berechtigungsbeschreibung anzeigen",
"flags": [
"google-translate",
"verified"
]
}
]
}
*/
const tr: typeof i18n.tr = i18n.tr;

View file

@ -92,7 +92,30 @@ function setup_jsrender() : boolean {
}
async function initialize() {
if(!setup_jsrender()) return;
const display_load_error = message => {
if(typeof(display_critical_load) !== "undefined")
display_critical_load(message);
else
displayCriticalError(message);
};
try {
if(!setup_jsrender())
throw "invalid load";
} catch (error) {
display_load_error(tr("Failed to setup jsrender"));
console.error(tr("Failed to load jsrender! %o"), error);
return;
}
try { //Initialize main template
const main = $("#tmpl_main").renderTag();
$("body").append(main);
} catch(error) {
display_load_error(tr("Failed to setup main page!"));
return;
}
try {
await i18n.initialize();
} catch(error) {

View file

@ -4,6 +4,9 @@
/// <reference path="../../voice/AudioController.ts" />
namespace Modals {
import info = log.info;
import TranslationRepository = i18n.TranslationRepository;
export function spawnSettingsModal() {
let modal;
modal = createModal({
@ -12,6 +15,7 @@ namespace Modals {
let template = $("#tmpl_settings").renderTag();
template = $.spawn("div").append(template);
initialiseSettingListeners(modal,template = template.tabify());
initialise_translations(template.find(".settings-translations"));
return template;
},
footer: () => {
@ -22,7 +26,7 @@ namespace Modals {
footer.css("text-align", "right");
let buttonOk = $.spawn("button");
buttonOk.text("Ok");
buttonOk.text(tr("Ok"));
buttonOk.click(() => modal.close());
footer.append(buttonOk);
@ -270,4 +274,173 @@ namespace Modals {
//Initialise speakers
}
function initialise_translations(tag: JQuery) {
{ //Initialize the list
const tag_list = tag.find(".setting-list .list");
const tag_loading = tag.find(".setting-list .loading");
const template = $("#settings-translations-list-entry");
const restart_hint = tag.find(".setting-list .restart-note");
restart_hint.hide();
const update_list = () => {
tag_list.empty();
const currently_selected = i18n.config.translation_config().current_translation_url;
{ //Default translation
const tag = template.renderTag({
type: "default",
selected: !currently_selected || currently_selected == "default"
});
tag.on('click', () => {
i18n.select_translation(undefined, undefined);
tag_list.find(".selected").removeClass("selected");
tag.addClass("selected");
restart_hint.show();
});
tag.appendTo(tag_list);
}
{
const display_repository_info = (repository: TranslationRepository) => {
const info_modal = createModal({
header: tr("Repository info"),
body: () => {
return $("#settings-translations-list-entry-info").renderTag({
type: "repository",
name: repository.name,
url: repository.url,
contact: repository.contact,
translations: repository.translations || []
});
},
footer: () => {
let footer = $.spawn("div");
footer.addClass("modal-button-group");
footer.css("margin-top", "5px");
footer.css("margin-bottom", "5px");
footer.css("text-align", "right");
let buttonOk = $.spawn("button");
buttonOk.text(tr("Close"));
buttonOk.click(() => info_modal.close());
footer.append(buttonOk);
return footer;
}
});
info_modal.open()
};
tag_loading.show();
i18n.iterate_translations((repo, entry) => {
let repo_tag = tag_list.find("[repository=\"" + repo.unique_id + "\"]");
if(repo_tag.length == 0) {
repo_tag = template.renderTag({
type: "repository",
name: repo.name || repo.url,
id: repo.unique_id
});
repo_tag.find(".button-delete").on('click', e => {
e.preventDefault();
Modals.spawnYesNo(tr("Are you sure?"), tr("Do you really want to delete this repository?"), answer => {
if(answer) {
i18n.delete_repository(repo);
update_list();
}
});
});
repo_tag.find(".button-info").on('click', e => {
e.preventDefault();
display_repository_info(repo);
});
tag_list.append(repo_tag);
}
const tag = template.renderTag({
type: "translation",
name: entry.info.name || entry.url,
id: repo.unique_id,
selected: i18n.config.translation_config().current_translation_url == entry.url
});
tag.find(".button-info").on('click', e => {
e.preventDefault();
const info_modal = createModal({
header: tr("Translation info"),
body: () => {
const tag = $("#settings-translations-list-entry-info").renderTag({
type: "translation",
name: entry.info.name,
url: entry.url,
repository_name: repo.name,
contributors: entry.info.contributors || []
});
tag.find(".button-info").on('click', () => display_repository_info(repo));
return tag;
},
footer: () => {
let footer = $.spawn("div");
footer.addClass("modal-button-group");
footer.css("margin-top", "5px");
footer.css("margin-bottom", "5px");
footer.css("text-align", "right");
let buttonOk = $.spawn("button");
buttonOk.text(tr("Close"));
buttonOk.click(() => info_modal.close());
footer.append(buttonOk);
return footer;
}
});
info_modal.open()
});
tag.on('click', e => {
if(e.isDefaultPrevented()) return;
i18n.select_translation(repo, entry);
tag_list.find(".selected").removeClass("selected");
tag.addClass("selected");
restart_hint.show();
});
tag.insertAfter(repo_tag)
}, () => {
tag_loading.hide();
});
}
};
{
tag.find(".button-add-repository").on('click', () => {
createInputModal("Enter URL", tr("Enter repository URL:<br>"), text => true, url => { //FIXME test valid url
if(!url) return;
tag_loading.show();
i18n.load_repository(url as string).then(repository => {
i18n.register_repository(repository);
update_list();
}).catch(error => {
tag_loading.hide();
createErrorModal("Failed to load repository", tr("Failed to query repository.<br>Ensure that this repository is valid and reachable.<br>Error: ") + error).open();
})
}).open();
});
}
restart_hint.find(".button-reload").on('click', () => {
location.reload();
});
update_list();
}
}
}