mirror of
https://github.com/FrankerFaceZ/FrankerFaceZ.git
synced 2025-07-02 17:18:31 +00:00
4.12.5
* Added: Localization Message Capture. Message capture is a developer feature intended to make it easier to add new strings to the localization project. * Added: Translation Tester. A tool to allow translators to test translations directly in the app before submitting them to the localization project. * Fixed: Modified emotes not appearing correctly in tool-tips.
This commit is contained in:
parent
ebb954e6c1
commit
f4c989561e
25 changed files with 521 additions and 82 deletions
17
package-lock.json
generated
17
package-lock.json
generated
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "frankerfacez",
|
||||
"version": "4.12.3",
|
||||
"version": "4.12.5",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
|
@ -7475,6 +7475,21 @@
|
|||
"integrity": "sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM=",
|
||||
"dev": true
|
||||
},
|
||||
"sourcemapped-stacktrace": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/sourcemapped-stacktrace/-/sourcemapped-stacktrace-1.1.11.tgz",
|
||||
"integrity": "sha512-O0pcWjJqzQFVsisPlPXuNawJHHg9N9UgpJ/aDmvi9+vnS3x1C0NhwkVFzzZ1VN0Xo+bekyweoqYvBw5ZBKiNnQ==",
|
||||
"requires": {
|
||||
"source-map": "0.5.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"source-map": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz",
|
||||
"integrity": "sha1-dc449SvwczxafwwRjYEzSiu19BI="
|
||||
}
|
||||
}
|
||||
},
|
||||
"spdx-correct": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.0.tgz",
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "frankerfacez",
|
||||
"author": "Dan Salvato LLC",
|
||||
"version": "4.12.4",
|
||||
"version": "4.12.5",
|
||||
"description": "FrankerFaceZ is a Twitch enhancement suite.",
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
|
@ -79,6 +79,7 @@
|
|||
"react": "^16.4.1",
|
||||
"safe-regex": "^2.0.2",
|
||||
"sortablejs": "^1.10.0-rc3",
|
||||
"sourcemapped-stacktrace": "^1.1.11",
|
||||
"vue": "^2.6.10",
|
||||
"vue-clickaway": "^2.2.2",
|
||||
"vue-color": "^2.4.6",
|
||||
|
|
230
src/i18n.js
230
src/i18n.js
|
@ -6,12 +6,27 @@
|
|||
|
||||
import Parser from '@ffz/icu-msgparser';
|
||||
|
||||
import {SERVER} from 'utilities/constants';
|
||||
import {get, pick_random, timeout} from 'utilities/object';
|
||||
import {SERVER, DEBUG} from 'utilities/constants';
|
||||
import {get, pick_random, shallow_copy, deep_copy} from 'utilities/object';
|
||||
import Module from 'utilities/module';
|
||||
|
||||
import NewTransCore from 'utilities/translation-core';
|
||||
|
||||
const STACK_SPLITTER = /\s*at\s+(.+?)\s+\((.+)\)$/,
|
||||
SOURCE_SPLITTER = /^(.+):\/\/(.+?):(\d+:\d+)$/;
|
||||
|
||||
const MAP_OPTIONS = {
|
||||
filter(line) {
|
||||
return line.includes('.frankerfacez.com') || line.includes('localhost');
|
||||
},
|
||||
cacheGlobally: true
|
||||
};
|
||||
|
||||
const BAD_FRAMES = [
|
||||
'/src/i18n.js',
|
||||
'/src/utilities/vue.js'
|
||||
]
|
||||
|
||||
const FACES = ['(・`ω´・)', ';;w;;', 'owo', 'ono', 'oAo', 'oxo', 'ovo;', 'UwU', '>w<', '^w^', '> w >', 'v.v'],
|
||||
|
||||
transformText = (ast, fn) => ast.map(node => {
|
||||
|
@ -68,6 +83,33 @@ export class TranslationManager extends Module {
|
|||
ja: { name: '日本語' }*/
|
||||
}
|
||||
|
||||
this.capturing = false;
|
||||
this.captured = new Map;
|
||||
|
||||
this.settings.addUI('i18n.debug.open', {
|
||||
path: 'Debugging > Localization >> Editing',
|
||||
component: 'i18n-open',
|
||||
force_seen: true
|
||||
});
|
||||
|
||||
this.settings.add('i18n.debug.capture', {
|
||||
default: null,
|
||||
process(ctx, val) {
|
||||
if ( val === null )
|
||||
return DEBUG;
|
||||
return val;
|
||||
},
|
||||
ui: {
|
||||
path: 'Debugging > Localization >> General',
|
||||
title: 'Enable message capture.',
|
||||
description: 'Capture all localized strings, including variables and call locations, for the purpose of reporting them to the backend. This is used to add new strings to the translation project. By default, message capture is enabled when running in development mode.',
|
||||
component: 'setting-check-box',
|
||||
force_seen: true
|
||||
},
|
||||
changed: val => {
|
||||
this.capturing = val;
|
||||
}
|
||||
});
|
||||
|
||||
this.settings.add('i18n.debug.transform', {
|
||||
default: null,
|
||||
|
@ -128,6 +170,8 @@ export class TranslationManager extends Module {
|
|||
}
|
||||
|
||||
onEnable() {
|
||||
this.capturing = this.settings.get('i18n.debug.capture');
|
||||
|
||||
this._ = new NewTransCore({ //TranslationCore({
|
||||
warn: (...args) => this.log.warn(...args),
|
||||
});
|
||||
|
@ -142,6 +186,79 @@ export class TranslationManager extends Module {
|
|||
this.locale = this.settings.get('i18n.locale');
|
||||
}
|
||||
|
||||
broadcast(msg) {
|
||||
if ( this._broadcaster )
|
||||
this._broadcaster.postMessage(msg);
|
||||
}
|
||||
|
||||
getKeys() {
|
||||
return deep_copy(Array.from(this.captured.values()));
|
||||
}
|
||||
|
||||
requestKeys() {
|
||||
this.broadcast({type: 'request-keys'});
|
||||
}
|
||||
|
||||
updatePhrase(key, phrase) {
|
||||
this.broadcast({
|
||||
type: 'update-key',
|
||||
key,
|
||||
phrase
|
||||
});
|
||||
|
||||
this._.extend({
|
||||
[key]: phrase
|
||||
});
|
||||
|
||||
this.emit(':loaded', [key]);
|
||||
this.emit(':update');
|
||||
}
|
||||
|
||||
handleMessage(event) {
|
||||
const msg = event.data;
|
||||
if ( ! msg )
|
||||
return;
|
||||
|
||||
if ( msg.type === 'update-key' ) {
|
||||
this._.extend({
|
||||
[msg.key]: msg.phrase
|
||||
});
|
||||
|
||||
this.emit(':loaded', [msg.key]);
|
||||
this.emit(':update');
|
||||
|
||||
} else if ( msg.type === 'request-keys' )
|
||||
this.broadcast({
|
||||
type: 'keys',
|
||||
data: Array.from(this.captured.values())
|
||||
});
|
||||
|
||||
else if ( msg.type === 'keys' && Array.isArray(msg.data) ) {
|
||||
for(const entry of msg.data) {
|
||||
// TODO: Merging logic.
|
||||
this.captured.set(entry.key, entry);
|
||||
}
|
||||
|
||||
this.emit(':got-keys');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
openUI(popout = true) {
|
||||
// Override the capturing state when we open the UI.
|
||||
if ( ! this.capturing ) {
|
||||
this.capturing = true;
|
||||
this.emit(':update');
|
||||
}
|
||||
|
||||
const mod = this.resolve('translation_ui');
|
||||
if ( popout )
|
||||
mod.openPopout();
|
||||
else
|
||||
mod.enable();
|
||||
}
|
||||
|
||||
|
||||
get locale() {
|
||||
return this._.locale;
|
||||
}
|
||||
|
@ -151,56 +268,91 @@ export class TranslationManager extends Module {
|
|||
}
|
||||
|
||||
|
||||
handleMessage(event) {
|
||||
const msg = event.data;
|
||||
if ( ! msg )
|
||||
see(key, phrase, options) {
|
||||
if ( ! this.capturing )
|
||||
return;
|
||||
|
||||
if ( msg.type === 'seen' )
|
||||
this.see(msg.key, true);
|
||||
|
||||
else if ( msg.type === 'request-keys' ) {
|
||||
this.broadcast({type: 'keys', keys: Array.from(this._seen)})
|
||||
}
|
||||
|
||||
else if ( msg.type === 'keys' )
|
||||
this.emit(':receive-keys', msg.keys);
|
||||
}
|
||||
|
||||
|
||||
async getKeys() {
|
||||
this.broadcast({type: 'request-keys'});
|
||||
|
||||
let data;
|
||||
|
||||
let stack;
|
||||
try {
|
||||
data = await timeout(this.waitFor(':receive-keys'), 100);
|
||||
} catch(err) { /* no-op */ }
|
||||
stack = new Error().stack;
|
||||
} catch(err) {
|
||||
/* :thinking: */
|
||||
try {
|
||||
stack = err.stack;
|
||||
} catch(err_again) { /* aww */ }
|
||||
}
|
||||
|
||||
if ( data )
|
||||
for(const val of data)
|
||||
this._seen.add(val);
|
||||
let store = this.captured.get(key);
|
||||
if ( ! store )
|
||||
this.captured.set(key, store = {key, phrase, hits: 0, calls: []});
|
||||
|
||||
return this._seen;
|
||||
|
||||
store.options = this.pluckVariables(key, options);
|
||||
store.hits++;
|
||||
|
||||
if ( stack ) {
|
||||
if ( this.mapStackTrace )
|
||||
this.mapStackTrace(stack, result => this.recordCall(store, result), MAP_OPTIONS);
|
||||
else
|
||||
import(/* webpackChunkName: 'translation-ui' */ 'sourcemapped-stacktrace').then(mod => {
|
||||
this.mapStackTrace = mod.mapStackTrace;
|
||||
this.mapStackTrace(stack, result => this.recordCall(store, result), MAP_OPTIONS);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
broadcast(msg) {
|
||||
if ( this._broadcaster )
|
||||
this._broadcaster.postMessage(msg)
|
||||
pluckVariables(key, options) {
|
||||
const ast = this._.cache.get(key);
|
||||
if ( ! ast )
|
||||
return null;
|
||||
|
||||
const out = {};
|
||||
this._doPluck(ast, options, out);
|
||||
if ( Object.keys(out).length )
|
||||
return out;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
_doPluck(ast, options, out) {
|
||||
if ( Array.isArray(ast) ) {
|
||||
for(const val of ast)
|
||||
this._doPluck(val, options, out);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ( typeof ast === 'object' && ast.v )
|
||||
out[ast.v] = shallow_copy(get(ast.v, options));
|
||||
}
|
||||
|
||||
|
||||
see(key, from_broadcast = false) {
|
||||
if ( this._seen.has(key) )
|
||||
recordCall(store, stack) { // eslint-disable-line class-methods-use-this
|
||||
if ( ! Array.isArray(stack) )
|
||||
return;
|
||||
|
||||
this._seen.add(key);
|
||||
this.emit(':seen', key);
|
||||
for(const line of stack) {
|
||||
const match = STACK_SPLITTER.exec(line);
|
||||
if ( ! match )
|
||||
continue;
|
||||
|
||||
if ( ! from_broadcast )
|
||||
this.broadcast({type: 'seen', key});
|
||||
const location = SOURCE_SPLITTER.exec(match[2]);
|
||||
if ( ! location || location[1] !== 'webpack' )
|
||||
continue;
|
||||
|
||||
const file = location[2];
|
||||
if ( file.includes('/node_modules/') || BAD_FRAMES.includes(file) )
|
||||
continue;
|
||||
|
||||
const out = `${match[1]} (${location[2]}:${location[3]})`;
|
||||
if ( ! store.calls.includes(out) )
|
||||
store.calls.push(out);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async loadLocale(locale) {
|
||||
if ( locale === 'en' )
|
||||
|
@ -414,12 +566,12 @@ export class TranslationManager extends Module {
|
|||
}
|
||||
|
||||
t(key, ...args) {
|
||||
this.see(key);
|
||||
this.see(key, ...args);
|
||||
return this._.t(key, ...args);
|
||||
}
|
||||
|
||||
tList(key, ...args) {
|
||||
this.see(key);
|
||||
this.see(key, ...args);
|
||||
return this._.tList(key, ...args);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1028,7 +1028,7 @@ export const AddonEmotes = {
|
|||
const set_id = this.emotes.getTwitchEmoteSet(emote_id, tip.rerender),
|
||||
emote_set = set_id != null && this.emotes.getTwitchSetChannel(set_id, tip.rerender);
|
||||
|
||||
preview = `//static-cdn.jtvnw.net/emoticons/v1/${emote_id}/3.0?_=preview`;
|
||||
preview = `//static-cdn.jtvnw.net/emoticons/v1/${ds.id}/3.0?_=preview`;
|
||||
fav_source = 'twitch';
|
||||
|
||||
if ( emote_set ) {
|
||||
|
|
39
src/modules/main_menu/components/i18n-open.vue
Normal file
39
src/modules/main_menu/components/i18n-open.vue
Normal file
|
@ -0,0 +1,39 @@
|
|||
<template lang="html">
|
||||
<div>
|
||||
<div class="tw-mg-b-1">
|
||||
<markdown :source="t('i18n.interest', md)" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="tw-button"
|
||||
@click="open"
|
||||
>
|
||||
<span class="tw-button__icon tw-button__icon--left">
|
||||
<figure class="ffz-i-language" />
|
||||
</span>
|
||||
<span class="tw-button__text">
|
||||
{{ t('i18n.ui.open', 'Open Translation Tester') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import I18N_MD from '../i18n.md';
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
md: I18N_MD
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
open() {
|
||||
window.FrankerFaceZ.get().resolve('i18n').openUI();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
|
@ -1,13 +1,13 @@
|
|||
<template lang="html">
|
||||
<div v-if="item.contents" :class="classes">
|
||||
<header v-if="! item.no_header">
|
||||
{{ t(item.i18n_key, item.title, item) }}
|
||||
{{ t(item.i18n_key, item.title) }}
|
||||
</header>
|
||||
<section
|
||||
v-if="item.description"
|
||||
class="tw-pd-b-1"
|
||||
>
|
||||
<markdown :source="t(item.desc_i18n_key, item.description, item)" />
|
||||
<markdown :source="t(item.desc_i18n_key, item.description)" />
|
||||
</section>
|
||||
<div
|
||||
v-for="i in item.contents"
|
||||
|
|
|
@ -2,8 +2,8 @@
|
|||
<div class="ffz--menu-page">
|
||||
<header class="tw-mg-b-1">
|
||||
<span v-for="i in breadcrumbs" :key="i.full_key">
|
||||
<a v-if="i !== item" href="#" @click="$emit('change-item', i, false)">{{ t(i.i18n_key, i.title, i) }}</a>
|
||||
<strong v-if="i === item">{{ t(i.i18n_key, i.title, i) }}</strong>
|
||||
<a v-if="i !== item" href="#" @click="$emit('change-item', i, false)">{{ t(i.i18n_key, i.title) }}</a>
|
||||
<strong v-if="i === item">{{ t(i.i18n_key, i.title) }}</strong>
|
||||
<template v-if="i !== item">» </template>
|
||||
</span>
|
||||
</header>
|
||||
|
@ -34,7 +34,7 @@
|
|||
v-if="item.description"
|
||||
class="tw-border-t tw-pd-y-1"
|
||||
>
|
||||
<markdown :source="t(item.desc_i18n_key || `${item.i18n_key}.description`, item.description, item)" />
|
||||
<markdown :source="t(item.desc_i18n_key || `${item.i18n_key}.description`, item.description)" />
|
||||
</section>
|
||||
<template v-if="! item.contents || ! item.contents.length">
|
||||
<ul class="tw-border-t tw-pd-y-1">
|
||||
|
@ -45,7 +45,7 @@
|
|||
class="tw-pd-x-1"
|
||||
>
|
||||
<a href="#" @click="$emit('change-item', i, false)">
|
||||
{{ t(i.i18n_key, i.title, i) }}
|
||||
{{ t(i.i18n_key, i.title) }}
|
||||
<span v-if="i.unseen" class="tw-pill">{{ i.unseen }}</span>
|
||||
</a>
|
||||
</li>
|
||||
|
|
|
@ -32,10 +32,10 @@
|
|||
class="arrow"
|
||||
/>
|
||||
<span class="tw-flex-grow-1">
|
||||
{{ t(item.i18n_key, item.title, item) }}
|
||||
{{ t(item.i18n_key, item.title) }}
|
||||
</span>
|
||||
<span v-if="item.pill" class="tw-pill">
|
||||
{{ item.pill_i18n_key ? t(item.pill_i18n_key, item.pill, item) : item.pill }}
|
||||
{{ item.pill_i18n_key ? t(item.pill_i18n_key, item.pill) : item.pill }}
|
||||
</span>
|
||||
<span v-else-if="item.unseen" class="tw-pill">
|
||||
{{ item.unseen }}
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
>
|
||||
|
||||
<label :for="item.full_key" class="tw-checkbox__label">
|
||||
{{ t(item.i18n_key, item.title, item) }}
|
||||
{{ t(item.i18n_key, item.title) }}
|
||||
<span v-if="unseen" class="tw-pill">{{ t('setting.new', 'New') }}</span>
|
||||
</label>
|
||||
|
||||
|
@ -42,7 +42,7 @@
|
|||
class="tw-c-text-alt-2"
|
||||
style="padding-left:2.2rem"
|
||||
>
|
||||
<markdown :source="t(item.desc_i18n_key || `${item.i18n_key}.description`, item.description, item)" />
|
||||
<markdown :source="t(item.desc_i18n_key || `${item.i18n_key}.description`, item.description)" />
|
||||
</section>
|
||||
<section
|
||||
v-if="item.extra"
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
>
|
||||
<div class="tw-flex tw-align-items-center">
|
||||
<label :for="item.full_key">
|
||||
{{ t(item.i18n_key, item.title, item) }}
|
||||
{{ t(item.i18n_key, item.title) }}
|
||||
<span v-if="unseen" class="tw-pill">{{ t('setting.new', 'New') }}</span>
|
||||
</label>
|
||||
|
||||
|
@ -40,7 +40,7 @@
|
|||
v-if="item.description"
|
||||
class="tw-c-text-alt-2"
|
||||
>
|
||||
<markdown :source="t(item.desc_i18n_key || `${item.i18n_key}.description`, item.description, item)" />
|
||||
<markdown :source="t(item.desc_i18n_key || `${item.i18n_key}.description`, item.description)" />
|
||||
</section>
|
||||
<section v-if="item.extra">
|
||||
<component :is="item.extra.component" :context="context" :item="item" />
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
>
|
||||
<div class="tw-flex tw-align-items-start">
|
||||
<label :for="item.full_key" class="tw-mg-y-05">
|
||||
{{ t(item.i18n_key, item.title, item) }}
|
||||
{{ t(item.i18n_key, item.title) }}
|
||||
<span v-if="unseen" class="tw-pill">{{ t('setting.new', 'New') }}</span>
|
||||
</label>
|
||||
|
||||
|
@ -58,7 +58,7 @@
|
|||
v-if="item.description"
|
||||
class="tw-c-text-alt-2"
|
||||
>
|
||||
<markdown :source="t(item.desc_i18n_key || `${item.i18n_key}.description`, item.description, item)" />
|
||||
<markdown :source="t(item.desc_i18n_key || `${item.i18n_key}.description`, item.description)" />
|
||||
</section>
|
||||
<section v-if="item.extra">
|
||||
<component :is="item.extra.component" :context="context" :item="item" />
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<template lang="html">
|
||||
<div class="ffz--widget ffz--hotkey-input">
|
||||
<label :for="item.full_key">
|
||||
{{ t(item.i18n_key, item.title, item) }}
|
||||
{{ t(item.i18n_key, item.title) }}
|
||||
</label>
|
||||
<div class="tw-relative">
|
||||
<div class="tw-input__icon-group tw-input__icon-group--right">
|
||||
|
@ -24,7 +24,7 @@
|
|||
v-if="item.description"
|
||||
class="tw-c-text-alt-2"
|
||||
>
|
||||
<markdown :source="t(item.desc_i18n_key || `${item.i18n_key}.description`, item.description, item)" />
|
||||
<markdown :source="t(item.desc_i18n_key || `${item.i18n_key}.description`, item.description)" />
|
||||
</section>
|
||||
<section v-if="item.extra">
|
||||
<component :is="item.extra.component" :context="context" :item="item" />
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
<template lang="html">
|
||||
<div class="tw-input">
|
||||
<header>
|
||||
{{ t(item.i18n_key, item.title, item) }}
|
||||
{{ t(item.i18n_key, item.title) }}
|
||||
<span v-if="unseen" class="tw-pill">{{ t('setting.new', 'New') }}</span>
|
||||
</header>
|
||||
<section
|
||||
v-if="item.description"
|
||||
class="tw-c-text-alt-2"
|
||||
>
|
||||
<markdown :source="t(item.desc_i18n_key || `${item.i18n_key}.description`, item.description, item)" />
|
||||
<markdown :source="t(item.desc_i18n_key || `${item.i18n_key}.description`, item.description)" />
|
||||
</section>
|
||||
<section v-if="item.extra">
|
||||
<component :is="item.extra.component" :context="context" :item="item" />
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
>
|
||||
<div class="tw-flex tw-align-items-center">
|
||||
<label :for="item.full_key">
|
||||
{{ t(item.i18n_key, item.title, item) }}
|
||||
{{ t(item.i18n_key, item.title) }}
|
||||
<span v-if="unseen" class="tw-pill">{{ t('setting.new', 'New') }}</span>
|
||||
</label>
|
||||
|
||||
|
@ -46,7 +46,7 @@
|
|||
v-if="item.description"
|
||||
class="tw-c-text-alt-2"
|
||||
>
|
||||
<markdown :source="t(item.desc_i18n_key || `${item.i18n_key}.description`, item.description, item)" />
|
||||
<markdown :source="t(item.desc_i18n_key || `${item.i18n_key}.description`, item.description)" />
|
||||
</section>
|
||||
<section v-if="item.extra">
|
||||
<component :is="item.extra.component" :context="context" :item="item" />
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
>
|
||||
<div class="tw-flex tw-align-items-center">
|
||||
<label :for="item.full_key">
|
||||
{{ t(item.i18n_key, item.title, item) }}
|
||||
{{ t(item.i18n_key, item.title) }}
|
||||
<span v-if="unseen" class="tw-pill">{{ t('setting.new', 'New') }}</span>
|
||||
</label>
|
||||
|
||||
|
@ -39,7 +39,7 @@
|
|||
v-if="item.description"
|
||||
class="tw-c-text-alt-2"
|
||||
>
|
||||
<markdown :source="t(item.desc_i18n_key || `${item.i18n_key}.description`, item.description, item)" />
|
||||
<markdown :source="t(item.desc_i18n_key || `${item.i18n_key}.description`, item.description)" />
|
||||
</section>
|
||||
<section v-if="item.extra">
|
||||
<component :is="item.extra.component" :context="context" :item="item" />
|
||||
|
|
3
src/modules/main_menu/i18n.md
Normal file
3
src/modules/main_menu/i18n.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
Interested in helping out with the localization efforts? Please [join our Discord](https://discord.gg/UrAkGhT) and join the chat in the localization channel.
|
||||
|
||||
This tool allows translators to test their strings in real-time in the app. That way you can be sure everything looks right before submitting your translations.
|
|
@ -364,7 +364,7 @@ export default class MainMenu extends Module {
|
|||
|
||||
for(const [setting_key, def] of token.settings)
|
||||
if ( def.ui ) { //} && def.ui.title ) {
|
||||
const i18n_key = `${token.i18n_key}.${def.ui.key}`
|
||||
const i18n_key = def.ui.i18n_key ? def.ui.i18n_key : setting_key ? `setting.entry.${setting_key}` : def.ui.key ? `${token.i18n_key}.${def.ui.key}` : token.i18n_key;
|
||||
const tok = Object.assign({
|
||||
i18n_key,
|
||||
desc_i18n_key: `${i18n_key}.description`,
|
||||
|
@ -387,17 +387,17 @@ export default class MainMenu extends Module {
|
|||
|
||||
let terms = [
|
||||
setting_key,
|
||||
this.i18n.t(tok.i18n_key, tok.title, tok, true)
|
||||
this.i18n.t(tok.i18n_key, tok.title, null, true)
|
||||
];
|
||||
|
||||
if ( have_locale && this.i18n.has(tok.i18n_key) )
|
||||
terms.push(this.i18n.t(tok.i18n_key, tok.title, tok));
|
||||
terms.push(this.i18n.t(tok.i18n_key, tok.title, null));
|
||||
|
||||
if ( tok.description ) {
|
||||
terms.push(this.i18n.t(tok.desc_i18n_key, tok.description, tok, true));
|
||||
terms.push(this.i18n.t(tok.desc_i18n_key, tok.description, null, true));
|
||||
|
||||
if ( have_locale && this.i18n.has(tok.desc_i18n_key) )
|
||||
terms.push(this.i18n.t(tok.desc_i18n_key, tok.description, tok));
|
||||
terms.push(this.i18n.t(tok.desc_i18n_key, tok.description, null));
|
||||
}
|
||||
|
||||
if ( tok.getExtraTerms )
|
||||
|
@ -425,20 +425,20 @@ export default class MainMenu extends Module {
|
|||
}
|
||||
|
||||
if ( ! token.search_terms ) {
|
||||
const formatted = this.i18n.t(token.i18n_key, token.title, token, true);
|
||||
const formatted = this.i18n.t(token.i18n_key, token.title, null, true);
|
||||
let terms = [token.key];
|
||||
|
||||
if ( formatted && formatted.localeCompare(token.key, undefined, {sensitivity: 'base'}) )
|
||||
terms.push(formatted);
|
||||
|
||||
if ( have_locale && this.i18n.has(token.i18n_key) )
|
||||
terms.push(this.i18n.t(token.i18n_key, token.title, token));
|
||||
terms.push(this.i18n.t(token.i18n_key, token.title, null));
|
||||
|
||||
if ( token.description ) {
|
||||
terms.push(this.i18n.t(token.desc_i18n_key, token.description, token, true));
|
||||
terms.push(this.i18n.t(token.desc_i18n_key, token.description, null, true));
|
||||
|
||||
if ( have_locale && this.i18n.has(token.desc_i18n_key) )
|
||||
terms.push(this.i18n.t(token.desc_i18n_key, token.description, token));
|
||||
terms.push(this.i18n.t(token.desc_i18n_key, token.description, null));
|
||||
}
|
||||
|
||||
terms = terms.map(format_term);
|
||||
|
|
103
src/modules/translation_ui/components/i18n-entry.vue
Normal file
103
src/modules/translation_ui/components/i18n-entry.vue
Normal file
|
@ -0,0 +1,103 @@
|
|||
<template lang="html">
|
||||
<div class="ffz--i18n-entry tw-pd-x-1 tw-pd-y-05 tw-border-b">
|
||||
<div class="tw-flex tw-full-width">
|
||||
<div class="ffz--i18n-sub-entry tw-mg-r-05 tw-flex-grow-1 tw-c-text-alt tw-mg-b-2">
|
||||
<div class="tw-font-size-7 tw-c-text-alt-2 tw-pd-b-05 tw-strong tw-upcase tw-ellipsis" :title="entry.key">
|
||||
{{ entry.key }}
|
||||
</div>
|
||||
<code>{{ entry.phrase }}</code>
|
||||
</div>
|
||||
<div class="ffz--i18n-sub-entry tw-flex-grow-1">
|
||||
<textarea
|
||||
v-model="value"
|
||||
:class="{'tw-textarea--error': ! valid}"
|
||||
class="tw-block tw-font-size-6 tw-full-width tw-full-height tw-textarea"
|
||||
@input="onInput"
|
||||
/>
|
||||
</div>
|
||||
<div class="ffz--i18n-sub-entry tw-mg-l-05 tw-flex-grow-1">
|
||||
<div v-if="error">
|
||||
<div class="tw-strong">{{ t('i18n.ui.error', 'Error') }}</div>
|
||||
<code class="tw-font-size-7 tw-c-text-alt-2">{{ error }}</code>
|
||||
</div>
|
||||
<div v-if="source">
|
||||
<div class="tw-strong">{{ t('i18n.ui.source', 'Source') }}</div>
|
||||
<code class="tw-font-size-7 tw-c-text-alt-2">{{ source }}</code>
|
||||
</div>
|
||||
<div v-if="context">
|
||||
<div class="tw-strong">{{ t('i18n.ui.context', 'Context') }}</div>
|
||||
<code class="tw-font-size-7 tw-c-text-alt-2">{{ context }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import Parser from '@ffz/icu-msgparser';
|
||||
import { debounce } from 'utilities/object';
|
||||
|
||||
const parser = new Parser();
|
||||
|
||||
export default {
|
||||
props: ['entry'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
value: this.entry.phrase,
|
||||
valid: true,
|
||||
error: null
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
source() {
|
||||
const calls = this.entry.calls;
|
||||
if ( ! Array.isArray(calls) || ! calls.length )
|
||||
return null;
|
||||
|
||||
return calls.join('\n');
|
||||
},
|
||||
|
||||
context() {
|
||||
const opts = this.entry.options;
|
||||
if ( ! opts || typeof opts !== 'object' )
|
||||
return null;
|
||||
|
||||
const lines = [];
|
||||
for(const [key, val] of Object.entries(opts))
|
||||
lines.push(`${key}: ${JSON.stringify(val)}`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
this.validate();
|
||||
this.onInput = debounce(this.onInput, 250);
|
||||
},
|
||||
|
||||
methods: {
|
||||
onInput() {
|
||||
this.validate();
|
||||
if ( this.valid )
|
||||
this.$emit('update', this.value);
|
||||
},
|
||||
|
||||
validate() {
|
||||
try {
|
||||
parser.parse(this.value);
|
||||
} catch(err) {
|
||||
this.error = err;
|
||||
this.valid = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.error = null;
|
||||
this.valid = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
|
@ -4,7 +4,7 @@
|
|||
class="ffz-dialog tw-elevation-3 tw-c-background-alt tw-c-text-base tw-border tw-flex tw-flex-nowrap tw-flex-column"
|
||||
>
|
||||
<header class="tw-c-background-base tw-full-width tw-align-items-center tw-flex tw-flex-nowrap" @dblclick="resize">
|
||||
<h3 class="ffz-i-zreknarf ffz-i-pd-1">{{ t('i18n.ui.title', 'Translation Editor') }}</h3>
|
||||
<h3 class="ffz-i-zreknarf ffz-i-pd-1">{{ t('i18n.ui.title', 'Translation Tester') }}</h3>
|
||||
<div class="tw-flex-grow-1 tw-pd-x-2">
|
||||
<div class="tw-search-input">
|
||||
<label for="ffz-main-menu.search" class="tw-hide-accessible">{{ t('i18n.ui.search', 'Search Strings') }}</label>
|
||||
|
@ -26,6 +26,14 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="tw-button-icon tw-mg-x-05 tw-relative tw-tooltip-wrapper" @click="requestKeys">
|
||||
<span class="tw-button-icon__icon">
|
||||
<figure class="ffz-i-arrows-cw" />
|
||||
</span>
|
||||
<div class="tw-tooltip tw-tooltip--down tw-tooltip--align-right">
|
||||
{{ t('i18n.ui.refresh', 'Refresh Strings') }}
|
||||
</div>
|
||||
</button>
|
||||
<button v-if="!maximized && !exclusive" class="tw-button-icon tw-mg-x-05" @click="faded = ! faded">
|
||||
<span class="tw-button-icon__icon">
|
||||
<figure :class="faded ? 'ffz-i-eye-off' : 'ffz-i-eye'" />
|
||||
|
@ -51,9 +59,14 @@
|
|||
</button>
|
||||
</header>
|
||||
<section class="tw-border-t tw-full-height tw-full-width tw-flex tw-overflow-hidden">
|
||||
<div v-for="(key, idx) of phrases" :key="idx" class="tw-block tw-mg-1">
|
||||
{{ key }}
|
||||
</div>
|
||||
<simplebar classes="tw-flex-grow-1">
|
||||
<i18n-entry
|
||||
v-for="phrase in filtered"
|
||||
:key="phrase.key"
|
||||
:entry="phrase"
|
||||
@update="update(phrase.key, $event)"
|
||||
/>
|
||||
</simplebar>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -70,6 +83,21 @@ export default {
|
|||
computed: {
|
||||
filter() {
|
||||
return this.query.toLowerCase()
|
||||
},
|
||||
|
||||
filtered() {
|
||||
if ( ! this.query || ! this.query.length )
|
||||
return this.phrases;
|
||||
|
||||
return this.phrases.filter(entry => {
|
||||
if ( entry.key.toLowerCase().includes(this.query) )
|
||||
return true;
|
||||
|
||||
if ( entry.phrase.toLowerCase().includes(this.query) )
|
||||
return true;
|
||||
|
||||
return false;
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -79,6 +107,13 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
this.requestKeys();
|
||||
this.grabKeys();
|
||||
|
||||
this.listen('i18n:got-keys', this.grabKeys, this);
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.updateDrag();
|
||||
|
||||
|
@ -93,9 +128,22 @@ export default {
|
|||
window.removeEventListener('resize', this._on_resize);
|
||||
this._on_resize = null;
|
||||
}
|
||||
|
||||
this.unlisten('i18n:got-keys', this.grabKeys, this);
|
||||
},
|
||||
|
||||
methods: {
|
||||
grabKeys() {
|
||||
this.phrases = this.getKeys();
|
||||
this.phrases.sort((a, b) => {
|
||||
return a.key.localeCompare(b.key)
|
||||
});
|
||||
},
|
||||
|
||||
update(key, phrase) {
|
||||
this.updatePhrase(key, phrase);
|
||||
},
|
||||
|
||||
updateDrag() {
|
||||
if ( this.maximized )
|
||||
this.destroyDrag();
|
||||
|
|
|
@ -70,15 +70,13 @@ export default class TranslationUI extends Module {
|
|||
}
|
||||
|
||||
|
||||
async buildDialog() {
|
||||
buildDialog() {
|
||||
if ( this._dialog )
|
||||
return this._dialog;
|
||||
|
||||
const data = await this.getData();
|
||||
|
||||
this._vue = new this.vue.Vue({
|
||||
el: createElement('div'),
|
||||
render: h => h('translation-ui', data)
|
||||
render: h => h('translation-ui', this.getData())
|
||||
});
|
||||
|
||||
return this._dialog = this._vue.$el;
|
||||
|
@ -92,15 +90,21 @@ export default class TranslationUI extends Module {
|
|||
}
|
||||
|
||||
|
||||
async getData() {
|
||||
getData() {
|
||||
return {
|
||||
phrases: this.i18n.getKeys(),
|
||||
query: '',
|
||||
|
||||
faded: false,
|
||||
maximized: this.dialog.maximized,
|
||||
exclusive: this.dialog.exclusive,
|
||||
|
||||
phrases: Array.from(await this.i18n.getKeys()),
|
||||
getKeys: () => this.i18n.getKeys(),
|
||||
requestKeys: () => this.i18n.requestKeys(),
|
||||
updatePhrase: (key, phrase) => this.i18n.updatePhrase(key, phrase),
|
||||
|
||||
listen: (event, fn, ctx) => this.on(event, fn, ctx),
|
||||
unlisten: (event, fn, ctx) => this.off(event, fn, ctx),
|
||||
|
||||
resize: e => ! this.dialog.exclusive && this.dialog.toggleSize(e),
|
||||
close: e => ! this.dialog.exclusive && this.dialog.toggleVisible(e),
|
||||
|
|
|
@ -454,6 +454,9 @@ export default class SettingsManager extends Module {
|
|||
parse_path(ui.path) :
|
||||
undefined;
|
||||
|
||||
if ( ! ui.key && key )
|
||||
ui.key = key;
|
||||
|
||||
if ( ! ui.key && ui.title )
|
||||
ui.key = ui.title.toSnakeCase();
|
||||
}
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
.search-result-card,
|
||||
.tw-avatar {
|
||||
--border-radius-rounded: 0 !important;
|
||||
}
|
||||
|
||||
.player-streaminfo__picture img[src] {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.bits-leaderboard-medal .tw-avatar {
|
||||
--border-radius-rounded: 9000px !important;
|
||||
}
|
|
@ -29,7 +29,7 @@
|
|||
class="tab tw-pd-y-05 tw-pd-x-1"
|
||||
@click="select(idx)"
|
||||
>
|
||||
{{ t(i.i18n_key, i.title, i) }}
|
||||
{{ t(i.i18n_key, i.title) }}
|
||||
<span v-if="i.unseen > 0" class="tw-pill">{{ i.unseen }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
@ -42,7 +42,7 @@
|
|||
aria-expanded="true"
|
||||
>
|
||||
<section v-if="tab.description" class="tw-pd-b-1">
|
||||
{{ t(tab.desc_i18n_key, tab.description, tab) }}
|
||||
{{ t(tab.desc_i18n_key, tab.description) }}
|
||||
</section>
|
||||
<div
|
||||
v-for="i in tab.contents"
|
||||
|
|
|
@ -349,6 +349,58 @@ export function get(path, object) {
|
|||
}
|
||||
|
||||
|
||||
/**
|
||||
* Copy an object so that it can be safely serialized. If an object
|
||||
* is not serializable, such as a promise, returns null.
|
||||
*
|
||||
* @export
|
||||
* @param {*} object The thing to copy.
|
||||
* @param {Number} [depth=2] The maximum depth to explore the object.
|
||||
* @param {Set} [seen=null] A Set of seen objects. Internal use only.
|
||||
* @returns {Object} The copy to safely store or use.
|
||||
*/
|
||||
export function shallow_copy(object, depth = 2, seen = null) {
|
||||
if ( object == null )
|
||||
return object;
|
||||
|
||||
if ( object instanceof Promise || typeof object === 'function' )
|
||||
return null;
|
||||
|
||||
if ( typeof object !== 'object' )
|
||||
return object;
|
||||
|
||||
if ( depth === 0 )
|
||||
return null;
|
||||
|
||||
if ( ! seen )
|
||||
seen = new Set;
|
||||
|
||||
seen.add(object);
|
||||
|
||||
if ( Array.isArray(object) ) {
|
||||
const out = [];
|
||||
for(const val of object) {
|
||||
if ( seen.has(val) )
|
||||
continue;
|
||||
|
||||
out.push(shallow_copy(val, depth - 1, new Set(seen)));
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
const out = {};
|
||||
for(const [key, val] of Object.entries(object) ) {
|
||||
if ( seen.has(val) )
|
||||
continue;
|
||||
|
||||
out[key] = shallow_copy(val, depth - 1, new Set(seen));
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
export function deep_copy(object, seen) {
|
||||
if ( object === null )
|
||||
return null;
|
||||
|
|
|
@ -27,6 +27,20 @@
|
|||
}
|
||||
}
|
||||
|
||||
.ffz--i18n-entry {
|
||||
&:nth-child(2n+1) {
|
||||
background-color: var(--color-background-alt-2);
|
||||
}
|
||||
|
||||
.ffz--i18n-sub-entry {
|
||||
width: 33%;
|
||||
}
|
||||
|
||||
code {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
textarea.tw-input {
|
||||
height: unset;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue