Skip to content
Merged
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
11 changes: 10 additions & 1 deletion .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ python3 scripts/check_translations.py

# Blueprints, sentences and packaging metadata parse (matches "Data files")
python3 scripts/check_data_files.py
python3 scripts/split_locales.py --check # drop --check to regenerate the bundles
```

Or install the hooks once and let them run on commit:
Expand All @@ -102,7 +103,15 @@ to every locale in the same PR** — `da`, `de`, `fr`, `nb`, `nl`, `nn`, `pl`, `
be asked to include the translations.

Backend strings live in `custom_components/taskmate/translations/` and
`strings.json`; card strings live under `www/locales/`.
`strings.json`; card and panel strings live in `www/locales/<lang>.json`.
Edit only those files. The browser loads smaller per-screen bundles,
`www/locales/<lang>/{cards,editor,panel}.json`, which
`scripts/split_locales.py` generates from them. Run it after changing a locale
and commit the result (the pre-commit hook and pre-commit.ci do this for you);
CI fails if the bundles are stale. Which bundle a key goes in follows from its
name (`*.editor.*` keys go to the editors, `panel.`/`wizard.`/`library.`/
`planner.`/`notify.` keys to the admin panel, everything else to the cards),
set by the rules at the top of `www/taskmate-localize.js`.

This is enforced in CI: `scripts/check_translations.py` fails the build if any
locale's key set differs from `en.json` in either catalogue. Run it locally to
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/data-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ jobs:
- name: Parse blueprints, sentences, locales and metadata
run: python3 scripts/check_data_files.py

- name: Check the split locale bundles match the sources
run: python3 scripts/split_locales.py --check

- name: Install the docs toolchain
run: python -m pip install --require-hashes -r requirements-docs.txt

Expand Down
10 changes: 10 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ repos:
pass_filenames: false
files: ^custom_components/taskmate/(translations|www/locales)/.*\.json$

# Rewrites locales/<lang>/{cards,editor,panel}.json from locales/<lang>.json.
# Stdlib-only, so pre-commit.ci runs it too and pushes the regenerated
# bundles onto a translator's PR.
- id: split-locales
name: Regenerate the split locale bundles
entry: python3 scripts/split_locales.py
language: python
pass_filenames: false
files: ^(custom_components/taskmate/www/(locales/.*\.json|taskmate-localize\.js)|scripts/split_locales\.py)$

- id: data-files
name: Blueprints, sentences and metadata parse
entry: python3 scripts/check_data_files.py
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
/**
* TaskMate activity card editor (visual card editor).
*
* Loaded on demand by taskmate-activity-card.js getConfigElement(), so a dashboard
* never downloads or parses it until someone opens the card editor.
*/

const LitElement = customElements.get("hui-masonry-view")
? Object.getPrototypeOf(customElements.get("hui-masonry-view"))
: Object.getPrototypeOf(customElements.get("hui-view"));

const html = LitElement.prototype.html;
const css = LitElement.prototype.css;

