1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-08-02 16:08:31 +00:00
* Changed: Improve the Translation Tester UI to make it considerably more useful.
* Changed: Load a list of known strings from the server when in Capture Mode so that we can check to see which strings are actually new or changed.
* Fixed: Drop-down menu options in the control center not being assigned localization keys.
* Fixed: Durations not being localized correctly.
* Fixed: i18n name collision for the Ban chat action.
This commit is contained in:
SirStendec 2019-10-06 20:01:22 -04:00
parent 7f0cad4bd4
commit 14e9f10685
15 changed files with 790 additions and 135 deletions

7
package-lock.json generated
View file

@ -1,6 +1,6 @@
{ {
"name": "frankerfacez", "name": "frankerfacez",
"version": "4.12.5", "version": "4.13.0",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
@ -7902,6 +7902,11 @@
} }
} }
}, },
"text-diff": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/text-diff/-/text-diff-1.0.1.tgz",
"integrity": "sha1-bBBZBUNeM3hXN1ydL2ymPkU/9WU="
},
"text-table": { "text-table": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",

View file

@ -1,7 +1,7 @@
{ {
"name": "frankerfacez", "name": "frankerfacez",
"author": "Dan Salvato LLC", "author": "Dan Salvato LLC",
"version": "4.13.0", "version": "4.13.1",
"description": "FrankerFaceZ is a Twitch enhancement suite.", "description": "FrankerFaceZ is a Twitch enhancement suite.",
"license": "Apache-2.0", "license": "Apache-2.0",
"scripts": { "scripts": {
@ -80,6 +80,7 @@
"safe-regex": "^2.0.2", "safe-regex": "^2.0.2",
"sortablejs": "^1.10.0-rc3", "sortablejs": "^1.10.0-rc3",
"sourcemapped-stacktrace": "^1.1.11", "sourcemapped-stacktrace": "^1.1.11",
"text-diff": "^1.0.1",
"vue": "^2.6.10", "vue": "^2.6.10",
"vue-clickaway": "^2.2.2", "vue-clickaway": "^2.2.2",
"vue-color": "^2.4.6", "vue-color": "^2.4.6",

View file

@ -141,9 +141,9 @@ export default class AddonManager extends Module {
const old = this.addons[addon.id]; const old = this.addons[addon.id];
this.addons[addon.id] = addon; this.addons[addon.id] = addon;
addon.name_i18n = addon.name_i18n || `addon.${addon.id}.name`; /*addon.name_i18n = addon.name_i18n || `addon.${addon.id}.name`;
addon.short_name_i18n = addon.short_name_i18n || `addon.${addon.id}.short_name`; addon.short_name_i18n = addon.short_name_i18n || `addon.${addon.id}.short_name`;
addon.author_i18n = addon.author_i18n || `addon.${addon.id}.author`; addon.author_i18n = addon.author_i18n || `addon.${addon.id}.author`;*/
addon.dev = is_dev; addon.dev = is_dev;
addon.requires = addon.requires || []; addon.requires = addon.requires || [];

View file

@ -12,8 +12,10 @@ import Module from 'utilities/module';
import NewTransCore from 'utilities/translation-core'; import NewTransCore from 'utilities/translation-core';
const API_SERVER = 'https://api-test.frankerfacez.com';
const STACK_SPLITTER = /\s*at\s+(.+?)\s+\((.+)\)$/, const STACK_SPLITTER = /\s*at\s+(.+?)\s+\((.+)\)$/,
SOURCE_SPLITTER = /^(.+):\/\/(.+?):(\d+:\d+)$/; SOURCE_SPLITTER = /^(.+):\/\/(.+?)(?:\?[a-zA-Z0-9]+)?:(\d+:\d+)$/;
const MAP_OPTIONS = { const MAP_OPTIONS = {
filter(line) { filter(line) {
@ -83,6 +85,9 @@ export class TranslationManager extends Module {
this.loadLocales(); this.loadLocales();
this.strings_loaded = false;
this.new_strings = 0;
this.changed_strings = 0;
this.capturing = false; this.capturing = false;
this.captured = new Map; this.captured = new Map;
@ -160,48 +165,54 @@ export class TranslationManager extends Module {
description: `FrankerFaceZ is lovingly translated by volunteers from our community. Thank you. If you're interested in helping to translate FrankerFaceZ, please [join our Discord](https://discord.gg/UrAkGhT) and ask about localization.`, description: `FrankerFaceZ is lovingly translated by volunteers from our community. Thank you. If you're interested in helping to translate FrankerFaceZ, please [join our Discord](https://discord.gg/UrAkGhT) and ask about localization.`,
component: 'setting-select-box', component: 'setting-select-box',
data: (profile, val) => { data: (profile, val) => this.getLocaleOptions(val)
const out = this.availableLocales.map(l => {
const data = this.localeData[l];
let title = data?.native_name;
if ( ! title )
title = data?.name || l;
if ( data?.coverage != null && data?.coverage < 100 )
title = this.t('i18n.locale-coverage', '{name} ({coverage,number,percent} Complete)', {
name: title,
coverage: data.coverage / 100
});
return {
selected: val === l,
value: l,
title
};
});
out.sort((a, b) => {
return a.title.localeCompare(b.title)
});
out.unshift({
selected: val === -1,
value: -1,
i18n_key: 'setting.appearance.localization.general.language.twitch',
title: "Use Twitch's Language"
});
return out;
}
}, },
changed: val => this.locale = val changed: val => this.locale = val
}); });
}
getLocaleOptions(val) {
if( val === undefined )
val = this.settings.get('i18n.locale');
const out = this.availableLocales.map(l => {
const data = this.localeData[l];
let title = data?.native_name;
if ( ! title )
title = data?.name || l;
if ( data?.coverage != null && data?.coverage < 100 )
title = this.t('i18n.locale-coverage', '{name} ({coverage,number,percent} Complete)', {
name: title,
coverage: data.coverage / 100
});
return {
selected: val === l,
value: l,
title
};
});
out.sort((a, b) => {
return a.title.localeCompare(b.title)
});
out.unshift({
selected: val === -1,
value: -1,
i18n_key: 'setting.appearance.localization.general.language.twitch',
title: "Use Twitch's Language"
});
return out;
} }
onEnable() { onEnable() {
this.capturing = this.settings.get('i18n.debug.capture'); this.capturing = this.settings.get('i18n.debug.capture');
if ( this.capturing )
this.loadStrings();
this._ = new NewTransCore({ //TranslationCore({ this._ = new NewTransCore({ //TranslationCore({
warn: (...args) => this.log.warn(...args), warn: (...args) => this.log.warn(...args),
@ -306,6 +317,80 @@ export class TranslationManager extends Module {
} }
async loadStrings() {
if ( this.strings_loaded )
return;
if ( this.strings_loading )
return;
this.strings_loading = true;
const loadPage = async page => {
const resp = await fetch(`${API_SERVER}/v2/i18n/strings?page=${page}`);
if ( ! resp.ok ) {
this.log.warn(`Error Loading Strings -- Status: ${resp.status}`);
return {
next: false,
strings: []
};
}
const data = await resp.json();
return {
next: data?.pages > page,
strings: data?.strings || []
}
}
let page = 1;
let next = true;
let strings = [];
while(next) {
const data = await loadPage(page++); // eslint-disable-line no-await-in-loop
strings = strings.concat(data.strings);
next = data.next;
}
for(const str of strings) {
const key = str.id;
let store = this.captured.get(key);
if ( ! store ) {
this.captured.set(key, store = {key, phrase: str.default, hits: 0, calls: []});
if ( str.source?.length )
store.calls.push(str.source);
}
if ( ! store.options && str.context?.length )
try {
store.options = JSON.parse(str.context);
} catch(err) { /* no-op */ }
store.known = str.default;
store.different = str.default !== store.phrase;
}
this.new_strings = 0;
this.changed_strings = 0;
for(const entry of this.captured.values()) {
if ( ! entry.known )
this.new_strings++;
if ( entry.different )
this.changed_strings++;
}
this.strings_loaded = true;
this.strings_loading = false;
this.log.info(`Loaded ${strings.length} strings from the server.`);
this.emit(':strings-loaded');
this.emit(':new-strings', this.new_strings);
this.emit(':changed-strings', this.changed_strings);
}
see(key, phrase, options) { see(key, phrase, options) {
if ( ! this.capturing ) if ( ! this.capturing )
return; return;
@ -321,9 +406,22 @@ export class TranslationManager extends Module {
} }
let store = this.captured.get(key); let store = this.captured.get(key);
if ( ! store ) if ( ! store ) {
this.captured.set(key, store = {key, phrase, hits: 0, calls: []}); this.captured.set(key, store = {key, phrase, hits: 0, calls: []});
if ( this.strings_loaded ) {
this.new_strings++;
this.emit(':new-strings', this.new_strings);
}
}
if ( phrase !== store.phrase ) {
store.phrase = phrase;
if ( store.known && phrase !== store.known && ! store.different ) {
store.different = true;
this.changed_strings++;
this.emit(':changed-strings', this.changed_strings);
}
}
store.options = this.pluckVariables(key, options); store.options = this.pluckVariables(key, options);
store.hits++; store.hits++;
@ -383,7 +481,17 @@ export class TranslationManager extends Module {
if ( file.includes('/node_modules/') || BAD_FRAMES.includes(file) ) if ( file.includes('/node_modules/') || BAD_FRAMES.includes(file) )
continue; continue;
const out = `${match[1]} (${location[2]}:${location[3]})`; let out;
if ( match[1] === 'MainMenu.getSettingsTree' )
out = 'FFZ Control Center';
else {
let label = match[1];
if ( label === 'Proxy.render' && location[2].includes('.vue') )
label = 'Vue Component';
out = `${label} (${location[2]}:${location[3]})`;
}
if ( ! store.calls.includes(out) ) if ( ! store.calls.includes(out) )
store.calls.push(out); store.calls.push(out);
@ -393,7 +501,7 @@ export class TranslationManager extends Module {
async loadLocales() { async loadLocales() {
const resp = await fetch(`https://api-test.frankerfacez.com/v2/i18n/locales`); const resp = await fetch(`${API_SERVER}/v2/i18n/locales`);
if ( ! resp.ok ) { if ( ! resp.ok ) {
this.log.warn(`Error Populating Locales -- Status: ${resp.status}`); this.log.warn(`Error Populating Locales -- Status: ${resp.status}`);
throw new Error(`http error ${resp.status} loading locales`) throw new Error(`http error ${resp.status} loading locales`)
@ -425,7 +533,7 @@ export class TranslationManager extends Module {
if ( locale === 'en' ) if ( locale === 'en' )
return {}; return {};
const resp = await fetch(`https://api-test.frankerfacez.com/v2/i18n/locale/${locale}`); const resp = await fetch(`${API_SERVER}/v2/i18n/locale/${locale}`);
if ( ! resp.ok ) { if ( ! resp.ok ) {
if ( resp.status === 404 ) { if ( resp.status === 404 ) {
this.log.info(`Cannot Load Locale: ${locale}`); this.log.info(`Cannot Load Locale: ${locale}`);
@ -503,8 +611,8 @@ export class TranslationManager extends Module {
return this._.toLocaleString(...args); return this._.toLocaleString(...args);
} }
toHumanTime(...args) { toRelativeTime(...args) {
return this._.formatHumanTime(...args); return this._.formatRelativeTime(...args);
} }
formatNumber(...args) { formatNumber(...args) {

View file

@ -242,7 +242,7 @@ export const unban = {
title: 'Unban User', title: 'Unban User',
tooltip(data) { tooltip(data) {
return this.i18n.t('chat.actions.unban', 'Unban {user.login}', {user: data.user}); return this.i18n.t('chat.actions.unban.tooltip', 'Unban {user.login}', {user: data.user});
}, },
click(event, data) { click(event, data) {

View file

@ -194,7 +194,7 @@ export const Videos = {
image: video.previewThumbnailURL, image: video.previewThumbnailURL,
title: video.title, title: video.title,
desc_1, desc_1,
desc_2: this.i18n.t('video.desc.2', '{length,duration} — {views,number} Views - {date}', { desc_2: this.i18n.t('video.desc.2', '{length,duration} — {views,number} Views - {date,datetime}', {
length: video.lengthSeconds, length: video.lengthSeconds,
views: video.viewCount, views: video.viewCount,
date: video.publishedAt date: video.publishedAt

View file

@ -24,10 +24,10 @@
<div class="tw-flex-grow-1"> <div class="tw-flex-grow-1">
<div class="tw-border-b tw-mg-b-05"> <div class="tw-border-b tw-mg-b-05">
<h4>{{ t(addon.name_i18n, addon.name) }} <span class="tw-c-text-alt-2 tw-font-size-6">({{ addon.id }})</span></h4> <h4>{{ addon.name_i18n ? t(addon.name_i18n, addon.name) : addon.name }} <span class="tw-c-text-alt-2 tw-font-size-6">({{ addon.id }})</span></h4>
<span class="tw-c-text-alt tw-mg-r-1"> <span class="tw-c-text-alt tw-mg-r-1">
{{ t('addon.author', 'By: {author}', { {{ t('addon.author', 'By: {author}', {
author: t(addon.author_i18n, addon.author) author: addon.author_i18n ? t(addon.author_i18n, addon.author) : addon.author
}) }} }) }}
</span> </span>
<span v-if="version" class="tw-c-text-alt"> <span v-if="version" class="tw-c-text-alt">
@ -126,18 +126,7 @@ export default {
props: ['id', 'addon', 'item'], props: ['id', 'addon', 'item'],
data() { data() {
let description;
if ( this.addon.description_i18n )
description = this.t(this.addon.description_i18n, this.addon.description);
else
description = this.addon.description;
const lines = description.split(/\n/);
return { return {
description,
multi_line: lines.length > 1,
first_line: lines[0],
enabled: this.item.isAddonEnabled(this.id), enabled: this.item.isAddonEnabled(this.id),
external: this.item.isAddonExternal(this.id), external: this.item.isAddonExternal(this.id),
version: this.item.getVersion(this.id), version: this.item.getVersion(this.id),
@ -150,6 +139,25 @@ export default {
return this.addon.icon || 'https://cdn.frankerfacez.com/badge/2/4/solid' return this.addon.icon || 'https://cdn.frankerfacez.com/badge/2/4/solid'
}, },
description() {
if ( this.addon.description_i18n )
return this.t(this.addon.description_i18n, this.addon.description);
return this.addon.description;
},
lines() {
return this.description.split(/\n/);
},
multi_line() {
return this.lines.length > 1
},
first_line() {
return this.lines[0]
},
show_description() { show_description() {
if ( this.expanded ) if ( this.expanded )
return this.description; return this.description;

View file

@ -369,7 +369,7 @@ export default class MainMenu extends Module {
i18n_key, i18n_key,
desc_i18n_key: `${i18n_key}.description`, desc_i18n_key: `${i18n_key}.description`,
sort: 0, sort: 0,
title: setting_key //title: setting_key
}, def.ui, { }, def.ui, {
full_key: `setting:${setting_key}`, full_key: `setting:${setting_key}`,
setting: setting_key, setting: setting_key,
@ -386,12 +386,15 @@ export default class MainMenu extends Module {
} }
let terms = [ let terms = [
setting_key, setting_key
this.i18n.t(tok.i18n_key, tok.title, null, true)
]; ];
if ( have_locale && this.i18n.has(tok.i18n_key) ) if ( tok.title ) {
terms.push(this.i18n.t(tok.i18n_key, tok.title, null)); terms.push(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, null));
}
if ( tok.description ) { if ( tok.description ) {
terms.push(this.i18n.t(tok.desc_i18n_key, tok.description, null, true)); terms.push(this.i18n.t(tok.desc_i18n_key, tok.description, null, true));
@ -425,7 +428,7 @@ export default class MainMenu extends Module {
} }
if ( ! token.search_terms ) { if ( ! token.search_terms ) {
const formatted = this.i18n.t(token.i18n_key, token.title, null, true); const formatted = token.title && this.i18n.t(token.i18n_key, token.title, null, true);
let terms = [token.key]; let terms = [token.key];
if ( formatted && formatted.localeCompare(token.key, undefined, {sensitivity: 'base'}) ) if ( formatted && formatted.localeCompare(token.key, undefined, {sensitivity: 'base'}) )

View file

@ -5,82 +5,290 @@
<div class="tw-font-size-7 tw-c-text-alt-2 tw-pd-b-05 tw-strong tw-upcase tw-ellipsis" :title="entry.key"> <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 }} {{ entry.key }}
</div> </div>
<code>{{ entry.phrase }}</code> <code><span
v-for="(bit, index) in diff"
:key="index"
:class="{
'ffz-tooltip': bit[0] !== 0,
'tw-c-text-base': bit[0] === 0,
'tw-c-text-error': bit[0] === -1,
'tw-c-text-prime': bit[0] === 1
}"
:data-title="getDiffTooltip(bit)"
>{{ bit[1] }}</span></code>
</div> </div>
<div class="ffz--i18n-sub-entry tw-flex-grow-1"> <div class="ffz--i18n-sub-entry tw-flex-grow-1">
<textarea <textarea
ref="editor"
v-model="value" v-model="value"
:class="{'tw-textarea--error': ! valid}" :class="{'tw-textarea--error': ! valid}"
class="tw-block tw-font-size-6 tw-full-width tw-full-height tw-textarea" class="tw-block tw-font-size-6 tw-full-width tw-full-height tw-textarea"
@input="onInput" @input="onInput"
@blur="onBlur"
@focus="open = true"
/> />
</div> </div>
<div class="ffz--i18n-sub-entry tw-mg-l-05 tw-flex-grow-1"> <div class="ffz--i18n-sub-entry tw-mg-l-05 tw-flex-grow-1">
<div v-if="error"> <div v-if="error && ! open">
<div class="tw-strong">{{ t('i18n.ui.error', 'Error') }}</div> <div class="tw-strong">{{ t('i18n.ui.error', 'Error') }}</div>
<code class="tw-font-size-7 tw-c-text-alt-2">{{ error }}</code> <code class="tw-font-size-7 tw-c-text-alt-2">{{ error }}</code>
</div> </div>
<div v-if="source"> <div v-if="source">
<div class="tw-strong">{{ t('i18n.ui.source', 'Source') }}</div> <div class="tw-strong">{{ t('i18n.ui.source', 'Source') }}</div>
<code class="tw-font-size-7 tw-c-text-alt-2">{{ source }}</code> <div
v-for="(line, idx) in source"
:key="idx"
:title="line"
class="tw-font-size-7 tw-c-text-alt-2 tw-ellipsis tw-full-width"
>
{{ line }}
</div>
</div> </div>
<div v-if="context"> <div v-if="context_str && ! open">
<div class="tw-strong">{{ t('i18n.ui.context', 'Context') }}</div> <div class="tw-strong">{{ t('i18n.ui.context', 'Context') }}</div>
<code class="tw-font-size-7 tw-c-text-alt-2">{{ context }}</code> <code class="tw-font-size-7 tw-c-text-alt-2">{{ context_str }}</code>
</div> </div>
</div> </div>
</div> </div>
<div
v-if="open"
class="tw-flex tw-full-width tw-mg-t-05"
>
<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">
{{ t('i18n.ui.preview', 'Preview') }}
</div>
<code v-if="error">{{ error }}</code>
<code v-else>{{ preview }}</code>
</div>
<div class="ffz--i18n-sub-entry tw-flex-grow-1">
<div class="tw-font-size-7 tw-c-text-alt-2 tw-pd-b-05 tw-strong tw-upcase tw-ellipsis">
{{ t('i18n.ui.context', 'Context') }}
</div>
<div
v-for="val in context"
:key="val.key"
class="tw-flex tw-align-items-center tw-mg-b-05"
>
<label
:for="`ui-ctx:${entry.key}:${val.key}`"
class="tw-mg-r-05"
>
{{ val.key }}
</label>
<input
:id="`ui-ctx:${entry.key}:${val.key}`"
:type="val.is_number ? 'number' : 'text'"
:value="val.value"
class="tw-full-width tw-block tw-border-radius-medium tw-font-size-6 tw-input tw-pd-x-1 tw-pd-y-05"
@input="updateContext(val.key, $event)"
>
</div>
</div>
<button
class="tw-button-icon tw-mg-l-05 tw-relative tw-tooltip-wrapper"
@click="open = false"
>
<span class="tw-button-icon__icon">
<figure class="ffz-i-cancel" />
</span>
</button>
</div>
</div> </div>
</template> </template>
<script> <script>
import Diff from 'text-diff';
import Parser from '@ffz/icu-msgparser'; import Parser from '@ffz/icu-msgparser';
import { debounce } from 'utilities/object'; import { has, debounce } from 'utilities/object';
const parser = new Parser(); const parser = new Parser();
const diff = new Diff();
function parse(text) {
try {
return parser.parse(text);
} catch(err) {
return null;
}
}
const NUMBER_TYPES = ['number', 'plural', 'en_plural', 'selectordinal', 'duration']
function extractVariables(ast, out, vars, context) {
for(const node of ast) {
if ( typeof node !== 'object' || ! node.v )
continue;
const key = node.v,
type = node.t;
let value = context ? context[key] : null,
is_number = typeof value === 'number';
if ( ! is_number && NUMBER_TYPES.includes(type) ) {
is_number = true;
try {
value = parseFloat(value);
if ( isNaN(value) || ! isFinite(value) )
value = 0;
} catch(err) {
value = 0;
}
}
vars[key] = value;
if ( ! has(out, key) )
out[key] = {
key,
value,
is_number
};
if ( is_number )
out[key].is_number = true;
if ( typeof node.o === 'object' )
for(const subast of Object.values(node.o))
extractVariables(subast, out, vars, context);
}
}
export default { export default {
props: ['entry'], props: ['entry', 'getI18n'],
data() { data() {
return { return {
value: this.entry.translation, value: this.entry.translation,
valid: true, valid: true,
error: null pending: false,
error: null,
open: false
} }
}, },
computed: { computed: {
diff() {
if ( ! this.entry.different || ! this.entry.known )
return [[0, this.entry.phrase]];
const out = diff.main(this.entry.known, this.entry.phrase);
diff.cleanupSemantic(out);
return out;
},
source() { source() {
const calls = this.entry.calls; const calls = this.entry.calls;
if ( ! Array.isArray(calls) || ! calls.length ) if ( ! Array.isArray(calls) || ! calls.length )
return null; return null;
return calls.join('\n'); return calls.join('\n').split(/\n/);
},
preview() {
try {
return this.getI18n()._.t(null, this.value, this.variables, {noCache: true, throwParse: true});
} catch(err) {
return err;
}
}, },
context() { context() {
const opts = this.entry.options; const out = {}, vars = {};
if ( ! opts || typeof opts !== 'object' ) let ast = parse(this.entry.phrase);
return null; if ( ast )
extractVariables(ast, out, vars, this.entry.options);
if ( this.open ) {
ast = parse(this.value);
if ( ast )
extractVariables(ast, out, vars, this.entry.options);
}
return Object.values(out);
},
variables() {
const out = {}, vars = {};
let ast = parse(this.entry.phrase);
if ( ast )
extractVariables(ast, out, vars, this.entry.options);
if ( this.open ) {
ast = parse(this.value);
if ( ast )
extractVariables(ast, out, vars, this.entry.options);
}
return vars;
},
context_str() {
const lines = []; const lines = [];
for(const [key, val] of Object.entries(opts)) for(const entry of this.context)
lines.push(`${key}: ${JSON.stringify(val)}`); lines.push(`${entry.key}: ${JSON.stringify(entry.value)}`);
return lines.join('\n'); return lines.join('\n');
} }
}, },
watch: {
'entry.translation'() {
if ( document.activeElement !== this.$refs.editor )
this.value = this.entry.translation;
}
},
created() { created() {
this.validate(); this.validate();
this.onInput = debounce(this.onInput, 250); this.onInput = debounce(this.onInput, 250);
}, },
methods: { methods: {
getDiffTooltip(diff) {
const mode = diff[0];
if ( mode === -1 )
return this.t('i18n.diff.removed', 'Removed:\n{text}', {text: diff[1]});
if ( mode === 1 )
return this.t('i18n.diff.added', 'Added:\n{text}', {text: diff[1]});
return '';
},
updateContext(key, event) {
if ( ! this.entry.options )
this.$set(this.entry, 'options', {});
let val = event.target.value;
if ( event.target.type === 'number' ) {
try {
val = parseFloat(val);
if ( isNaN(val) || !isFinite(val) )
val = 0;
} catch(err) {
val = 0;
}
}
this.$set(this.entry.options, key, val);
this.$emit('update-context', this.variables);
},
onBlur() {
if ( this.pending )
this.$emit('update', this.value);
},
onInput() { onInput() {
this.validate(); this.validate();
this.pending = ! this.valid;
if ( this.valid ) if ( this.valid )
this.$emit('update', this.value); this.$emit('update', this.value);
}, },

View file

@ -26,6 +26,14 @@
</div> </div>
</div> </div>
</div> </div>
<button class="tw-button-icon tw-mg-x-05 tw-relative tw-tooltip-wrapper" @click="saveBlob">
<span class="tw-button-icon__icon">
<figure class="ffz-i-floppy" />
</span>
<div class="tw-tooltip tw-tooltip--down tw-tooltip--align-right">
{{ t('i18n.ui.save', 'Generate Change Blob') }}
</div>
</button>
<button class="tw-button-icon tw-mg-x-05 tw-relative tw-tooltip-wrapper" @click="requestKeys"> <button class="tw-button-icon tw-mg-x-05 tw-relative tw-tooltip-wrapper" @click="requestKeys">
<span class="tw-button-icon__icon"> <span class="tw-button-icon__icon">
<figure class="ffz-i-arrows-cw" /> <figure class="ffz-i-arrows-cw" />
@ -58,13 +66,150 @@
</span> </span>
</button> </button>
</header> </header>
<section class="tw-border-t tw-full-height tw-full-width tw-flex tw-overflow-hidden"> <section class="tw-border-t tw-full-height tw-full-width tw-flex tw-flex-column tw-overflow-hidden">
<header class="tw-border-b tw-pd-05 tw-c-background-base tw-flex tw-align-items-center">
<button class="tw-border-radius-medium tw-pd-x-05 tw-core-button tw-core-button--text tw-c-text-base tw-interactive tw-relative tw-tooltip-wrapper" @click="prevPage">
<span class="tw-button-icon__icon">
<figure class="ffz-i-left-dir" />
</span>
<div class="tw-tooltip tw-tooltip--down">
{{ t('page.previous', 'Previous Page') }}
</div>
</button>
<button
v-if="! page_open"
class="tw-border-radius-medium tw-pd-x-05 tw-core-button tw-core-button--text tw-c-text-base tw-interactive"
@click="openPage"
>
{{ t('i18n.ui.pages', 'Page {current,number} of {total,number}', {
current: page,
total: pages
}) }}
</button>
<input
v-if="page_open"
ref="pager"
:value="page"
:max="pages"
class="tw-block tw-border-radius-medium tw-font-size-6 tw-input tw-pd-x-1 tw-pd-y-05"
type="number"
min="1"
@keydown.enter="closePage"
@blur="closePage"
>
<button class="tw-border-radius-medium tw-pd-x-05 tw-core-button tw-core-button--text tw-c-text-base tw-interactive tw-relative tw-tooltip-wrapper" @click="nextPage">
<span class="tw-button-icon__icon">
<figure class="ffz-i-right-dir" />
</span>
<div class="tw-tooltip tw-tooltip--down">
{{ t('page.next', 'Next Page') }}
</div>
</button>
<div class="tw-flex-grow-1" />
<button
class="tw-border-radius-medium tw-pd-x-05 tw-core-button tw-c-text-base tw-interactive tw-relative tw-tooltip-wrapper"
:class="[mode === 0 ? 'tw-core-button--primary' : 'tw-core-button--text']"
@click="mode = 0"
>
<div class="tw-align-items-center tw-flex tw-flex-grow-0">
<figure class="ffz-i-search" />
<div class="tw-mg-l-05">
{{ total }}
</div>
</div>
<div class="tw-tooltip tw-tooltip--down tw-tooltip--align-right">
{{ t('i18n.ui.all', 'All Strings') }}
</div>
</button>
<button
v-if="existing != total"
class="tw-mg-l-05 tw-border-radius-medium tw-pd-x-05 tw-core-button tw-c-text-base tw-interactive tw-relative tw-tooltip-wrapper"
:class="[mode === 1 ? 'tw-core-button--primary' : 'tw-core-button--text']"
@click="mode = 1"
>
<div class="tw-align-items-center tw-flex tw-flex-grow-0">
<figure class="ffz-i-star-empty" />
<div class="tw-mg-l-05">
{{ existing }}
</div>
</div>
<div class="tw-tooltip tw-tooltip--down tw-tooltip--align-right">
{{ t('i18n.ui.existing', 'Existing Strings') }}
</div>
</button>
<button
v-if="added"
class="tw-mg-l-05 tw-border-radius-medium tw-pd-x-05 tw-core-button tw-c-text-base tw-interactive tw-relative tw-tooltip-wrapper"
:class="[mode === 2 ? 'tw-core-button--primary' : 'tw-core-button--text']"
@click="mode = 2"
>
<div class="tw-align-items-center tw-flex tw-flex-grow-0">
<figure class="ffz-i-star" />
<div class="tw-mg-l-05">
{{ added }}
</div>
</div>
<div class="tw-tooltip tw-tooltip--down tw-tooltip--align-right">
{{ t('i18n.ui.added', 'New Strings') }}
</div>
</button>
<button
v-if="changed"
class="tw-mg-l-05 tw-border-radius-medium tw-pd-x-05 tw-core-button tw-c-text-base tw-interactive tw-relative tw-tooltip-wrapper"
:class="[mode === 3 ? 'tw-core-button--primary' : 'tw-core-button--text']"
@click="mode = 3"
>
<div class="tw-align-items-center tw-flex tw-flex-grow-0">
<figure class="ffz-i-floppy" />
<div class="tw-mg-l-05">
{{ changed }}
</div>
</div>
<div class="tw-tooltip tw-tooltip--down tw-tooltip--align-right">
{{ t('i18n.ui.changed', 'Changed Strings') }}
</div>
</button>
<button
v-if="pending"
class="tw-mg-l-05 tw-border-radius-medium tw-pd-x-05 tw-core-button tw-c-text-base tw-interactive tw-relative tw-tooltip-wrapper"
:class="[mode === 4 ? 'tw-core-button--primary' : 'tw-core-button--text']"
@click="mode = 4"
>
<div class="tw-align-items-center tw-flex tw-flex-grow-0">
<figure class="ffz-i-upload-cloud" />
<div class="tw-mg-l-05">
{{ pending }}
</div>
</div>
<div class="tw-tooltip tw-tooltip--down tw-tooltip--align-right">
{{ t('i18n.ui.pending', 'Pending Strings') }}
</div>
</button>
<button
v-if="invalid"
class="tw-mg-l-05 tw-border-radius-medium tw-pd-x-05 tw-core-button tw-c-text-base tw-interactive tw-relative tw-tooltip-wrapper"
:class="[mode === 5 ? 'tw-core-button--primary' : 'tw-core-button--text']"
@click="mode = 5"
>
<div class="tw-align-items-center tw-flex tw-flex-grow-0">
<figure class="ffz-i-attention" />
<div class="tw-mg-l-05">
{{ invalid }}
</div>
</div>
<div class="tw-tooltip tw-tooltip--down tw-tooltip--align-right">
{{ t('i18n.ui.invalid', 'Invalid Strings') }}
</div>
</button>
</header>
<simplebar classes="tw-flex-grow-1"> <simplebar classes="tw-flex-grow-1">
<i18n-entry <i18n-entry
v-for="phrase in filtered" v-for="phrase in paged"
:key="phrase.key" :key="phrase.key"
:entry="phrase" :entry="phrase"
:get-i18n="getI18n"
@update="update(phrase, $event)" @update="update(phrase, $event)"
@update-context="updateContext(phrase, $event)"
/> />
</simplebar> </simplebar>
</section> </section>
@ -74,10 +219,24 @@
<script> <script>
import displace from 'displacejs'; import displace from 'displacejs';
import Parser from '@ffz/icu-msgparser';
import { saveAs } from 'file-saver';
import { deep_equals, deep_copy } from 'utilities/object';
const parser = new Parser();
const PER_PAGE = 20;
export default { export default {
data() { data() {
return this.$vnode.data; const data = this.$vnode.data;
data.mode = 0;
data.page = 1;
data.page_open = false;
return data;
}, },
computed: { computed: {
@ -86,25 +245,82 @@ export default {
}, },
filtered() { filtered() {
if ( ! this.query || ! this.query.length ) const mode = this.mode,
query = this.query,
has_query = query?.length > 0;
if ( mode === 0 && ! has_query )
return this.phrases; return this.phrases;
return this.phrases.filter(entry => { return this.phrases.filter(entry => {
if ( entry.key.toLowerCase().includes(this.query) ) if ( has_query ) {
return true; if (! (entry.key && entry.key.toLowerCase().includes(query)) &&
! (entry.phrase && entry.phrase.toLowerCase().includes(query)) &&
! (entry.translation && entry.translation.toLowerCase().includes(query)) )
return false;
}
if ( entry.phrase.toLowerCase().includes(this.query) ) if ( mode === 1 )
return true; return entry.known && ! entry.different;
if ( entry.translation.toLowerCase().includes(this.query) ) if ( mode === 2 )
return true; return ! entry.known;
return false; if ( mode === 3 )
return entry.different;
if ( mode === 4 )
return ! entry.known || entry.different || entry.context_changed;
if ( mode === 5 )
return ! entry.valid;
return true;
}) })
},
total() {
return this.phrases.length;
},
invalid() {
return this.phrases.filter(entry => ! entry.valid).length;
},
existing() {
return this.total - (this.added + this.changed);
},
added() {
return this.phrases.filter(entry => ! entry.known).length;
},
changed() {
return this.phrases.filter(entry => entry.different).length;
},
pending() {
return this.phrases.filter(entry => ! entry.known || entry.different || entry.context_changed).length;
},
pages() {
return Math.ceil(this.filtered.length / PER_PAGE);
},
paged() {
const offset = (this.page - 1) * PER_PAGE;
return this.filtered.slice(offset, offset + PER_PAGE);
} }
}, },
watch: { watch: {
pages() {
if ( this.pages == 0 )
this.page = 1;
else if ( this.page > this.pages )
this.page = this.pages;
},
maximized() { maximized() {
this.updateDrag(); this.updateDrag();
} }
@ -115,6 +331,7 @@ export default {
this.grabKeys(); this.grabKeys();
this.listen('i18n:got-keys', this.grabKeys, this); this.listen('i18n:got-keys', this.grabKeys, this);
this.listen('i18n:loaded', this.grabKeys, this);
}, },
mounted() { mounted() {
@ -133,21 +350,105 @@ export default {
} }
this.unlisten('i18n:got-keys', this.grabKeys, this); this.unlisten('i18n:got-keys', this.grabKeys, this);
this.unlisten('i18n:loaded', this.grabKeys, this);
}, },
methods: { methods: {
grabKeys() { saveBlob() {
this.phrases = this.getKeys(); const out = [];
this.phrases.sort((a, b) => {
return a.key.localeCompare(b.key) for(const entry of this.phrases) {
if ( entry.known && ! entry.different && ! entry.context_changed )
continue;
out.push({
key: entry.key,
calls: entry.calls,
options: entry.options,
phrase: entry.translation
});
}
try {
const blob = new Blob([JSON.stringify(out, null, '\t')], {type: 'application/json;charset=utf-8'});
saveAs(blob, 'ffz-strings.json');
} catch(err) {
alert('Unable to save: ' + err); // eslint-disable-line
}
},
openPage() {
this.page_open = true;
this.$nextTick(() => {
this.$refs.pager.focus();
this.$refs.pager.select();
}); });
}, },
closePage() {
if ( ! this.page_open )
return;
try {
this.page = parseInt(this.$refs.pager.value, 10);
if ( this.page < 1 )
this.page = 1;
else if ( this.page > this.pages )
this.page = this.pages;
} catch(err) { /* no-op */ }
this.page_open = false;
},
prevPage() {
if ( this.page > 1 )
this.page--;
},
nextPage() {
if ( this.page < this.pages )
this.page++;
},
grabKeys() {
this.phrases = [];
for(const phrase of this.getKeys()) {
try {
parser.parse(phrase.translation);
phrase.valid = true;
} catch(err) {
phrase.valid = false;
}
phrase.original_opts = deep_copy(phrase.options);
phrase.context_changed = false;
this.phrases.push(phrase);
}
this.phrases.sort((a, b) => a.key.localeCompare(b.key));
},
update(entry, phrase) { update(entry, phrase) {
entry.translation = phrase; entry.translation = phrase;
try {
parser.parse(phrase);
entry.valid = true;
} catch(err) {
entry.valid = false;
}
this.updatePhrase(entry.key, phrase); this.updatePhrase(entry.key, phrase);
}, },
updateContext(entry, context) {
if ( context && Object.keys(context).length === 0 )
context = null;
entry.options = context;
entry.context_changed = ! deep_equals(entry.options, entry.original_opts);
},
updateDrag() { updateDrag() {
if ( this.maximized ) if ( this.maximized )
this.destroyDrag(); this.destroyDrag();

View file

@ -99,6 +99,7 @@ export default class TranslationUI extends Module {
maximized: this.dialog.maximized, maximized: this.dialog.maximized,
exclusive: this.dialog.exclusive, exclusive: this.dialog.exclusive,
getI18n: () => this.i18n,
getKeys: () => this.i18n.getKeys(), getKeys: () => this.i18n.getKeys(),
requestKeys: () => this.i18n.requestKeys(), requestKeys: () => this.i18n.requestKeys(),
updatePhrase: (key, phrase) => this.i18n.updatePhrase(key, phrase), updatePhrase: (key, phrase) => this.i18n.updatePhrase(key, phrase),

View file

@ -459,6 +459,14 @@ export default class SettingsManager extends Module {
if ( ! ui.key && ui.title ) if ( ! ui.key && ui.title )
ui.key = ui.title.toSnakeCase(); ui.key = ui.title.toSnakeCase();
if ( ui.component === 'setting-select-box' && Array.isArray(ui.data) ) {
const i18n_base = `${ui.i18n_key || `setting.entry.${key}`}.values`;
for(const value of ui.data) {
if ( value.i18n_key === undefined && value.value )
value.i18n_key = `${i18n_base}.${value.value}`;
}
}
} }
if ( definition.changed ) if ( definition.changed )

View file

@ -68,6 +68,10 @@ export default class MenuButton extends SiteModule {
return this._has_update; return this._has_update;
} }
get has_strings() {
return this.i18n.new_strings > 0 || this.i18n.changed_strings > 0;
}
set has_update(val) { set has_update(val) {
if ( val && ! this._has_update ) if ( val && ! this._has_update )
this._important_update = true; this._important_update = true;
@ -97,6 +101,9 @@ export default class MenuButton extends SiteModule {
if ( this.has_update ) if ( this.has_update )
return null; return null;
if ( this.has_strings )
return this.i18n.formatNumber(this.i18n.new_strings + this.i18n.changed_strings);
if ( DEBUG && this.addons.has_dev ) if ( DEBUG && this.addons.has_dev )
return this.i18n.t('site.menu_button.dev', 'dev'); return this.i18n.t('site.menu_button.dev', 'dev');
@ -125,6 +132,8 @@ export default class MenuButton extends SiteModule {
this.once(':clicked', this.loadMenu); this.once(':clicked', this.loadMenu);
this.on('i18n:new-strings', this.update);
this.on('i18n:changed-strings', this.update);
this.on('i18n:update', this.update); this.on('i18n:update', this.update);
this.on('addons:data-loaded', this.update); this.on('addons:data-loaded', this.update);
} }
@ -177,6 +186,12 @@ export default class MenuButton extends SiteModule {
{this.has_new && (<div class="tw-mg-t-1"> {this.has_new && (<div class="tw-mg-t-1">
{this.i18n.t('site.menu_button.new-desc', 'There {count,plural,one {is one new setting} other {are # new settings}}.', {count: this._new_settings})} {this.i18n.t('site.menu_button.new-desc', 'There {count,plural,one {is one new setting} other {are # new settings}}.', {count: this._new_settings})}
</div>)} </div>)}
{this.has_strings && (<div class="tw-mg-t-1">
{this.i18n.t('site.menu_button.strings', 'There {added,plural,one {is # new string} other {are # new strings}} and {changed,plural,one {# changed string} other {# changed strings}}.', {
added: this.i18n.new_strings,
changed: this.i18n.changed_strings
})}
</div>)}
{DEBUG && (<div class="tw-mg-t-1"> {DEBUG && (<div class="tw-mg-t-1">
{this.i18n.t('site.menu_button.main-dev-desc', 'You are running a developer build of FrankerFaceZ.')} {this.i18n.t('site.menu_button.main-dev-desc', 'You are running a developer build of FrankerFaceZ.')}
</div>)} </div>)}

View file

@ -315,6 +315,9 @@ export function substr_count(str, needle) {
* @returns {*} The value at that point in the path, or undefined if part of the path doesn't exist. * @returns {*} The value at that point in the path, or undefined if part of the path doesn't exist.
*/ */
export function get(path, object) { export function get(path, object) {
if ( HOP.call(object, path) )
return object[path];
if ( typeof path === 'string' ) if ( typeof path === 'string' )
path = path.split('.'); path = path.split('.');

View file

@ -5,6 +5,11 @@
// ============================================================================ // ============================================================================
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import RelativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(RelativeTime);
import Parser from '@ffz/icu-msgparser'; import Parser from '@ffz/icu-msgparser';
import {get} from 'utilities/object'; import {get} from 'utilities/object';
@ -71,8 +76,12 @@ export const DEFAULT_TYPES = {
return this.toLocaleString(val); return this.toLocaleString(val);
}, },
relativetime(val, node) {
return this.formatRelativeTime(val, node.f);
},
humantime(val, node) { humantime(val, node) {
return this.formatHumanTime(val, 1, node.f); return this.formatRelativeTime(val, node.f);
}, },
en_plural: v => v !== 1 ? 's' : '' en_plural: v => v !== 1 ? 's' : ''
@ -226,36 +235,16 @@ export default class TranslationCore {
return thing; return thing;
} }
formatHumanTime(value, factor, round = false) { formatRelativeTime(value) { // eslint-disable-line class-methods-use-this
if ( value instanceof Date ) if ( !(value instanceof Date) )
value = (Date.now() - value.getTime()) / 1000; value = new Date(Date.now() + value * 1000);
value = Math.floor(value); const d = dayjs(value);
factor = Number(factor) || 1; try {
return d.locale(this._locale).fromNow(true);
const fn = round ? Math.round : Math.floor; } catch(err) {
return d.fromNow(true);
const years = fn((value * factor) / 31536000) / factor; }
if ( years >= 1 )
return this.t('human-time.years', '{count,number} year{count,en_plural}', years);
const days = fn((value %= 31536000) / 86400);
if ( days >= 1 )
return this.t('human-time.days', '{count,number} day{count,en_plural}', days);
const hours = fn((value %= 86400) / 3600);
if ( hours >= 1 )
return this.t('human-time.hours', '{count,number} hour{count,en_plural}', hours);
const minutes = fn((value %= 3600) / 60);
if ( minutes >= 1 )
return this.t('human-time.minutes', '{count,number} minute{count,en_plural}', minutes);
const seconds = value % 60;
if ( seconds >= 1 )
return this.t('human-time.seconds', '{count,number} second{count,en_plural}', seconds);
return this.t('human-time.none', 'less than a second');
} }
formatNumber(value, format) { formatNumber(value, format) {
@ -388,16 +377,16 @@ export default class TranslationCore {
this.extend(phrases); this.extend(phrases);
} }
_preTransform(key, phrase, options) { _preTransform(key, phrase, options, settings = {}) {
let ast, locale, data = options == null ? {} : options; let ast, locale, data = options == null ? {} : options;
if ( typeof data === 'number' ) if ( typeof data === 'number' )
data = {count: data}; data = {count: data};
if ( this.phrases.has(key) ) { if ( ! settings.noCache && this.phrases.has(key) ) {
ast = this.cache.get(key); ast = this.cache.get(key);
locale = this.locale; locale = this.locale;
} else if ( this.cache.has(key) ) { } else if ( ! settings.noCache && this.cache.has(key) ) {
ast = this.cache.get(key); ast = this.cache.get(key);
locale = this.defaultLocale; locale = this.defaultLocale;
@ -406,7 +395,10 @@ export default class TranslationCore {
try { try {
parsed = this.parser.parse(phrase); parsed = this.parser.parse(phrase);
} catch(err) { } catch(err) {
if ( this.warn ) if ( settings.throwParse )
throw err;
if ( ! settings.noWarn && this.warn )
this.warn(`Error parsing i18n phrase for key "${key}": ${phrase}`, err); this.warn(`Error parsing i18n phrase for key "${key}": ${phrase}`, err);
ast = ['parsing error']; ast = ['parsing error'];
@ -417,10 +409,12 @@ export default class TranslationCore {
ast = parsed; ast = parsed;
locale = this.locale; locale = this.locale;
if ( this.locale === this.defaultLocale ) if ( ! settings.noCache ) {
this.phrases.set(key, phrase); if ( this.locale === this.defaultLocale )
this.phrases.set(key, phrase);
this.cache.set(key, parsed); this.cache.set(key, parsed);
}
} }
} }
@ -430,12 +424,12 @@ export default class TranslationCore {
return [ast, data, locale]; return [ast, data, locale];
} }
t(key, phrase, options, use_default) { t(key, phrase, options, settings) {
return listToString(this.tList(key, phrase, options, use_default)); return listToString(this.tList(key, phrase, options, settings));
} }
tList(key, phrase, options, use_default) { tList(key, phrase, options, settings) {
return this._processAST(...this._preTransform(key, phrase, options, use_default)); return this._processAST(...this._preTransform(key, phrase, options, settings));
} }
formatNode(node, data, locale = null, out = null, ast = null) { formatNode(node, data, locale = null, out = null, ast = null) {