mirror of
https://github.com/FrankerFaceZ/FrankerFaceZ.git
synced 2025-08-02 16:08:31 +00:00
4.13.1
* 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:
parent
7f0cad4bd4
commit
14e9f10685
15 changed files with 790 additions and 135 deletions
7
package-lock.json
generated
7
package-lock.json
generated
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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 || [];
|
||||||
|
|
188
src/i18n.js
188
src/i18n.js
|
@ -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) {
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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'}) )
|
||||||
|
|
|
@ -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);
|
||||||
},
|
},
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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 )
|
||||||
|
|
|
@ -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>)}
|
||||||
|
|
|
@ -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('.');
|
||||||
|
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue