Translations for core states (#575)

* Fix deeper nested translations build

* Make fallback to message optional

* Use translated state names

* Remove unused switch cases

* Use src en.json as fallback instead of downloaded

* Use separate translations for badge states

* Eliminate unnecessary StatesMixin

* Remove now unused localize fallback parameter

* Fix capitalization to match material guidelines

* Move media player text generation back to model

* Make localize args object

* Change Mixin to use computed function

* Revert to normal args spread for haLocalize

* Rename to computeHaLocalize

* Allow state to default for badge and media player

* Denormalize en.json with Lokalise placeholders

* Fix cleanups missed after master merge

* Split zwave query stage states to separate keys

* Throw error to fail gulp build

* Fix zwave template and regression on general state
This commit is contained in:
Adam Mills 2017-11-12 20:48:42 -05:00 committed by Paulus Schoutsen
parent 926c46b701
commit 056e9e0d74
10 changed files with 370 additions and 128 deletions

View File

@ -14,10 +14,10 @@ const outDir = 'build-translations';
const tasks = [];
function recursiveFlatten(prefix, data) {
var output = {};
let output = {};
Object.keys(data).forEach(function (key) {
if (typeof (data[key]) === 'object') {
output = Object.assign({}, output, recursiveFlatten(key + '.', data[key]));
output = Object.assign({}, output, recursiveFlatten(prefix + key + '.', data[key]));
} else {
output[prefix + key] = data[key];
}
@ -29,16 +29,68 @@ function flatten(data) {
return recursiveFlatten('', data);
}
let taskName = 'build-merged-translations';
/**
* Replace Lokalise key placeholders with their actual values.
*
* We duplicate the behavior of Lokalise here so that placeholders can
* be included in src/translations/en.json, but still be usable while
* developing locally.
*
* @link https://docs.lokalise.co/article/KO5SZWLLsy-key-referencing
*/
const re_key_reference = /\[%key:([^%]+)%\]/;
function lokalise_transform (data, original) {
const output = {};
Object.entries(data).forEach(([key, value]) => {
if (value instanceof Object) {
output[key] = lokalise_transform(value, original);
} else {
output[key] = value.replace(re_key_reference, (match, key) => {
const replace = key.split('::').reduce((tr, k) => tr[k], original);
if (typeof replace !== 'string') {
throw Error(`Invalid key placeholder ${key} in src/translations/en.json`);
}
return replace;
});
}
});
return output;
}
/**
* This task will build a master translation file, to be used as the base for
* all languages. This starts with src/translations/en.json, and replaces all
* Lokalise key placeholders with their target values. Under normal circumstances,
* this will be the same as translations/en.json However, we build it here to
* facilitate both making changes in development mode, and to ensure that the
* project is buildable immediately after merging new translation keys, since
* the Lokalise update to translations/en.json will not happen immediately.
*/
let taskName = 'build-master-translation';
gulp.task(taskName, function () {
return gulp.src('src/translations/en.json')
.pipe(transform(function(data, file) {
return lokalise_transform(data, data);
}))
.pipe(rename('translation-master.json'))
.pipe(gulp.dest(outDir));
});
tasks.push(taskName);
taskName = 'build-merged-translations';
gulp.task(taskName, ['build-master-translation'], function () {
return gulp.src(inDir + '/*.json')
.pipe(foreach(function (stream, file) {
// For each language generate a merged json file. It begins with en.json as
// a failsafe for untranslated strings, and merges all parent tags into one
// file for each specific subtag
.pipe(foreach(function(stream, file) {
// For each language generate a merged json file. It begins with the master
// translation as a failsafe for untranslated strings, and merges all parent
// tags into one file for each specific subtag
//
// TODO: This is a naive interpretation of BCP47 that should be improved.
// Will be OK for now as long as we don't have anything more complicated
// than a base translation + region.
const tr = path.basename(file.history[0], '.json');
const subtags = tr.split('-');
const src = [inDir + '/en.json']; // Start with en as a fallback for missing translations
const src = [outDir + '/translation-master.json'];
for (let i = 1; i <= subtags.length; i++) {
const lang = subtags.slice(0, i).join('-');
src.push(inDir + '/' + lang + '.json');

View File

@ -153,8 +153,8 @@
<div class='caption'>
[[computeStateName(stateObj)]]
<div class='title'>[[playerObj.primaryText]]</div>
[[playerObj.secondaryText]]<br />
<div class='title'>[[computePrimaryText(haLocalize, playerObj)]]</div>
[[playerObj.secondaryTitle]]<br />
</div>
</div>
@ -206,7 +206,8 @@
</dom-module>
<script>
class HaMediaPlayerCard extends window.hassMixins.EventsMixin(Polymer.Element) {
class HaMediaPlayerCard extends
window.hassMixins.LocalizeMixin(window.hassMixins.EventsMixin(Polymer.Element)) {
static get is() { return 'ha-media_player-card'; }
static get properties() {
return {
@ -309,6 +310,12 @@ class HaMediaPlayerCard extends window.hassMixins.EventsMixin(Polymer.Element) {
return new window.MediaPlayerEntity(hass, stateObj);
}
computePrimaryText(haLocalize, playerObj) {
return playerObj.primaryTitle
|| haLocalize('state.media_player', playerObj.stateObj.state)
|| haLocalize('state.default', playerObj.stateObj.state) || playerObj.stateObj.state;
}
computePlaybackControlIcon(playerObj) {
if (playerObj.isPlaying) {
return playerObj.supportsPause ? 'mdi:pause' : 'mdi:stop';

View File

@ -40,22 +40,24 @@
value='[[computeValue(state)]]'
icon='[[computeIcon(state)]]'
image='[[computeImage(state)]]'
label='[[computeLabel(state)]]'
label='[[computeLabel(haLocalize, state)]]'
description='[[computeDescription(state)]]'
></ha-label-badge>
</template>
</dom-module>
<script>
class HaStateLabelBadge extends window.hassMixins.EventsMixin(Polymer.Element) {
/*
* @appliesMixin window.hassMixins.LocalizeMixin
* @appliesMixin window.hassMixins.EventsMixin
*/
class HaStateLabelBadge extends
window.hassMixins.LocalizeMixin(window.hassMixins.EventsMixin(Polymer.Element)) {
static get is() { return 'ha-state-label-badge'; }
static get properties() {
return {
hass: {
type: Object,
},
hass: Object,
state: {
type: Object,
observer: 'stateChanged',
@ -101,7 +103,7 @@ class HaStateLabelBadge extends window.hassMixins.EventsMixin(Polymer.Element) {
if (state.state === 'unavailable') {
return null;
}
var domain = window.hassUtil.computeDomain(state);
const domain = window.hassUtil.computeDomain(state);
switch (domain) {
case 'alarm_control_panel':
if (state.state === 'pending') {
@ -131,26 +133,16 @@ class HaStateLabelBadge extends window.hassMixins.EventsMixin(Polymer.Element) {
return state.attributes.entity_picture || null;
}
computeLabel(state) {
if (state.state === 'unavailable') {
return 'unavai';
}
switch (window.hassUtil.computeDomain(state)) {
case 'device_tracker':
return state.state === 'not_home' ? 'Away' : state.state;
case 'alarm_control_panel':
if (state.state === 'pending') {
return 'pend';
} else if (state.state === 'armed_away' || state.state === 'armed_home') {
return 'armed';
} else if (state.state === 'triggered') {
return 'trig';
}
// state == 'disarmed'
return 'disarm';
default:
return state.attributes.unit_of_measurement || null;
computeLabel(haLocalize, state) {
const domain = window.hassUtil.computeDomain(state);
if (state.state === 'unavailable' ||
['device_tracker', 'alarm_control_panel'].includes(domain)) {
// Localize the state with a special state_badge namespace, which has variations of
// the state translations that are truncated to fit within the badge label. Translations
// are only added for device_tracker and alarm_control_panel.
return haLocalize(`state_badge.${domain}`, state.state) || haLocalize('state_badge.default', state.state) || state.state;
}
return state.attributes.unit_of_measurement || null;
}
computeDescription(state) {

View File

@ -106,19 +106,19 @@
<paper-listbox attr-for-selected='data-panel' selected='[[route.panel]]'>
<paper-icon-item on-tap='menuClicked' data-panel='states'>
<iron-icon slot="item-icon" icon='mdi:apps'></iron-icon>
<span class='item-text'>{{localize('panel', 'states')}}</span>
<span class='item-text'>[[haLocalize('panel', 'states')]]</span>
</paper-icon-item>
<template is='dom-repeat' items='[[panels]]'>
<paper-icon-item on-tap='menuClicked' data-panel$='[[item.url_path]]'>
<iron-icon slot="item-icon" icon='[[item.icon]]'></iron-icon>
<span class='item-text'>{{localize('panel', item.title)}}</span>
<span class='item-text'>[[computePanelName(haLocalize, item)]]</span>
</paper-icon-item>
</template>
<paper-icon-item on-tap='menuClicked' data-panel='logout' class='logout'>
<iron-icon slot="item-icon" icon='mdi:exit-to-app'></iron-icon>
<span class='item-text'>{{localize('panel', 'log_out')}}</span>
<span class='item-text'>[[haLocalize('panel', 'log_out')]]</span>
</paper-icon-item>
</paper-listbox>
@ -214,6 +214,10 @@ class HaSidebar extends
return hass.config.core.components.indexOf('mqtt') !== -1;
}
computePanelName(haLocalize, panel) {
return haLocalize('panel', panel.title) || panel.title;
}
computePanels(hass) {
var panels = hass.config.panels;
var sortValue = {

View File

@ -20,17 +20,18 @@
<div class='horizontal justified layout'>
<state-info state-obj="[[stateObj]]" in-dialog='[[inDialog]]'></state-info>
<div class='state'>[[computeStateDisplay(stateObj)]]</div>
<div class='state'>[[computeStateDisplay(haLocalize, stateObj)]]</div>
</div>
</template>
</dom-module>
<script>
class StateCardDisplay extends Polymer.Element {
class StateCardDisplay extends window.hassMixins.LocalizeMixin(Polymer.Element) {
static get is() { return 'state-card-display'; }
static get properties() {
return {
hass: Object,
stateObj: Object,
inDialog: {
type: Boolean,
@ -39,8 +40,55 @@ class StateCardDisplay extends Polymer.Element {
};
}
computeStateDisplay(stateObj) {
return window.hassUtil.computeStateState(stateObj);
computeStateDisplay(haLocalize, stateObj) {
if (!stateObj._stateDisplay) {
const domain = window.hassUtil.computeDomain(stateObj);
if (domain === 'binary_sensor') {
// Try device class translation, then default binary sensor translation
stateObj._stateDisplay =
haLocalize(`state.${domain}.${stateObj.attributes.device_class}`, stateObj.state)
|| haLocalize(`state.${domain}.default`, stateObj.state);
} else if (stateObj.attributes.unit_of_measurement) {
stateObj._stateDisplay = stateObj.state + ' ' + stateObj.attributes.unit_of_measurement;
} else if (domain === 'input_datetime') {
let date;
if (!stateObj.attributes.has_time) {
date = new Date(
stateObj.attributes.year,
stateObj.attributes.month - 1,
stateObj.attributes.day
);
stateObj._stateDisplay = window.hassUtil.formatDate(date);
} else if (!stateObj.attributes.has_date) {
date = new Date(
1970, 0, 1,
stateObj.attributes.hour,
stateObj.attributes.minute
);
stateObj._stateDisplay = window.hassUtil.formatTime(date);
} else {
date = new Date(
stateObj.attributes.year, stateObj.attributes.month - 1,
stateObj.attributes.day, stateObj.attributes.hour,
stateObj.attributes.minute
);
stateObj._stateDisplay = window.hassUtil.formatDateTime(date);
}
} else if (domain === 'zwave') {
if (['initializing', 'dead'].includes(stateObj.state)) {
stateObj._stateDisplay = haLocalize('state.zwave.query_stage', stateObj.state, 'query_stage', stateObj.attributes.query_stage);
} else {
stateObj._stateDisplay = haLocalize('state.zwave.default', stateObj.state);
}
} else {
stateObj._stateDisplay = haLocalize(`state.${domain}`, stateObj.state);
}
// Fall back to default or raw state if nothing else matches.
stateObj._stateDisplay = stateObj._stateDisplay
|| haLocalize('state.default', stateObj.state) || stateObj.state;
}
return stateObj._stateDisplay;
}
}
customElements.define(StateCardDisplay.is, StateCardDisplay);

View File

@ -38,15 +38,15 @@
<div class='horizontal justified layout'>
<state-info state-obj="[[stateObj]]" in-dialog='[[inDialog]]'></state-info>
<div class='state'>
<div class='main-text' take-height$='[[!playerObj.secondaryText]]'>[[playerObj.primaryText]]</div>
<div class='secondary-text'>[[playerObj.secondaryText]]</div>
<div class='main-text' take-height$='[[!playerObj.secondaryTitle]]'>[[computePrimaryText(haLocalize, playerObj)]]</div>
<div class='secondary-text'>[[playerObj.secondaryTitle]]</div>
</div>
</div>
</template>
</dom-module>
<script>
class StateCardMediaPlayer extends Polymer.Element {
class StateCardMediaPlayer extends window.hassMixins.LocalizeMixin(Polymer.Element) {
static get is() { return 'state-card-media_player'; }
static get properties() {
@ -67,6 +67,12 @@ class StateCardMediaPlayer extends Polymer.Element {
computePlayerObj(hass, stateObj) {
return new window.MediaPlayerEntity(hass, stateObj);
}
computePrimaryText(haLocalize, playerObj) {
return playerObj.primaryTitle
|| haLocalize('state.media_player', playerObj.stateObj.state)
|| haLocalize('state.default', playerObj.stateObj.state) || playerObj.stateObj.state;
}
}
customElements.define(StateCardMediaPlayer.is, StateCardMediaPlayer);
</script>

View File

@ -8,5 +8,209 @@
"log_out": "Log out",
"mailbox": "Mailbox",
"shopping_list": "Shopping list"
},
"state": {
"default": {
"off": "Off",
"on": "On",
"unknown": "Unknown",
"unavailable": "Unavailable"
},
"alarm_control_panel": {
"armed": "Armed",
"disarmed": "Disarmed",
"armed_home": "Armed home",
"armed_away": "Armed away",
"armed_night": "Armed night",
"pending": "Pending",
"arming": "Arming",
"disarming": "Disarming",
"triggered": "Triggered"
},
"automation": {
"off": "[%key:state::default::off%]",
"on": "[%key:state::default::on%]"
},
"binary_sensor": {
"default": {
"off": "[%key:state::default::off%]",
"on": "[%key:state::default::on%]"
},
"moisture": {
"off": "Dry",
"on": "Wet"
},
"gas": {
"off": "Clear",
"on": "Detected"
},
"motion": {
"off": "[%key:state::binary_sensor::gas::off%]",
"on": "[%key:state::binary_sensor::gas::on%]"
},
"occupancy": {
"off": "[%key:state::binary_sensor::gas::off%]",
"on": "[%key:state::binary_sensor::gas::on%]"
},
"smoke": {
"off": "[%key:state::binary_sensor::gas::off%]",
"on": "[%key:state::binary_sensor::gas::on%]"
},
"sound": {
"off": "[%key:state::binary_sensor::gas::off%]",
"on": "[%key:state::binary_sensor::gas::on%]"
},
"vibration": {
"off": "[%key:state::binary_sensor::gas::off%]",
"on": "[%key:state::binary_sensor::gas::on%]"
},
"opening": {
"off": "Closed",
"on": "Open"
},
"safety": {
"off": "Safe",
"on": "Unsafe"
}
},
"calendar": {
"off": "[%key:state::default::off%]",
"on": "[%key:state::default::on%]"
},
"camera": {
"recording": "Recording",
"streaming": "Streaming",
"idle": "Idle"
},
"climate": {
"off": "[%key:state::default::off%]",
"on": "[%key:state::default::on%]",
"heat": "Heat",
"cool": "Cool",
"idle": "Idle",
"auto": "Auto",
"dry": "Dry",
"fan_only": "Fan only",
"eco": "Eco",
"electric": "Electric",
"performance": "Performance",
"high_demand": "High demand",
"heat_pump": "Heat pump",
"gas": "Gas"
},
"configurator": {
"configure": "Configure",
"configured": "Configured"
},
"cover": {
"open": "Open",
"opening": "Opening",
"closed": "Closed",
"closing": "Closing",
"stopped": "Stopped"
},
"device_tracker": {
"home": "Home",
"not_home": "Away"
},
"fan": {
"off": "[%key:state::default::off%]",
"on": "[%key:state::default::on%]"
},
"group": {
"off": "[%key:state::default::off%]",
"on": "[%key:state::default::on%]",
"home": "[%key:state::device_tracker::home%]",
"not_home": "[%key:state::device_tracker::not_home%]",
"open": "[%key:state::cover::open%]",
"opening": "[%key:state::cover::opening%]",
"closed": "[%key:state::cover::closed%]",
"closing": "[%key:state::cover::closing%]",
"stopped": "[%key:state::cover::stopped%]",
"locked": "[%key:state::lock::locked%]",
"unlocked": "[%key:state::lock::unlocked%]",
"ok": "[%key:state::plant::ok%]",
"problem": "[%key:state::plant::problem%]"
},
"input_boolean": {
"off": "[%key:state::default::off%]",
"on": "[%key:state::default::on%]"
},
"light": {
"off": "[%key:state::default::off%]",
"on": "[%key:state::default::on%]"
},
"lock": {
"locked": "Locked",
"unlocked": "Unlocked"
},
"media_player": {
"off": "[%key:state::default::off%]",
"on": "[%key:state::default::on%]",
"playing": "Playing",
"paused": "Paused",
"idle": "Idle",
"standby": "Standby"
},
"plant": {
"ok": "OK",
"problem": "Problem"
},
"remote": {
"off": "[%key:state::default::off%]",
"on": "[%key:state::default::on%]"
},
"scene": {
"scening": "Scening"
},
"script": {
"off": "[%key:state::default::off%]",
"on": "[%key:state::default::on%]"
},
"sensor": {
"off": "[%key:state::default::off%]",
"on": "[%key:state::default::on%]"
},
"sun": {
"above_horizon": "Above horizon",
"below_horizon": "Below horizon"
},
"switch": {
"off": "[%key:state::default::off%]",
"on": "[%key:state::default::on%]"
},
"zwave": {
"default": {
"initializing": "Initializing",
"dead": "Dead",
"sleeping": "Sleeping",
"ready": "Ready"
},
"query_stage": {
"initializing": "[%key:state::zwave::default::initializing%] ({query_stage})",
"dead": "[%key:state::zwave::default::dead%] ({query_stage})"
}
}
},
"state_badge": {
"default": {
"unknown": "Unk",
"unavailable": "Unavai"
},
"alarm_control_panel": {
"armed": "Armed",
"disarmed": "Disarm",
"armed_home": "Armed",
"armed_away": "Armed",
"armed_night": "Armed",
"pending": "Pend",
"arming": "Arming",
"disarming": "Disarm",
"triggered": "Trig"
},
"device_tracker": {
"home": "[%key:state::device_tracker::home%]",
"not_home": "[%key:state::device_tracker::not_home%]"
}
}
}

View File

@ -82,9 +82,7 @@ window.hassMixins.LocalizeMixin = Polymer.dedupingMixin(superClass =>
class extends Polymer.mixinBehaviors([Polymer.AppLocalizeBehavior], superClass) {
static get properties() {
return {
hass: {
type: Object,
},
hass: Object,
language: {
type: String,
computed: 'computeLanguage(hass)',
@ -93,6 +91,10 @@ window.hassMixins.LocalizeMixin = Polymer.dedupingMixin(superClass =>
type: Object,
computed: 'computeResources(hass)',
},
haLocalize: {
type: Function,
computed: 'computeHaLocalize(localize)',
},
};
}
@ -104,9 +106,8 @@ window.hassMixins.LocalizeMixin = Polymer.dedupingMixin(superClass =>
return hass && hass.resources;
}
localize(namespace, message, ...args) {
// Return the input message if no translation is found
return super.localize(namespace + '.' + message, ...args) || message;
computeHaLocalize(localize) {
return (namespace, message, ...args) => localize(namespace + '.' + message, ...args);
}
});

View File

@ -471,77 +471,6 @@ window.hassUtil.sortByName = function (entityA, entityB) {
return 0;
};
window.hassUtil.computeStateState = function (stateObj) {
if (!stateObj._stateDisplay) {
stateObj._stateDisplay = stateObj.state.replace(/_/g, ' ');
const domain = window.hassUtil.computeDomain(stateObj);
if (stateObj.attributes.unit_of_measurement) {
stateObj._stateDisplay += ' ' + stateObj.attributes.unit_of_measurement;
}
if (domain === 'binary_sensor') {
switch (stateObj.attributes.device_class) {
case 'moisture':
stateObj._stateDisplay = (stateObj._stateDisplay === 'off') ? 'dry' : 'wet';
break;
case 'gas':
case 'motion':
case 'occupancy':
case 'smoke':
case 'sound':
case 'vibration':
stateObj._stateDisplay = (stateObj._stateDisplay === 'off') ? 'clear' : 'detected';
break;
case 'opening':
stateObj._stateDisplay = (stateObj._stateDisplay === 'off') ? 'closed' : 'open';
break;
case 'safety':
stateObj._stateDisplay = (stateObj._stateDisplay === 'off') ? 'safe' : 'unsafe';
break;
case 'cold':
case 'connectivity':
case 'heat':
case 'light':
case 'moving':
case 'power':
case 'plug':
default:
}
} else if (domain === 'input_datetime') {
let date;
if (!stateObj.attributes.has_time) {
date = new Date(
stateObj.attributes.year,
stateObj.attributes.month - 1,
stateObj.attributes.day
);
stateObj._stateDisplay = window.hassUtil.formatDate(date);
} else if (!stateObj.attributes.has_date) {
date = new Date(
1970, 0, 1,
stateObj.attributes.hour,
stateObj.attributes.minute
);
stateObj._stateDisplay = window.hassUtil.formatTime(date);
} else {
date = new Date(
stateObj.attributes.year, stateObj.attributes.month - 1,
stateObj.attributes.day, stateObj.attributes.hour,
stateObj.attributes.minute
);
stateObj._stateDisplay = window.hassUtil.formatDateTime(date);
}
} else if (domain === 'zwave') {
if (['initializing', 'dead'].includes(stateObj.state) && stateObj.attributes && 'query_stage' in stateObj.attributes) {
return stateObj._stateDisplay + ' (' + stateObj.attributes.query_stage + ')';
}
return stateObj._stateDisplay;
}
}
return stateObj._stateDisplay;
};
window.hassUtil.isComponentLoaded = function (hass, component) {
return hass && hass.config.core.components.indexOf(component) !== -1;
};

View File

@ -109,12 +109,11 @@
/* eslint-enable no-bitwise */
addGetter('primaryText', function () {
return this.stateObj.attributes.media_title ||
window.hassUtil.computeStateState(this.stateObj);
addGetter('primaryTitle', function () {
return this.stateObj.attributes.media_title;
});
addGetter('secondaryText', function () {
addGetter('secondaryTitle', function () {
if (this.isMusic) {
return this.stateObj.attributes.media_artist;
} else if (this.isTVShow) {