// Card Editor
class TaskMateActivityCardEditor extends LitElement {
static get properties() {
return { hass: { type: Object }, config: { type: Object } };
}

_t(key, params) {
const fn = window.__taskmate_localize;
return fn ? fn(this.hass, key, params) : key;
}

static get styles() {
return css`
:host { display: block; }
ha-form { display: block; margin-bottom: 16px; }
.colour-field { display: flex; flex-direction: column; gap: 8px; padding: 12px 16px; border: 1px solid var(--outline-color, var(--divider-color, #e0e0e0)); border-radius: 4px; background: var(--mdc-text-field-fill-color, var(--card-background-color)); }
.colour-field-label { font-size: 0.82rem; color: var(--primary-color); font-weight: 500; }
.colour-field-body { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.colour-swatch-wrapper { position: relative; width: 36px; height: 36px; border-radius: 50%; overflow: hidden; cursor: pointer; border: 2px solid var(--divider-color, #e0e0e0); flex-shrink: 0; }
.colour-swatch-wrapper input[type="color"] { position: absolute; inset: 0; opacity: 0; cursor: pointer; border: 0; padding: 0; }
.colour-swatch-preview { position: absolute; inset: 0; pointer-events: none; }
.colour-hex { font-family: var(--code-font-family, monospace); font-size: 0.85rem; color: var(--secondary-text-color); min-width: 70px; }
.colour-presets { display: flex; gap: 6px; flex-wrap: wrap; }
.preset-swatch { width: 22px; height: 22px; border-radius: 50%; cursor: pointer; border: 2px solid var(--divider-color, #e0e0e0); transition: transform 0.1s; padding: 0; }
.preset-swatch:hover { transform: scale(1.15); }
.preset-swatch.active { border-color: var(--primary-text-color); box-shadow: 0 0 0 2px var(--primary-color); }
.colour-reset { font-size: 0.78rem; color: var(--secondary-text-color); background: none; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; padding: 4px 10px; cursor: pointer; margin-inline-start: auto; }
.colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; }
`;
}

setConfig(config) { this.config = config; }

_buildSchema() {
const entity = this.config?.entity ? this.hass?.states?.[this.config.entity] : null;
const children = entity?.attributes?.children || [];
return [
{ name: 'entity', selector: { entity: { domain: 'sensor' } } },
{ name: 'title', selector: { text: {} } },
{
name: 'child_id',
selector: {
select: {
options: [
{ value: '__all__', label: this._t('common.editor.filter_by_child_all') },
...children.map((c) => ({ value: c.id, label: c.name })),
],
mode: 'dropdown',
},
},
},
{
name: 'card_design',
selector: {
select: {
options: window.__taskmate_design
? window.__taskmate_design.editorOptions(this._t.bind(this))
: [{ value: 'global', label: 'Use global default' }],
mode: 'dropdown',
},
},
},
{ name: 'max_items', selector: { number: { min: 5, max: 200, mode: 'box' } } },
{ name: 'show_filter_chips', selector: { boolean: {} } },
{ name: 'accent_stripes', selector: { boolean: {} } },
{ name: 'show_relative_time', selector: { boolean: {} } },
{ name: 'show_undo', selector: { boolean: {} } },
];
}

_computeLabel = (entry) => {
const labels = {
entity: this._t('common.editor.overview_entity'),
title: this._t('common.editor.card_title'),
card_design: this._t('common.design.field_label'),
child_id: this._t('common.editor.filter_by_child'),
max_items: this._t('activity.editor.max_items'),
show_filter_chips: this._t('activity.editor.show_filter_chips'),
accent_stripes: this._t('activity.editor.accent_stripes'),
show_relative_time: this._t('activity.editor.show_relative_time'),
show_undo: this._t('activity.editor.show_undo'),
};
return labels[entry.name] ?? entry.name;
};

_computeHelper = (entry) => {
const helpers = {
entity: this._t('common.editor.overview_entity_helper'),
child_id: this._t('activity.editor.filter_child_helper'),
max_items: this._t('activity.editor.max_items_helper'),
show_filter_chips: this._t('activity.editor.show_filter_chips_helper'),
accent_stripes: this._t('activity.editor.accent_stripes_helper'),
show_relative_time: this._t('activity.editor.show_relative_time_helper'),
show_undo: this._t('activity.editor.show_undo_helper'),
};
return helpers[entry.name] ?? '';
};

render() {
if (!this.hass || !this.config) return html``;
const data = {
entity: this.config.entity || '',
title: this.config.title || '',
card_design: this.config.card_design || 'global',
child_id: this.config.child_id || '__all__',
max_items: this.config.max_items || 30,
show_filter_chips: this.config.show_filter_chips !== false,
accent_stripes: this.config.accent_stripes !== false,
show_relative_time: this.config.show_relative_time !== false,
show_undo: this.config.show_undo !== false,
};
return html`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${this._buildSchema()}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
@value-changed=${this._formChanged}
></ha-form>
${this._renderColourPicker('header_color', '#2471a3')}
`;
}

_renderColourPicker(key, defaultValue) {
const d = window.__taskmate_design;
const current = this.config[key] || defaultValue;
if (!d || !d.colourPicker) return html``;
return d.colourPicker({
defaultValue, current,
label: this._t('common.editor.header_colour'),
helper: this._t('common.editor.header_colour_helper'),
resetLabel: this._t('common.reset'),
onInput: (v) => this._updateConfig(key, v),
onPreset: (v) => this._updateConfig(key, v),
onReset: () => this._updateConfig(key, defaultValue),
});
}

_formChanged(e) {
const newValues = e.detail.value || {};
const newConfig = { ...this.config };
// Booleans whose default is `true` — drop them from config when true to keep YAML minimal.
const trueDefaultBooleans = new Set(['show_filter_chips', 'accent_stripes', 'show_relative_time']);
for (const [key, value] of Object.entries(newValues)) {
if (
value === '' || value === null || value === undefined
|| (key === 'child_id' && value === '__all__')
) {
delete newConfig[key];
} else if (key === 'card_design' && value === 'global') {
delete newConfig[key];
} else if (key === 'max_items' && value === 30) {
delete newConfig[key];
} else if (trueDefaultBooleans.has(key) && value === true) {
delete newConfig[key];
} else {
newConfig[key] = value;
}
}
this.dispatchEvent(new CustomEvent('config-changed', {
detail: { config: newConfig }, bubbles: true, composed: true,
}));
}

_updateConfig(key, value) {
const newConfig = { ...this.config, [key]: value };
if (value === null || value === "" || value === undefined) delete newConfig[key];
this.dispatchEvent(new CustomEvent("config-changed", {
detail: { config: newConfig }, bubbles: true, composed: true,
}));
}
}

if (!customElements.get("taskmate-activity-card-editor")) {
customElements.define("taskmate-activity-card-editor", TaskMateActivityCardEditor);
}
Loading
Loading