Compare commits

...
8 changed files with 472 additions and 63 deletions

No files matched your search

+87 -42
View File
@@ -157,6 +157,79 @@ async function pollProcess(lokaliseApi, projectId, processId) {
}
/* eslint-enable no-await-in-loop */
const DRAFTS_ACCEPTED_KEYS = [
// Written per language rather than translated from English, so an English edit doesn't make them outdated
"ui.components.rooms.keywords",
];
async function exportTranslations(lokaliseApi, projectId, filterData) {
const exportProcess = await lokaliseApi.files().async_download(projectId, {
format: "json",
original_filenames: false,
replace_breaks: false,
json_unescaped_slashes: true,
export_empty_as: "skip",
...(filterData && { filter_data: filterData }),
});
const finishedProcess = await pollProcess(
lokaliseApi,
projectId,
exportProcess.process_id
);
const bundleUrl = finishedProcess.details.download_url;
console.log(`Downloading translations from: ${bundleUrl}`);
const response = await fetch(bundleUrl);
if (response.status !== 200 && response.status !== 0) {
throw new Error(response.statusText);
}
console.log(`Extracting translations...`);
const contents = await JSZip.loadAsync(await response.arrayBuffer());
const files = {};
await Promise.all(
Object.keys(contents.files).map(async (filename) => {
const file = contents.file(filename);
if (!file) {
// no file, probably a directory
return;
}
files[filename.split("/").splice(-1)[0]] = await file.async("string");
})
);
return files;
}
function acceptDrafts(files, draftFiles) {
Object.entries(draftFiles).forEach(([filename, content]) => {
const draftData = JSON.parse(content);
const data = files[filename] ? JSON.parse(files[filename]) : {};
let changed = false;
DRAFTS_ACCEPTED_KEYS.forEach((key) => {
const keys = key.split(".");
const drafts = keys.reduce((obj, part) => obj?.[part], draftData);
if (!drafts) {
return;
}
const parent = keys.slice(0, -1).reduce((obj, part) => {
obj[part] ??= {};
return obj[part];
}, data);
parent[keys.at(-1)] = drafts;
changed = true;
});
if (changed) {
files[filename] = JSON.stringify(data, null, 2);
}
});
}
gulp.task("fetch-lokalise", async function () {
let apiKey;
try {
@@ -193,52 +266,24 @@ gulp.task("fetch-lokalise", async function () {
await Promise.all(
projects.map(async ([project, projectId]) => {
try {
const exportProcess = await lokaliseApi
.files()
.async_download(projectId, {
format: "json",
original_filenames: false,
replace_breaks: false,
json_unescaped_slashes: true,
export_empty_as: "skip",
filter_data: ["verified"],
});
const finishedProcess = await pollProcess(
lokaliseApi,
projectId,
exportProcess.process_id
);
const bundleUrl = finishedProcess.details.download_url;
console.log(`Downloading translations from: ${bundleUrl}`);
const response = await fetch(bundleUrl);
if (response.status !== 200 && response.status !== 0) {
throw new Error(response.statusText);
const [files, draftFiles] = await Promise.all([
exportTranslations(lokaliseApi, projectId, ["verified"]),
project === "frontend"
? exportTranslations(lokaliseApi, projectId)
: undefined,
]);
if (draftFiles) {
acceptDrafts(files, draftFiles);
}
console.log(`Extracting translations...`);
const contents = await JSZip.loadAsync(await response.arrayBuffer());
await mkdirPromise;
await Promise.all(
Object.keys(contents.files).map(async (filename) => {
const file = contents.file(filename);
if (!file) {
// no file, probably a directory
return;
}
const content = await file.async("nodebuffer");
await fs.writeFile(
path.join(inDir, project, filename.split("/").splice(-1)[0]),
content,
{ flag: "w", encoding }
);
})
Object.entries(files).map(([filename, content]) =>
fs.writeFile(path.join(inDir, project, filename), content, {
flag: "w",
encoding,
})
)
);
} catch (err) {
console.error(err);
+4
View File
@@ -112,6 +112,9 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@property({ type: Boolean, attribute: "no-sort" }) public noSort = false;
@property({ attribute: "pin-block" })
public pinBlock?: "start" | "center" | "end" | "nearest";
// Skip the "unknown value" highlight and note for a value that is not in the
// list but that the value renderer presents on its own.
@property({ type: Boolean, attribute: "no-unknown-state" })
@@ -347,6 +350,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
.searchKeys=${this.searchKeys}
.customValueLabel=${this.customValueLabel}
.noSort=${this.noSort}
.pinBlock=${this.pinBlock}
></ha-picker-combo-box>
`;
}
+62 -18
View File
@@ -1,13 +1,17 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { TemplateResult } from "lit";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { customIcons } from "../data/custom_icons";
import type { ValueChangedEvent } from "../types";
import "./ha-combo-box-item";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import "./ha-icon";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
@@ -50,16 +54,17 @@ const createIconItem = (icon: IconData, prefix: string): PickerComboBoxItem => {
const loadIcons = async () => {
ICONS_LOADED = true;
const iconList = await import("../../build/mdi/iconList.json");
ICONS = iconList.default.map((icon) => createIconItem(icon, "mdi"));
const sortIcons = (icons: PickerComboBoxItem[]) =>
icons.sort((a, b) => caseInsensitiveStringCompare(a.id, b.id));
const customIconLoads: Promise<PickerComboBoxItem[]>[] = [];
Object.keys(customIcons).forEach((iconSet) => {
customIconLoads.push(loadCustomIconItems(iconSet));
});
(await Promise.all(customIconLoads)).forEach((customIconItems) => {
ICONS.push(...customIconItems);
});
const iconList = await import("../../build/mdi/iconList.json");
const mdiIcons = iconList.default.map((icon) => createIconItem(icon, "mdi"));
ICONS = sortIcons([...mdiIcons]);
const customIconItems = await Promise.all(
Object.keys(customIcons).map(loadCustomIconItems)
);
ICONS = sortIcons([...mdiIcons, ...customIconItems.flat()]);
};
const loadCustomIconItems = async (
@@ -81,8 +86,8 @@ const loadCustomIconItems = async (
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
<ha-combo-box-item>
<ha-icon .icon=${item.id} slot="start"></ha-icon>
${item.id}
<ha-icon .icon=${item.icon ?? item.id} slot="start"></ha-icon>
${item.icon ?? item.id}
</ha-combo-box-item>
`;
@@ -104,12 +109,32 @@ export class HaIconPicker extends LitElement {
@property({ type: Boolean }) public invalid = false;
private _getIconPickerItems = (): PickerComboBoxItem[] => ICONS;
@property({ attribute: false }) public suggestedIcons?: string[];
@state() @consumeLocalize() private _localize!: LocalizeFunc;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
private _getIconPickerItems = () =>
this._itemsWithSuggestions(ICONS, this.suggestedIcons, this._localize);
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (changedProps.has("suggestedIcons") || changedProps.has("_localize")) {
this._picker?.refreshItems();
}
}
protected render(): TemplateResult {
return html`
<ha-generic-picker
allow-custom-value
no-sort
.pinBlock=${
this.value && this.suggestedIcons?.includes(this.value)
? "end"
: undefined
}
.getItems=${this._getIconPickerItems}
.helper=${this.helper}
.disabled=${this.disabled}
@@ -128,15 +153,35 @@ export class HaIconPicker extends LitElement {
`;
}
private _itemsWithSuggestions = memoizeOne(
(
icons: PickerComboBoxItem[],
suggestedIcons: string[] | undefined,
localize: LocalizeFunc
): (PickerComboBoxItem | string)[] => {
if (!suggestedIcons?.length) {
return icons;
}
const suggested = new Set(suggestedIcons);
return [
localize("ui.components.icon_picker.suggested"),
...suggestedIcons.map((icon) => ({ id: icon, primary: icon, icon })),
localize("ui.components.icon_picker.all_icons"),
...icons.filter((item) => !suggested.has(item.id)),
];
}
);
// Filter can take a significant chunk of frame (up to 3-5 ms)
private _filterIcons = memoizeOne(
(
filter: string,
filteredItems: PickerComboBoxItem[],
allItems: PickerComboBoxItem[]
_allItems: PickerComboBoxItem[]
): PickerComboBoxItem[] => {
const normalizedFilter = filter.toLowerCase().replace(/\s+/g, "-");
const iconItems = allItems?.length ? allItems : filteredItems;
// The full list also holds the suggestions and their section titles
const iconItems = ICONS.length ? ICONS : filteredItems;
if (!normalizedFilter.length) {
return iconItems;
@@ -175,8 +220,7 @@ export class HaIconPicker extends LitElement {
protected firstUpdated() {
if (!ICONS_LOADED) {
loadIcons().then(() => {
this._getIconPickerItems = (): PickerComboBoxItem[] => ICONS;
this.requestUpdate();
this._picker?.refreshItems();
});
}
}
+12 -2
View File
@@ -201,6 +201,9 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@property({ type: Boolean, attribute: "no-sort" }) public noSort = false;
@property({ attribute: "pin-block" })
public pinBlock?: "start" | "center" | "end" | "nearest";
@query(".list") private _list?: HaListBase;
@query("ha-input-search") private _searchFieldElement?: HaInputSearch;
@@ -453,6 +456,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
this.value
)}
.pinIndex=${this.value ? this._getInitialSelectedIndex() : undefined}
.pinBlock=${this.pinBlock ?? "center"}
@mousedown=${this._preventBlur}
@focus=${this._focusList}
@blur=${this._resetSelectedItem}
@@ -685,6 +689,11 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
fireEvent(this, "index-selected", { index, item, newTab });
}
// Fuse indexes every row as text when the first one is a section title
private _searchableItems = memoizeOne((items: PickerComboBoxItem[]) =>
items.filter((item) => typeof item !== "string")
);
private _fuseIndex = memoizeOne(
(states: PickerComboBoxItem[], searchKeys?: FuseWeightedKey[]) =>
Fuse.createIndex(searchKeys || DEFAULT_SEARCH_KEYS, states)
@@ -708,10 +717,11 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
return;
}
const index = this._fuseIndex(this._allItems, this.searchKeys);
const searchableItems = this._searchableItems(this._allItems);
const index = this._fuseIndex(searchableItems, this.searchKeys);
let filteredItems = multiTermSortedSearch<PickerComboBoxItem>(
this._allItems,
searchableItems,
searchString,
(item) => item.id,
index
+201
View File
@@ -0,0 +1,201 @@
import type { LocalizeFunc } from "../common/translations/localize";
export const ROOM_ICONS = {
living_room: [
"mdi:sofa",
"mdi:sofa-single",
"mdi:television",
"mdi:fireplace",
"mdi:floor-lamp",
],
bedroom: ["mdi:bed", "mdi:bed-king", "mdi:bed-double", "mdi:lamp"],
kids_room: ["mdi:teddy-bear", "mdi:toy-brick", "mdi:bunk-bed", "mdi:cradle"],
kitchen: [
"mdi:stove",
"mdi:fridge",
"mdi:countertop",
"mdi:chef-hat",
"mdi:pot-steam",
"mdi:coffee-maker",
],
dining_room: [
"mdi:silverware-fork-knife",
"mdi:table-chair",
"mdi:table-furniture",
"mdi:glass-wine",
],
bathroom: [
"mdi:shower",
"mdi:bathtub",
"mdi:shower-head",
"mdi:toothbrush",
"mdi:hair-dryer",
],
toilet: ["mdi:toilet", "mdi:paper-roll"],
office: [
"mdi:desk",
"mdi:laptop",
"mdi:monitor",
"mdi:chair-rolling",
"mdi:bookshelf",
],
laundry_room: [
"mdi:washing-machine",
"mdi:tumble-dryer",
"mdi:iron",
"mdi:hanger",
],
hallway: ["mdi:door", "mdi:door-open", "mdi:coat-rack", "mdi:key"],
stairs: ["mdi:stairs", "mdi:stairs-up", "mdi:stairs-down"],
garage: ["mdi:garage", "mdi:garage-variant", "mdi:car", "mdi:tools"],
garden: [
"mdi:flower",
"mdi:tree",
"mdi:grass",
"mdi:sprout",
"mdi:watering-can",
"mdi:greenhouse",
],
terrace: [
"mdi:balcony",
"mdi:grill",
"mdi:umbrella-outline",
"mdi:coach-lamp",
"mdi:patio-heater",
],
basement: [
"mdi:home-floor-negative-1",
"mdi:stairs-down",
"mdi:water-boiler",
],
attic: ["mdi:home-roof", "mdi:package-variant-closed"],
storage: ["mdi:archive", "mdi:package-variant-closed", "mdi:warehouse"],
utility_room: [
"mdi:water-boiler",
"mdi:heat-pump",
"mdi:server-network",
"mdi:flash",
],
pool: ["mdi:pool", "mdi:swim"],
media_room: [
"mdi:theater",
"mdi:projector",
"mdi:television",
"mdi:movie-open",
],
game_room: [
"mdi:gamepad-variant",
"mdi:billiards",
"mdi:cards-playing",
"mdi:dice-multiple",
],
music_room: ["mdi:piano", "mdi:guitar-acoustic", "mdi:music"],
library: ["mdi:bookshelf", "mdi:book-open-variant", "mdi:library"],
bar: [
"mdi:glass-cocktail",
"mdi:glass-mug-variant",
"mdi:bottle-wine",
"mdi:beer",
],
dressing_room: [
"mdi:hanger",
"mdi:wardrobe",
"mdi:tshirt-crew",
"mdi:shoe-heel",
],
workshop: ["mdi:tools", "mdi:hammer-wrench", "mdi:toolbox", "mdi:saw-blade"],
server_room: ["mdi:server-network", "mdi:server", "mdi:router-network"],
safe_room: ["mdi:shield-home", "mdi:shield-lock", "mdi:safe"],
prayer_room: ["mdi:hands-pray", "mdi:meditation", "mdi:candle"],
sauna: ["mdi:spa", "mdi:hot-tub", "mdi:thermometer-high"],
outdoor_kitchen: ["mdi:grill", "mdi:grill-outline", "mdi:pizza"],
driveway: ["mdi:road-variant", "mdi:car", "mdi:gate"],
} as const satisfies Record<string, readonly string[]>;
export type RoomType = keyof typeof ROOM_ICONS;
type RoomKeywords = Partial<Record<RoomType, string[]>>;
export const ROOM_TYPES = Object.keys(ROOM_ICONS) as RoomType[];
export const isRoomType = (value: string): value is RoomType =>
(ROOM_TYPES as string[]).includes(value);
// Languages written without spaces between words
const NO_WORD_SPACING =
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Thai}]/u;
const normalizeRoomText = (text: string): string =>
text
.normalize("NFD")
.replace(/\p{M}/gu, "")
.toLowerCase()
.replace(/['’]/g, "")
.replace(/[^\p{L}\p{N}]+/gu, " ")
.trim();
interface RoomMatch {
room: RoomType;
start: number;
end: number;
}
const findMatches = (name: string, keywords: RoomKeywords): RoomMatch[] => {
const text = ` ${normalizeRoomText(name)} `;
const candidates: RoomMatch[] = [];
for (const room of ROOM_TYPES) {
for (const keyword of keywords[room] ?? []) {
const phrase = normalizeRoomText(keyword);
if (!phrase) {
continue;
}
const needle = NO_WORD_SPACING.test(phrase) ? phrase : ` ${phrase} `;
const start = text.indexOf(needle);
if (start !== -1) {
candidates.push({ room, start, end: start + needle.length });
}
}
}
// The longest phrase wins, so "guest bedroom" is not also read as "bedroom"
candidates.sort((a, b) => b.end - b.start - (a.end - a.start));
const kept: RoomMatch[] = [];
for (const match of candidates) {
const overlaps = kept.some(
(other) => match.start < other.end - 1 && other.start < match.end - 1
);
if (!overlaps) {
kept.push(match);
}
}
return kept.sort((a, b) => a.start - b.start);
};
const matchRoomTypes = (
names: string[],
keywords: RoomKeywords
): RoomType[] => {
const rooms = new Set<RoomType>();
for (const name of names) {
for (const match of findMatches(name, keywords)) {
rooms.add(match.room);
}
}
return [...rooms];
};
const roomIconSuggestions = (rooms: RoomType[]): string[] => [
...new Set(rooms.flatMap((room) => ROOM_ICONS[room])),
];
export const getAreaIconSuggestions = (
names: string[],
localize: LocalizeFunc
): string[] => {
const keywords: RoomKeywords = Object.fromEntries(
ROOM_TYPES.map((room) => [
room,
localize(`ui.components.rooms.keywords.${room}`).split("|"),
])
);
return roomIconSuggestions(matchRoomTypes(names, keywords));
};
@@ -1,5 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
@@ -29,6 +29,10 @@ import type {
AreaRegistryEntryMutableParams,
} from "../../../data/area/area_registry";
import { deleteAreaRegistryEntry } from "../../../data/area/area_registry";
import {
getAreaIconSuggestions,
ROOM_ICONS,
} from "../../../data/area_icon_suggestions";
import {
SENSOR_DEVICE_CLASS_HUMIDITY,
SENSOR_DEVICE_CLASS_TEMPERATURE,
@@ -96,6 +100,10 @@ class DialogAreaDetail
@state() private _submitting = false;
@state() private _suggestedIcons?: string[];
private _iconFollowsName = false;
@state() private _open = false;
@state() private _suggestionInclude: MetadataSuggestionInclude = {
@@ -128,6 +136,7 @@ class DialogAreaDetail
this._temperatureEntity = null;
this._humidityEntity = null;
}
this._iconFollowsName = !this._params.entry;
this._open = true;
this._initDirtyTracking({ type: "deep" }, this._currentState());
await this.updateComplete;
@@ -151,6 +160,30 @@ class DialogAreaDetail
return true;
}
protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (changedProps.has("_name") || changedProps.has("_aliases")) {
this._updateSuggestedIcons();
}
}
private _updateSuggestedIcons() {
const icons = getAreaIconSuggestions(
[this._name ?? "", ...(this._aliases ?? [])],
this.hass.localize
);
this._suggestedIcons = icons;
if (this._iconFollowsName) {
const untouched = !this.isDirtyState;
this._icon = icons[0] ?? null;
if (untouched) {
this._initDirtyTracking({ type: "deep" }, this._currentState());
} else {
this._updateDirtyState(this._currentState());
}
}
}
private _entityName(entityId: string | null): string | null {
const stateObj = entityId ? this.hass.states[entityId] : undefined;
return stateObj ? this.hass.formatEntityName(stateObj) : null;
@@ -193,6 +226,7 @@ class DialogAreaDetail
.value=${this._icon}
@value-changed=${this._iconChanged}
.label=${this.hass.localize("ui.panel.config.areas.editor.icon")}
.suggestedIcons=${this._suggestedIcons}
></ha-icon-picker>
<ha-floor-picker
@@ -300,6 +334,7 @@ class DialogAreaDetail
this._suggestionInclude = {
...this._suggestionInclude,
name: this._name.trim() === "",
room: !this._icon || this._iconFollowsName,
};
return generateMetadataSuggestionTask<{
@@ -349,6 +384,11 @@ class DialogAreaDetail
this._floor = processed.floor;
}
if (processed.room && (!this._icon || this._iconFollowsName)) {
this._icon = ROOM_ICONS[processed.room][0];
this._iconFollowsName = false;
}
this._updateDirtyState(this._currentState());
}
@@ -464,6 +504,7 @@ class DialogAreaDetail
private _iconChanged(ev) {
this._error = undefined;
this._icon = ev.detail.value;
this._iconFollowsName = false;
this._updateDirtyState(this._currentState());
}
@@ -1,5 +1,7 @@
import { dump } from "js-yaml";
import type { AITaskStructure, GenDataTaskResult } from "../../../data/ai_task";
import type { RoomType } from "../../../data/area_icon_suggestions";
import { isRoomType, ROOM_TYPES } from "../../../data/area_icon_suggestions";
import type { HomeAssistant } from "../../../types";
import type { SuggestWithAIGenerateTask } from "../../../components/ha-suggest-with-ai-button";
import {
@@ -14,6 +16,7 @@ export interface MetadataSuggestionResult {
category?: string;
labels?: string[];
floor?: string;
room?: RoomType;
}
export type MetadataSuggestionDomain =
@@ -25,6 +28,7 @@ export interface MetadataSuggestionInclude {
categories?: boolean;
labels?: boolean;
floor?: boolean;
room?: boolean;
}
export const SUGGESTION_INCLUDE_DEFAULT: MetadataSuggestionInclude = {
@@ -86,6 +90,7 @@ export async function generateMetadataSuggestionTask<T>(
// providers reject a select/enum schema with an empty options list.
const includeCategories = categoryOptions.length > 0;
const includeFloor = floorOptions.length > 0;
const includeRoom = Boolean(include.room) && domain === "area";
const structure: AITaskStructure = {
...(include.name && {
@@ -139,6 +144,20 @@ export async function generateMetadataSuggestionTask<T>(
},
},
}),
...(includeRoom && {
room: {
description: `The type of room that best matches the ${domain}`,
required: false,
selector: {
select: {
options: ROOM_TYPES.map((room) => ({
value: room,
label: room.replace(/_/g, " "),
})),
},
},
},
}),
};
const requestedParts = [
@@ -147,6 +166,7 @@ export async function generateMetadataSuggestionTask<T>(
includeCategories ? "a category" : null,
include.labels ? "labels" : null,
includeFloor ? "a floor" : null,
includeRoom ? "a room type" : null,
].filter((entry): entry is string => entry !== null);
const categoryLabels: string[] = [
@@ -233,6 +253,10 @@ export async function processMetadataSuggestion(
const processed: MetadataSuggestionResult = {
name: include.name ? result.data.name : undefined,
description: include.description ? result.data.description : undefined,
room:
include.room && result.data.room && isRoomType(result.data.room)
? result.data.room
: undefined,
};
// Convert category name to ID
+40
View File
@@ -1105,6 +1105,46 @@
"failed_create_floor": "Failed to create floor.",
"unknown": "Unknown floor selected"
},
"rooms": {
"keywords": {
"living_room": "living room|lounge|family room|sitting room|den|living",
"bedroom": "bedroom|guest room|master bedroom|master suite|bed room",
"kids_room": "kids room|kids' room|kid's room|children's room|nursery|playroom|baby room",
"kitchen": "kitchen|kitchenette",
"dining_room": "dining room|dining area|dining",
"bathroom": "bathroom|bath|shower room|ensuite|washroom",
"toilet": "toilet|wc|restroom|powder room|loo|lavatory",
"office": "office|study|home office|workspace|workroom",
"laundry_room": "laundry|laundry room",
"hallway": "hallway|hall|corridor|entrance|entryway|foyer|mudroom",
"stairs": "stairs|staircase|stairway|stairwell|landing",
"garage": "garage|carport",
"garden": "garden|yard|backyard|front yard|lawn|greenhouse",
"terrace": "terrace|patio|balcony|deck|porch|veranda",
"basement": "basement|cellar",
"attic": "attic|loft",
"storage": "storage|storage room|pantry|closet|shed|storeroom",
"utility_room": "utility room|boiler room|technical room|plant room",
"pool": "pool|swimming pool|pool house",
"media_room": "media room|home cinema|home theater|home theatre|cinema room|movie room|tv room",
"game_room": "game room|games room|gaming room|recreation room|rec room|billiard room",
"music_room": "music room|music studio|piano room|practice room",
"library": "library|reading room|book room",
"bar": "bar|home bar|pub|wine room|wine cellar",
"dressing_room": "dressing room|walk-in closet|walk-in wardrobe",
"workshop": "workshop|tool room|craft room|hobby room",
"server_room": "server room|network room|rack room",
"safe_room": "safe room|panic room|shelter|storm shelter|bunker",
"prayer_room": "prayer room|chapel|meditation room",
"sauna": "sauna|steam room",
"outdoor_kitchen": "outdoor kitchen|summer kitchen|barbecue area|bbq area|grill area",
"driveway": "driveway|parking|parking space"
}
},
"icon_picker": {
"suggested": "Suggested",
"all_icons": "All icons"
},
"area-filter": {
"title": "Areas",
"no_areas": "No areas",