Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions src/editor/StrategyEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
CustomView,
CustomCard,
CustomBadge,
CustomSection,
RoomEntities,
SectionKey,
} from '../types/strategy';
Expand Down Expand Up @@ -1026,6 +1027,7 @@ class Simon42DashboardStrategyEditor extends LitElement {

${this._renderSectionOrderPanel()}
${this._renderCustomCardsSection()}
${this._renderCustomSectionsSection()}
${this._renderCustomBadgesSection()}
${this._renderCustomViewsSection()}
</div>
Expand Down Expand Up @@ -1611,6 +1613,61 @@ class Simon42DashboardStrategyEditor extends LitElement {
`;
}

private _renderCustomSectionsSection(): TemplateResult {
const customSections = this._config.custom_sections || [];
return html`
<div class="section">
<div class="section-title">${localize('editor.section_custom_sections')}</div>
<div class="description" style="margin-bottom: 8px;">${localize('editor.custom_sections_desc')}</div>

<div id="custom-sections-list">
${customSections.length === 0
? html`<div class="empty-state">${localize('editor.no_custom_sections')}</div>`
: customSections.map((s, index) => this._renderCustomSectionItem(s, index))}
</div>

<button class="btn-primary" style="margin-top: 8px;" @click=${this._addCustomSection}>
${localize('editor.add_custom_section')}
</button>
<div class="description">${localize('editor.custom_sections_help')}</div>
</div>
`;
}

private _renderCustomSectionItem(section: CustomSection, index: number): TemplateResult {
const validationMsg = section._yaml_error
? html`<span style="color: var(--error-color);">&#x274C; ${section._yaml_error}</span>`
: section.parsed_config
? html`<span style="color: var(--success-color, green);">&#x2705; ${localize('editor.yaml_valid')}</span>`
: html``;

return html`
<div class="custom-item">
<div class="custom-item-header">
<span class="custom-item-index">#${index + 1}</span>
<button class="btn-icon" @click=${() => this._removeCustomSection(index)}
title=${localize('editor.remove')}>&#x274C;</button>
</div>
<div class="custom-item-fields">
<input type="text" .value=${section.key || ''}
placeholder=${localize('editor.custom_section_key_placeholder')}
@change=${(e: Event) => this._updateCustomSectionField(index, 'key', (e.target as HTMLInputElement).value)} />
<input type="text" .value=${section.heading || ''}
placeholder=${localize('editor.custom_section_heading_placeholder')}
@change=${(e: Event) => this._updateCustomSectionField(index, 'heading', (e.target as HTMLInputElement).value)} />
<input type="text" .value=${section.icon || ''}
placeholder="mdi:card-bulleted"
@change=${(e: Event) => this._updateCustomSectionField(index, 'icon', (e.target as HTMLInputElement).value)} />
<textarea rows="6" placeholder=${localize('editor.custom_section_yaml_placeholder')}
.value=${section.yaml || ''}
style="width: 100%;"
@change=${(e: Event) => this._updateCustomSectionYaml(index, (e.target as HTMLTextAreaElement).value)}></textarea>
<div class="custom-item-validation">${validationMsg}</div>
</div>
</div>
`;
}

private _renderCustomBadgesSection(): TemplateResult {
const customBadges = this._config.custom_badges || [];

Expand Down Expand Up @@ -2458,6 +2515,66 @@ class Simon42DashboardStrategyEditor extends LitElement {
this._fireConfigChanged(newConfig);
}

// -- Custom Sections --------------------------------------------------

private _addCustomSection(): void {
const sections: CustomSection[] = [...(this._config.custom_sections || [])];
sections.push({ key: '', heading: '', yaml: '', parsed_config: undefined });
const newConfig: Simon42StrategyConfig = { ...this._config, custom_sections: sections };
this._config = newConfig;
this._fireConfigChanged(newConfig);
}

private _removeCustomSection(index: number): void {
const sections: CustomSection[] = [...(this._config.custom_sections || [])];
sections.splice(index, 1);
const newConfig: Simon42StrategyConfig = { ...this._config };
if (sections.length === 0) delete newConfig.custom_sections;
else newConfig.custom_sections = sections;
this._config = newConfig;
this._fireConfigChanged(newConfig);
}

private _updateCustomSectionField(index: number, field: 'key' | 'heading' | 'icon', value: string): void {
const sections: CustomSection[] = [...(this._config.custom_sections || [])];
if (!sections[index]) return;
sections[index] = { ...sections[index], [field]: value };
const newConfig: Simon42StrategyConfig = { ...this._config, custom_sections: sections };
this._config = newConfig;
this._fireConfigChanged(newConfig);
}

private _updateCustomSectionYaml(index: number, yamlString: string): void {
const sections: CustomSection[] = [...(this._config.custom_sections || [])];
if (!sections[index]) return;
const updated: CustomSection = { ...sections[index], yaml: yamlString };
delete updated._yaml_error;
if (yamlString.trim()) {
try {
const parsed = yaml.load(yamlString);
if (Array.isArray(parsed)) {
updated.parsed_config = parsed as Record<string, any>[];
} else if (parsed && typeof parsed === 'object') {
// single card → wrap into array for caller convenience
updated.parsed_config = [parsed as Record<string, any>];
} else {
updated._yaml_error = 'YAML must produce a card or list of cards';
updated.parsed_config = undefined;
}
} catch (e: unknown) {
const message = e instanceof Error ? e.message.split('\n')[0] : 'Invalid YAML';
updated._yaml_error = message || 'Invalid YAML';
updated.parsed_config = undefined;
}
} else {
updated.parsed_config = undefined;
}
sections[index] = updated;
const newConfig: Simon42StrategyConfig = { ...this._config, custom_sections: sections };
this._config = newConfig;
this._fireConfigChanged(newConfig);
}

// -- Custom Badges ----------------------------------------------------

private _addCustomBadge(): void {
Expand Down
9 changes: 9 additions & 0 deletions src/translations/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,15 @@
"add_custom_card": "+ Neue Karte hinzufügen",
"video_tutorial": "Video-Anleitung ansehen",
"custom_cards_help": "Füge eigene Karten zur Übersicht hinzu. Die Karten erscheinen in einer eigenen Section zwischen Zusammenfassung und Bereichen. Tipp: Erstelle die Karte zuerst in einem normalen Dashboard, kopiere den YAML-Code und füge ihn hier ein.",
"section_custom_sections": "Eigene Sections",
"custom_sections_desc": "Eigene Section-Blöcke (jeder mit Überschrift und Lovelace-Karten-Liste). Der Section-Key kann in der Sortierung verwendet und als Zielsection für eigene Karten ausgewählt werden.",
"custom_sections_help": "Power-User-Escape — neue Sections hinzufügen ohne Fork. Eindeutigen Key vergeben, Überschrift setzen, YAML-Liste mit Karten einfügen.",
"no_custom_sections": "Keine eigenen Sections hinzugefügt",
"add_custom_section": "+ Neue Section hinzufügen",
"custom_section_key_placeholder": "key (z. B. morgenroutine)",
"custom_section_heading_placeholder": "Überschrift",
"custom_section_yaml_placeholder": "- type: markdown\n content: \"Guten Morgen!\"",
"yaml_valid": "gültig",
"section_custom_badges": "Eigene Badges",
"add_custom_badge": "+ Neues Badge hinzufügen",
"custom_badges_help": "Füge eigene Badges zum Header der Übersicht hinzu (neben den Personen-Chips).",
Expand Down
9 changes: 9 additions & 0 deletions src/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,15 @@
"add_custom_card": "+ Add new card",
"video_tutorial": "Watch video tutorial",
"custom_cards_help": "Add custom cards to the overview. Cards appear in a separate section between summaries and areas. Tip: Create the card in a regular dashboard first, copy the YAML code, and paste it here.",
"section_custom_sections": "Custom Sections",
"custom_sections_desc": "Declare your own section blocks (each with a heading and a list of Lovelace cards). The section key can be referenced in Section Order and used as a Show in target for custom cards.",
"custom_sections_help": "Power-user escape hatch — add new sections without forking the strategy. Use a unique key, set the heading, paste a YAML list of cards.",
"no_custom_sections": "No custom sections added",
"add_custom_section": "+ Add new section",
"custom_section_key_placeholder": "key (e.g. morning_routine)",
"custom_section_heading_placeholder": "Heading",
"custom_section_yaml_placeholder": "- type: markdown\n content: \"Good morning!\"",
"yaml_valid": "valid",
"section_custom_badges": "Custom Badges",
"add_custom_badge": "+ Add new badge",
"custom_badges_help": "Add custom badges to the overview header (next to person chips).",
Expand Down
35 changes: 33 additions & 2 deletions src/types/strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ export interface Simon42StrategyConfig {
custom_cards_heading?: string;
custom_cards_icon?: string;

// Custom sections — user-declared section blocks with their own heading
// and card list. Each entry's `key` becomes a valid sections_order entry
// and a valid custom_cards.target_section value. Auto-hides when empty.
custom_sections?: CustomSection[];

// Custom badges (shown in header next to person chips)
custom_badges?: CustomBadge[];
}
Expand Down Expand Up @@ -134,8 +139,9 @@ export interface CustomBadge {
export interface CustomCard {
/** Optional title shown as heading above the card */
title?: string;
/** Target section where this card appears (default: 'custom_cards') */
target_section?: SectionKey;
/** Target section where this card appears (default: 'custom_cards').
* Accepts built-in SectionKeys OR a user-defined custom_sections[].key. */
target_section?: SectionKey | string;
/** Raw YAML string entered by the user in the editor */
yaml?: string;
/** Parsed Lovelace card config (generated from yaml) */
Expand All @@ -144,6 +150,31 @@ export interface CustomCard {
_yaml_error?: string;
}

// -- Custom Sections --------------------------------------------------
// User-declared sections that render alongside built-ins. Lighter-weight
// extension hook than full plugin/extension API — users get their own
// heading + card list in YAML without forking. The section can be
// positioned via `sections_order` (its `key` is a valid order entry),
// custom_cards can target it via `target_section`, and it auto-hides
// when `cards` is empty.

export interface CustomSection {
/** Required unique key (must not collide with a built-in SectionKey).
* Used as the entry in sections_order and as custom_cards.target_section. */
key: string;
/** Heading text shown at the top of the section */
heading?: string;
/** Optional MDI icon for the heading */
icon?: string;
/** Raw YAML string entered by the user in the editor — a Lovelace card
* config array, e.g. `- type: markdown\n content: ...` */
yaml?: string;
/** Parsed array of Lovelace card configs (derived from yaml) */
parsed_config?: Record<string, any>[] | null;
/** YAML parse error message, if any */
_yaml_error?: string;
}

// -- Room Entities (entity collections per area) ----------------------

export interface RoomEntities {
Expand Down
82 changes: 71 additions & 11 deletions src/views/OverviewViewStrategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
// ====================================================================

import type { HomeAssistant } from '../types/homeassistant';
import type { Simon42StrategyConfig, SectionKey, CustomCard } from '../types/strategy';
import type { Simon42StrategyConfig, CustomCard, CustomSection } from '../types/strategy';
import { DEFAULT_SECTIONS_ORDER } from '../types/strategy';
import type { LovelaceViewConfig, LovelaceSectionConfig, LovelaceBadgeConfig, LovelaceCardConfig } from '../types/lovelace';
import { Registry } from '../Registry';
Expand All @@ -20,26 +20,66 @@ import { createWeatherSection, createEnergySection } from '../sections/WeatherEn
import { createOverviewView } from '../utils/view-builder';
import { timeStart, timeEnd, debugLog } from '../utils/debug';

/** Built-in section keys (collision check for custom_sections). */
const BUILTIN_SECTION_KEYS = new Set<string>(['overview', 'custom_cards', 'areas', 'weather', 'energy']);

/**
* Normalizes a sections_order array: removes invalid/duplicate keys,
* appends any missing keys at the end (forward compatibility).
*
* Accepts user-defined custom_section keys alongside built-in SectionKeys.
* Unknown keys (typos, removed sections) are dropped silently.
*/
function normalizeSectionsOrder(order: SectionKey[]): SectionKey[] {
const validKeys = new Set<SectionKey>(['overview', 'custom_cards', 'areas', 'weather', 'energy']);
const seen = new Set<SectionKey>();
const result: SectionKey[] = [];
function normalizeSectionsOrder(order: string[], customSectionKeys: string[]): string[] {
const validKeys = new Set<string>([...BUILTIN_SECTION_KEYS, ...customSectionKeys]);
const seen = new Set<string>();
const result: string[] = [];
for (const key of order) {
if (validKeys.has(key) && !seen.has(key)) {
result.push(key);
seen.add(key);
}
}
// Append any missing built-ins in their default order
for (const key of DEFAULT_SECTIONS_ORDER) {
if (!seen.has(key)) result.push(key);
}
// Append any custom sections the user didn't explicitly position
for (const key of customSectionKeys) {
if (!seen.has(key)) result.push(key);
}
return result;
}

/**
* Build a LovelaceSectionConfig from a user-declared CustomSection.
* Returns null when the section has no cards (auto-hide).
*
* Defensive: only accepts an array of card configs (the editor parses YAML
* to that shape). Malformed entries are dropped.
*/
function buildCustomSection(section: CustomSection): LovelaceSectionConfig | null {
if (!Array.isArray(section.parsed_config) || section.parsed_config.length === 0) return null;
// parsed_config comes from YAML.load → always Record<string, any>; we only
// need to verify each entry actually has a string `type` field, which is
// what every Lovelace card config requires.
const validCards = section.parsed_config.filter(
(c): c is LovelaceCardConfig => typeof (c as { type?: unknown }).type === 'string'
);
if (validCards.length === 0) return null;
const cards: LovelaceCardConfig[] = [];
if (section.heading) {
cards.push({
type: 'heading',
heading: section.heading,
heading_style: 'title',
...(section.icon ? { icon: section.icon } : {}),
});
}
cards.push(...validCards);
return { type: 'grid', cards };
}

/**
* Renders custom cards into an array of LovelaceCardConfigs (without section wrapper).
* Used to append assigned custom cards to existing sections.
Expand Down Expand Up @@ -85,17 +125,31 @@ class Simon42ViewOverviewStrategy extends HTMLElement {
const showSearchCard = dashboardConfig.show_search_card === true;
const groupByFloors = dashboardConfig.group_by_floors === true;

// Group custom cards by target section
// Group custom cards by target section (built-in OR user-defined custom_sections key)
const allCustomCards = dashboardConfig.custom_cards || [];
const customCardsBySection = new Map<SectionKey, CustomCard[]>();
const customCardsBySection = new Map<string, CustomCard[]>();
for (const card of allCustomCards) {
const target = card.target_section || 'custom_cards';
const list = customCardsBySection.get(target) || [];
list.push(card);
customCardsBySection.set(target, list);
}

// Build sections
// Process custom_sections: validate keys (no collision with built-ins, no duplicates)
// and pre-build their LovelaceSectionConfig. Invalid entries are dropped silently.
const rawCustomSections = dashboardConfig.custom_sections || [];
const seenCustomKeys = new Set<string>();
const customSections: { key: string; section: LovelaceSectionConfig | null }[] = [];
for (const cs of rawCustomSections) {
if (!cs.key || typeof cs.key !== 'string') continue;
if (BUILTIN_SECTION_KEYS.has(cs.key)) continue; // can't shadow built-ins
if (seenCustomKeys.has(cs.key)) continue; // first wins on duplicates
seenCustomKeys.add(cs.key);
customSections.push({ key: cs.key, section: buildCustomSection(cs) });
}
const customSectionKeys = customSections.map((s) => s.key);

// Build built-in sections
const overviewSection = createOverviewSection({ someSensorId, showSearchCard, config: dashboardConfig, hass });
const customCardsSection = createCustomCardsSection(
customCardsBySection.get('custom_cards') || [],
Expand All @@ -104,17 +158,23 @@ class Simon42ViewOverviewStrategy extends HTMLElement {
);
const areasSections = createAreasSection(visibleAreas, groupByFloors, hass);

// Section map: key → section(s) or null
const sectionMap = new Map<SectionKey, LovelaceSectionConfig | LovelaceSectionConfig[] | null>([
// Section map: key → section(s) or null. Keyed by string so custom keys fit alongside built-ins.
const sectionMap = new Map<string, LovelaceSectionConfig | LovelaceSectionConfig[] | null>([
['overview', overviewSection],
['custom_cards', customCardsSection],
['areas', areasSections],
['weather', createWeatherSection(weatherEntity ?? null, showWeather)],
['energy', createEnergySection(showEnergy, dashboardConfig.energy_link_dashboard !== false)],
]);
for (const { key, section } of customSections) {
sectionMap.set(key, section);
}

// Assemble in configured order, appending assigned custom cards to each section
const sectionsOrder = normalizeSectionsOrder(dashboardConfig.sections_order ?? DEFAULT_SECTIONS_ORDER);
const sectionsOrder = normalizeSectionsOrder(
(dashboardConfig.sections_order as string[] | undefined) ?? DEFAULT_SECTIONS_ORDER,
customSectionKeys,
);
const overviewSections: LovelaceSectionConfig[] = [];
for (const key of sectionsOrder) {
const result = sectionMap.get(key);
Expand Down