diff --git a/src/common/string/is_date.ts b/src/common/string/is_date.ts new file mode 100644 index 0000000000..b075efc1e4 --- /dev/null +++ b/src/common/string/is_date.ts @@ -0,0 +1,11 @@ +// https://regex101.com/r/kc5C14/2 +const regExpString = "^\\d{4}-(0[1-9]|1[0-2])-([12]\\d|0[1-9]|3[01])"; + +const regExp = new RegExp(regExpString + "$"); +// 2nd expression without the "end of string" enforced, so it can be used +// to just verify the start of a string and then based on that result e.g. +// check for a full timestamp string efficiently. +const regExpNoStringEnd = new RegExp(regExpString); + +export const isDate = (input: string, allowCharsAfterDate = false): boolean => + allowCharsAfterDate ? regExpNoStringEnd.test(input) : regExp.test(input); diff --git a/src/common/string/is_timestamp.ts b/src/common/string/is_timestamp.ts new file mode 100644 index 0000000000..e4b7649529 --- /dev/null +++ b/src/common/string/is_timestamp.ts @@ -0,0 +1,11 @@ +// https://stackoverflow.com/a/14322189/1947205 +// Changes: +// 1. Do not allow a plus or minus at the start. +// 2. Enforce that we have a "T" or a blank after the date portion +// to ensure we have a timestamp and not only a date. +// 3. Disallow dates based on week number. +// 4. Disallow dates only consisting of a year. +// https://regex101.com/r/kc5C14/3 +const regexp = /^\d{4}-(0[1-9]|1[0-2])-([12]\d|0[1-9]|3[01])[T| ](((([01]\d|2[0-3])((:?)[0-5]\d)?|24:?00)([.,]\d+(?!:))?)(\8[0-5]\d([.,]\d+)?)?([zZ]|([+-])([01]\d|2[0-3]):?([0-5]\d)?)?)$/; + +export const isTimestamp = (input: string): boolean => regexp.test(input); diff --git a/src/components/ha-attributes.ts b/src/components/ha-attributes.ts index 68d3111caa..5c75269a0b 100644 --- a/src/components/ha-attributes.ts +++ b/src/components/ha-attributes.ts @@ -1,16 +1,14 @@ import { HassEntity } from "home-assistant-js-websocket"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators"; -import { until } from "lit/directives/until"; import { haStyle } from "../resources/styles"; import { HomeAssistant } from "../types"; import hassAttributeUtil, { formatAttributeName, + formatAttributeValue, } from "../util/hass-attributes-util"; import "./ha-expansion-panel"; -let jsYamlPromise: Promise; - @customElement("ha-attributes") class HaAttributes extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -124,38 +122,7 @@ class HaAttributes extends LitElement { return "-"; } const value = this.stateObj.attributes[attribute]; - return this.formatAttributeValue(value); - } - - private formatAttributeValue(value: any): string | TemplateResult { - if (value === null) { - return "-"; - } - // YAML handling - if ( - (Array.isArray(value) && value.some((val) => val instanceof Object)) || - (!Array.isArray(value) && value instanceof Object) - ) { - if (!jsYamlPromise) { - jsYamlPromise = import("js-yaml"); - } - const yaml = jsYamlPromise.then((jsYaml) => jsYaml.safeDump(value)); - return html`
${until(yaml, "")}
`; - } - // URL handling - if (typeof value === "string" && value.startsWith("http")) { - try { - // If invalid URL, exception will be raised - const url = new URL(value); - if (url.protocol === "http:" || url.protocol === "https:") - return html`${value}`; - } catch (_) { - // Nothing to do here - } - } - return Array.isArray(value) ? value.join(", ") : value; + return formatAttributeValue(this.hass, value); } private expandedChanged(ev) { diff --git a/src/panels/lovelace/cards/hui-entity-card.ts b/src/panels/lovelace/cards/hui-entity-card.ts index fa7d14d970..28c7709926 100644 --- a/src/panels/lovelace/cards/hui-entity-card.ts +++ b/src/panels/lovelace/cards/hui-entity-card.ts @@ -18,6 +18,7 @@ import "../../../components/ha-card"; import "../../../components/ha-icon"; import { UNAVAILABLE_STATES } from "../../../data/entity"; import { HomeAssistant } from "../../../types"; +import { formatAttributeValue } from "../../../util/hass-attributes-util"; import { computeCardSize } from "../common/compute-card-size"; import { findEntities } from "../common/find-entities"; import { hasConfigOrEntityChanged } from "../common/has-changed"; @@ -124,8 +125,11 @@ export class HuiEntityCard extends LitElement implements LovelaceCard {
${"attribute" in this._config - ? stateObj.attributes[this._config.attribute!] ?? - this.hass.localize("state.default.unknown") + ? formatAttributeValue( + this.hass, + stateObj.attributes[this._config.attribute!] ?? + this.hass.localize("state.default.unknown") + ) : stateObj.attributes.unit_of_measurement ? formatNumber(stateObj.state, this.hass.locale) : computeStateDisplay( diff --git a/src/panels/lovelace/special-rows/hui-attribute-row.ts b/src/panels/lovelace/special-rows/hui-attribute-row.ts index 49a5eb3111..77b05f0803 100644 --- a/src/panels/lovelace/special-rows/hui-attribute-row.ts +++ b/src/panels/lovelace/special-rows/hui-attribute-row.ts @@ -10,6 +10,7 @@ import { customElement, property, state } from "lit/decorators"; import checkValidDate from "../../../common/datetime/check_valid_date"; import { formatNumber } from "../../../common/string/format_number"; import { HomeAssistant } from "../../../types"; +import { formatAttributeValue } from "../../../util/hass-attributes-util"; import { hasConfigOrEntityChanged } from "../common/has-changed"; import "../components/hui-generic-entity-row"; import "../components/hui-timestamp-display"; @@ -72,7 +73,9 @@ class HuiAttributeRow extends LitElement implements LovelaceRow { >` : typeof attribute === "number" ? formatNumber(attribute, this.hass.locale) - : attribute ?? "-"} + : attribute !== undefined + ? formatAttributeValue(this.hass, attribute) + : "-"} ${this._config.suffix}
diff --git a/src/util/hass-attributes-util.ts b/src/util/hass-attributes-util.ts index 8a44c00aa3..ff0804b00f 100644 --- a/src/util/hass-attributes-util.ts +++ b/src/util/hass-attributes-util.ts @@ -1,3 +1,14 @@ +import { html, TemplateResult } from "lit"; +import { until } from "lit/directives/until"; +import checkValidDate from "../common/datetime/check_valid_date"; +import { formatDate } from "../common/datetime/format_date"; +import { formatDateTimeWithSeconds } from "../common/datetime/format_date_time"; +import { isDate } from "../common/string/is_date"; +import { isTimestamp } from "../common/string/is_timestamp"; +import { HomeAssistant } from "../types"; + +let jsYamlPromise: Promise; + const hassAttributeUtil = { DOMAIN_DEVICE_CLASS: { binary_sensor: [ @@ -130,3 +141,59 @@ export function formatAttributeName(value: string): string { .replace(/\bgps\b/g, "GPS"); return value.charAt(0).toUpperCase() + value.slice(1); } + +export function formatAttributeValue( + hass: HomeAssistant, + value: any +): string | TemplateResult { + if (value === null) { + return "-"; + } + + // YAML handling + if ( + (Array.isArray(value) && value.some((val) => val instanceof Object)) || + (!Array.isArray(value) && value instanceof Object) + ) { + if (!jsYamlPromise) { + jsYamlPromise = import("js-yaml"); + } + const yaml = jsYamlPromise.then((jsYaml) => jsYaml.safeDump(value)); + return html`
${until(yaml, "")}
`; + } + + if (typeof value === "string") { + // URL handling + if (value.startsWith("http")) { + try { + // If invalid URL, exception will be raised + const url = new URL(value); + if (url.protocol === "http:" || url.protocol === "https:") + return html`${value}`; + } catch (_) { + // Nothing to do here + } + } + + // Date handling + if (isDate(value, true)) { + // Timestamp handling + if (isTimestamp(value)) { + const date = new Date(value); + if (checkValidDate(date)) { + return formatDateTimeWithSeconds(date, hass.locale); + } + } + + // Value was not a timestamp, so only do date formatting + const date = new Date(value); + if (checkValidDate(date)) { + return formatDate(date, hass.locale); + } + } + } + + return Array.isArray(value) ? value.join(", ") : value; +} diff --git a/test-mocha/common/string/is_date.ts b/test-mocha/common/string/is_date.ts new file mode 100644 index 0000000000..58964f766f --- /dev/null +++ b/test-mocha/common/string/is_date.ts @@ -0,0 +1,12 @@ +import { assert } from "chai"; +import { isDate } from "../../../src/common/string/is_date"; + +describe("isDate", () => { + assert.strictEqual(isDate("ABC"), false); + + assert.strictEqual(isDate("2021-02-03", false), true); + assert.strictEqual(isDate("2021-02-03", true), true); + + assert.strictEqual(isDate("2021-05-25T19:23:52+00:00", true), true); + assert.strictEqual(isDate("2021-05-25T19:23:52+00:00", false), false); +});