feat: Implement basic general folder support (#2051)

- Related: #1748
This commit is contained in:
Dermot Duffy
2025-05-21 19:59:21 -07:00
committed by GitHub
parent 2eb0d9e35e
commit c6a4c8aea2
350 changed files with 12837 additions and 4509 deletions
+230 -89
View File
@@ -3,6 +3,7 @@ import { customElement, property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import './components/icon.js';
import './components/key-assigner.js';
import { renderMessage } from './components/message.js';
import {
copyConfig,
deleteConfigValue,
@@ -90,6 +91,12 @@ import {
CONF_DIMENSIONS_ASPECT_RATIO,
CONF_DIMENSIONS_ASPECT_RATIO_MODE,
CONF_DIMENSIONS_HEIGHT,
CONF_FOLDERS,
CONF_FOLDERS_ARRAY_HA_URL,
CONF_FOLDERS_ARRAY_ICON,
CONF_FOLDERS_ARRAY_ID,
CONF_FOLDERS_ARRAY_TITLE,
CONF_FOLDERS_ARRAY_TYPE,
CONF_IMAGE_ENTITY,
CONF_IMAGE_ENTITY_PARAMETERS,
CONF_IMAGE_MODE,
@@ -235,19 +242,18 @@ import {
CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA,
CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS,
CONF_VIEW_TRIGGERS_UNTRIGGER_SECONDS,
FOLDERS_CONFIGURATION_URL,
MEDIA_CHUNK_SIZE_MAX,
} from './const.js';
import { fireHASSEvent } from './ha/fire-hass-event.js';
import { getEntitiesFromHASS } from './ha/get-entities.js';
import { getEntityTitle } from './ha/get-entity-title.js';
import { sideLoadHomeAssistantElements } from './ha/side-load-ha-elements.js';
import { HomeAssistant, LovelaceCardEditor } from './ha/types.js';
import { localize } from './localize/localize.js';
import editorStyle from './scss/editor.scss';
import { arrayMove, prettifyTitle } from './utils/basic.js';
import { getCameraID } from './utils/camera.js';
import {
getEntitiesFromHASS,
getEntityTitle,
sideLoadHomeAssistantElements,
} from './utils/ha';
const MENU_CAMERAS = 'cameras';
const MENU_CAMERAS_CAPABILITIES = 'cameras.capabilities';
@@ -265,6 +271,8 @@ const MENU_CAMERAS_PROXY = 'cameras.proxy';
const MENU_CAMERAS_REOLINK = 'cameras.reolink';
const MENU_CAMERAS_TRIGGERS = 'cameras.triggers';
const MENU_CAMERAS_WEBRTC_CARD = 'cameras.webrtc_card';
const MENU_FOLDERS = 'folders';
const MENU_FOLDERS_HA = 'folders.ha';
const MENU_LIVE_CONTROLS = 'live.controls';
const MENU_LIVE_CONTROLS_NEXT_PREVIOUS = 'live.controls.next_previous';
const MENU_LIVE_CONTROLS_PTZ = 'live.controls.ptz';
@@ -337,6 +345,11 @@ const options: EditorOptions = {
name: localize('editor.live'),
secondary: localize('editor.live_secondary'),
},
folders: {
icon: 'folder-multiple',
name: localize('editor.folders'),
secondary: localize('editor.folders'),
},
media_gallery: {
icon: 'grid',
name: localize('editor.media_gallery'),
@@ -398,14 +411,15 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
protected _viewModes: EditorSelectOption[] = [
{ value: '', label: '' },
{ value: 'live', label: localize('config.view.views.live') },
{ value: 'clips', label: localize('config.view.views.clips') },
{ value: 'snapshots', label: localize('config.view.views.snapshots') },
{ value: 'recordings', label: localize('config.view.views.recordings') },
{ value: 'clip', label: localize('config.view.views.clip') },
{ value: 'snapshot', label: localize('config.view.views.snapshot') },
{ value: 'recording', label: localize('config.view.views.recording') },
{ value: 'clips', label: localize('config.view.views.clips') },
{ value: 'folder', label: localize('config.view.views.folder') },
{ value: 'image', label: localize('config.view.views.image') },
{ value: 'live', label: localize('config.view.views.live') },
{ value: 'recording', label: localize('config.view.views.recording') },
{ value: 'recordings', label: localize('config.view.views.recordings') },
{ value: 'snapshot', label: localize('config.view.views.snapshot') },
{ value: 'snapshots', label: localize('config.view.views.snapshots') },
{ value: 'timeline', label: localize('config.view.views.timeline') },
];
@@ -1204,6 +1218,27 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
);
}
/**
* Get an editor title for the camera.
* @param cameraIndex The index of the camera in the cameras array.
* @param cameraConfig The raw camera configuration object.
* @returns A string title.
*/
protected _getEditorFolderTitle(
folderIndex: number,
folderConfig: RawAdvancedCameraCardConfig,
): string {
// Attempt to render a recognizable name for the camera, starting with the
// most likely to be useful and working our ways towards the least useful.
// This is only used for the editor since the card itself can use the
// cameraManager.
return (
(typeof folderConfig?.title === 'string' && folderConfig.title) ||
(typeof folderConfig?.id === 'string' && folderConfig.id) ||
localize('common.folder') + ' #' + folderIndex
);
}
protected _renderViewDefaultResetMenu(): TemplateResult {
return this._putInSubmenu(
MENU_VIEW_DEFAULT_RESET,
@@ -1898,6 +1933,173 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
`;
}
protected _modifyConfig(func: (config: RawAdvancedCameraCardConfig) => boolean): void {
if (this._config) {
const newConfig = copyConfig(this._config);
if (func(newConfig)) {
this._updateConfig(newConfig);
}
}
}
protected _renderArrayManagementControls(
configPathArray: string,
index: number,
menu: string,
add?: boolean,
): TemplateResult | void {
const array = this._config ? getConfigValue(this._config, configPathArray) : null;
return html`
<div class="controls">
<ha-icon-button
.label=${localize('editor.move_up')}
.disabled=${add || !this._config || !Array.isArray(array) || index <= 0}
@click=${() =>
!add &&
this._modifyConfig((config: RawAdvancedCameraCardConfig): boolean => {
const array = getConfigValue(config, configPathArray);
if (Array.isArray(array) && index > 0) {
arrayMove(array, index, index - 1);
this._openMenu(menu, index - 1);
return true;
}
return false;
})}
>
<advanced-camera-card-icon
.icon=${{ icon: 'mdi:arrow-up' }}
></advanced-camera-card-icon>
</ha-icon-button>
<ha-icon-button
.label=${localize('editor.move_down')}
.disabled=${add ||
!this._config ||
!Array.isArray(this._config.cameras) ||
index >= this._config.cameras.length - 1}
@click=${() =>
!add &&
this._modifyConfig((config: RawAdvancedCameraCardConfig): boolean => {
const array = getConfigValue(config, configPathArray);
if (Array.isArray(array) && index < array.length - 1) {
arrayMove(array, index, index + 1);
this._openMenu(menu, index + 1);
return true;
}
return false;
})}
>
<advanced-camera-card-icon
.icon=${{ icon: 'mdi:arrow-down' }}
></advanced-camera-card-icon>
</ha-icon-button>
<ha-icon-button
.label=${localize('editor.delete')}
.disabled=${add}
@click=${() => {
this._modifyConfig((config: RawAdvancedCameraCardConfig): boolean => {
const array = getConfigValue(config, configPathArray);
if (Array.isArray(array)) {
array.splice(index, 1);
this._closeMenu(menu);
return true;
}
return false;
});
}}
>
<advanced-camera-card-icon
.icon=${{ icon: 'mdi:delete' }}
></advanced-camera-card-icon>
</ha-icon-button>
</div>
`;
}
protected _renderFolder(
folders: RawAdvancedCameraCardConfigArray,
folderIndex: number,
addNewFolder?: boolean,
): TemplateResult | void {
const submenuClasses = {
submenu: true,
selected: this._expandedMenus[MENU_FOLDERS] === folderIndex,
};
const folderTypes: EditorSelectOption[] = [
{ value: '', label: '' },
{ value: 'ha', label: localize('config.folders.types.ha') },
];
return html` <div class="${classMap(submenuClasses)}">
<div
class="submenu-header"
@click=${this._toggleMenu}
.domain=${MENU_FOLDERS}
.key=${folderIndex}
>
<advanced-camera-card-icon
.icon=${{ icon: addNewFolder ? 'mdi:folder-plus' : 'mdi:folder' }}
></advanced-camera-card-icon>
<span>
${addNewFolder
? html` <span class="new"> [${localize('editor.add_new_folder')}...] </span>`
: html`<span
>${this._getEditorFolderTitle(
folderIndex,
folders[folderIndex] || {},
)}</span
>`}
</span>
</div>
${this._expandedMenus[MENU_FOLDERS] === folderIndex
? html` <div class="values">
${this._renderArrayManagementControls(
CONF_FOLDERS,
folderIndex,
MENU_FOLDERS,
addNewFolder,
)}
${this._renderOptionSelector(
getArrayConfigPath(CONF_FOLDERS_ARRAY_TYPE, folderIndex),
folderTypes,
)}
${this._renderStringInput(
getArrayConfigPath(CONF_FOLDERS_ARRAY_TITLE, folderIndex),
)}
${this._renderIconSelector(
getArrayConfigPath(CONF_FOLDERS_ARRAY_ICON, folderIndex),
{
label: localize('config.folders.icon'),
},
)}
${this._renderStringInput(
getArrayConfigPath(CONF_FOLDERS_ARRAY_ID, folderIndex),
)}
${this._putInSubmenu(
MENU_FOLDERS_HA,
folderIndex,
'config.folders.ha.editor_label',
'mdi:home-assistant',
html`
${this._renderStringInput(
getArrayConfigPath(CONF_FOLDERS_ARRAY_HA_URL, folderIndex),
)}
${renderMessage({
message: localize('config.folders.ha.path_info'),
icon: 'mdi:information-outline',
url: {
link: FOLDERS_CONFIGURATION_URL,
title: localize('error.configuration'),
},
})}
`,
)}
</div>`
: ''}
</div>`;
}
/**
* Render a camera section.
* @param cameras The full array of cameras.
@@ -1943,18 +2145,6 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
}
});
// Make a new config and update the editor with changes on it,
const modifyConfig = (
func: (config: RawAdvancedCameraCardConfig) => boolean,
): void => {
if (this._config) {
const newConfig = copyConfig(this._config);
if (func(newConfig)) {
this._updateConfig(newConfig);
}
}
};
const submenuClasses = {
submenu: true,
selected: this._expandedMenus[MENU_CAMERAS] === cameraIndex,
@@ -1973,7 +2163,7 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
></advanced-camera-card-icon>
<span>
${addNewCamera
? html` <span class="new-camera">
? html` <span class="new">
[${localize('editor.add_new_camera')}...]
</span>`
: html`<span
@@ -1986,71 +2176,12 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
</div>
${this._expandedMenus[MENU_CAMERAS] === cameraIndex
? html` <div class="values">
<div class="controls">
<ha-icon-button
.label=${localize('editor.move_up')}
.disabled=${addNewCamera ||
!this._config ||
!Array.isArray(this._config.cameras) ||
cameraIndex <= 0}
@click=${() =>
!addNewCamera &&
modifyConfig((config: RawAdvancedCameraCardConfig): boolean => {
if (Array.isArray(config.cameras) && cameraIndex > 0) {
arrayMove(config.cameras, cameraIndex, cameraIndex - 1);
this._openMenu(MENU_CAMERAS, cameraIndex - 1);
return true;
}
return false;
})}
>
<advanced-camera-card-icon
.icon=${{ icon: 'mdi:arrow-up' }}
></advanced-camera-card-icon>
</ha-icon-button>
<ha-icon-button
.label=${localize('editor.move_down')}
.disabled=${addNewCamera ||
!this._config ||
!Array.isArray(this._config.cameras) ||
cameraIndex >= this._config.cameras.length - 1}
@click=${() =>
!addNewCamera &&
modifyConfig((config: RawAdvancedCameraCardConfig): boolean => {
if (
Array.isArray(config.cameras) &&
cameraIndex < config.cameras.length - 1
) {
arrayMove(config.cameras, cameraIndex, cameraIndex + 1);
this._openMenu(MENU_CAMERAS, cameraIndex + 1);
return true;
}
return false;
})}
>
<advanced-camera-card-icon
.icon=${{ icon: 'mdi:arrow-down' }}
></advanced-camera-card-icon>
</ha-icon-button>
<ha-icon-button
.label=${localize('editor.delete')}
.disabled=${addNewCamera}
@click=${() => {
modifyConfig((config: RawAdvancedCameraCardConfig): boolean => {
if (Array.isArray(config.cameras)) {
config.cameras.splice(cameraIndex, 1);
this._closeMenu(MENU_CAMERAS);
return true;
}
return false;
});
}}
>
<advanced-camera-card-icon
.icon=${{ icon: 'mdi:delete' }}
></advanced-camera-card-icon>
</ha-icon-button>
</div>
${this._renderArrayManagementControls(
CONF_CAMERAS,
cameraIndex,
MENU_CAMERAS,
addNewCamera,
)}
${this._renderEntitySelector(
getArrayConfigPath(CONF_CAMERAS_ARRAY_CAMERA_ENTITY, cameraIndex),
'camera',
@@ -2542,6 +2673,8 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
const entities = getEntitiesFromHASS(this.hass);
const cameras = (getConfigValue(this._config, CONF_CAMERAS) ||
[]) as RawAdvancedCameraCardConfigArray;
const folders = (getConfigValue(this._config, CONF_FOLDERS) ||
[]) as RawAdvancedCameraCardConfigArray;
return html`
${this._configUpgradeable
@@ -2626,6 +2759,7 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
${this._renderMenuButton('clips') /* */}
${this._renderMenuButton('snapshots')}
${this._renderMenuButton('recordings')}
${this._renderMenuButton('folders')}
${this._renderMenuButton('image') /* */}
${this._renderMenuButton('download')}
${this._renderMenuButton('camera_ui')}
@@ -2849,6 +2983,13 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
</div>
`
: ''}
${this._renderOptionSetHeader('folders')}
${this._expandedMenus[MENU_OPTIONS] === 'folders'
? html` <div class="values">
${folders.map((_, index) => this._renderFolder(folders, index))}
${this._renderFolder(folders, folders.length, true)}
</div>`
: ''}
${this._renderOptionSetHeader('media_gallery')}
${this._expandedMenus[MENU_OPTIONS] === 'media_gallery'
? html` <div class="values">