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
6 changes: 6 additions & 0 deletions .github/workflows/validate-custom.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
name: Validate Custom

on:
workflow_dispatch:
push:
branches: [main]
pull_request:
Expand Down Expand Up @@ -56,6 +57,11 @@ jobs:
pip install flake8 --quiet
flake8 custom_components/life_events/ --max-line-length=120 --ignore=E501,W503

- name: Run tests
run: |
pip install pytest pytest-homeassistant-custom-component --quiet
python -m pytest

- name: Check brand icon exists
run: |
if [ ! -f "custom_components/life_events/brands/icon.png" ]; then
Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Track birthdays, wedding anniversaries, and custom recurring dates — with coun
- **Lovelace card** — polished card with urgency colouring, type badges, age display
- **Notification blueprint** — one-click automation for day-of and advance notifications
- **UI-only config** — add/edit/remove events from the HA UI, no YAML editing
- **CSV import** — add many events at once with validation and duplicate handling
- **Event types** — Birthday, Anniversary, Custom (with your own label)
- **Year-optional** — track day/month only when the birth year isn't known

Expand Down Expand Up @@ -68,6 +69,28 @@ Once you've added all the events you want, in the Configure window, scroll down

---

### Importing events from CSV

Place a CSV file in the Home Assistant configuration directory, then choose
**📥 Import events from CSV** from the integration's Configure menu. Paths are
restricted to that directory and files are limited to 1 MB.

The required columns are `name`, `date`, and `type`. Optional columns are
`year_unknown`, `custom_label`, and `icon`:

```csv
name,date,type,year_unknown,custom_label,icon
Sarah,1990-03-15,birthday,false,,
Mum & Dad,06-12,anniversary,true,,mdi:ring
Adoption Day,2018-09-04,custom,false,Gotcha Day,mdi:paw
```

Dates may use `YYYY-MM-DD`, or `MM-DD` when the year is unknown. When merging,
rows with the same normalised name, date, and type as an existing event are
skipped.

---

### Deleting an event

Go to **Settings → Devices & Services → Life Events → Configure** and select your event from the dropdown and click Submit.
Expand Down
52 changes: 52 additions & 0 deletions custom_components/life_events/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
from homeassistant import config_entries
from homeassistant.core import callback

from .csv_import import merge_events, read_import_csv, resolve_import_path

from .const import (
DOMAIN,
CONF_EVENTS,
Expand All @@ -28,8 +30,12 @@

# Sentinel for "add new event" selection in the options menu
_ADD_EVENT = "__add__"
_IMPORT_EVENTS = "__import__"
_DONE = "__done__"

_CONF_IMPORT_PATH = "file_path"
_CONF_REPLACE_EXISTING = "replace_existing"


class LifeEventsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle the initial config flow (one-time setup)."""
Expand Down Expand Up @@ -93,6 +99,8 @@ async def async_step_init(
if selection == _ADD_EVENT:
self._editing_index = None
return await self.async_step_event_form()
if selection == _IMPORT_EVENTS:
return await self.async_step_import_csv()
if selection == _DONE:
return self.async_create_entry(
title="", data={CONF_EVENTS: self._events}
Expand All @@ -109,6 +117,7 @@ async def async_step_init(
for i, ev in enumerate(self._events)
}
event_options[_ADD_EVENT] = "➕ Add new event"
event_options[_IMPORT_EVENTS] = "📥 Import events from CSV"
event_options[_DONE] = "✅ Save and finish"

# After adding/editing, pre-select "Save and finish".
Expand All @@ -128,6 +137,49 @@ async def async_step_init(
},
)

async def async_step_import_csv(
self, user_input: dict[str, Any] | None = None
) -> config_entries.ConfigFlowResult:
"""Import events from a CSV file in the Home Assistant config directory."""
errors: dict[str, str] = {}

if user_input is not None:
import_path = resolve_import_path(
self.hass.config.config_dir, user_input[_CONF_IMPORT_PATH]
)
if import_path is None:
errors["base"] = "invalid_import_path"
else:
try:
imported = await self.hass.async_add_executor_job(
read_import_csv, import_path
)
except (OSError, UnicodeError, ValueError) as err:
_LOGGER.warning("Unable to import Life Events CSV: %s", err)
errors["base"] = "invalid_csv"
else:
existing = (
[]
if user_input.get(_CONF_REPLACE_EXISTING, False)
else self._events
)
merged = merge_events(existing, imported)
return self.async_create_entry(
title="", data={CONF_EVENTS: merged}
)

schema = vol.Schema(
{
vol.Required(
_CONF_IMPORT_PATH, default="life_events.csv"
): str,
vol.Optional(_CONF_REPLACE_EXISTING, default=False): bool,
}
)
return self.async_show_form(
step_id="import_csv", data_schema=schema, errors=errors
)

# ── Add / Edit form ────────────────────────────────────────────────────

async def async_step_event_form(
Expand Down
132 changes: 132 additions & 0 deletions custom_components/life_events/csv_import.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"""CSV import helpers for Life Events."""

from __future__ import annotations

import csv
import re
import uuid
from datetime import date
from pathlib import Path

from .const import (
CONF_EVENT_CUSTOM_LABEL,
CONF_EVENT_DATE,
CONF_EVENT_ICON,
CONF_EVENT_NAME,
CONF_EVENT_TYPE,
CONF_EVENT_YEAR_UNKNOWN,
EVENT_TYPES,
)

MAX_IMPORT_BYTES = 1_000_000


def resolve_import_path(config_dir: str, value: str) -> Path | None:
"""Resolve an import path and restrict it to the HA config directory."""
root = Path(config_dir).resolve()
candidate = Path(value.strip())
if not candidate.is_absolute():
candidate = root / candidate
candidate = candidate.resolve()
try:
candidate.relative_to(root)
except ValueError:
return None
return candidate


def _normalise_date(date_str: str, year_unknown: bool) -> str | None:
"""Return a validated date in YYYY-MM-DD or MM-DD format."""
date_str = date_str.strip()
pattern = (
r"(\d{1,2})-(\d{1,2})"
if year_unknown
else r"(\d{4})-(\d{1,2})-(\d{1,2})"
)
match = re.fullmatch(pattern, date_str)
if match is None:
return None

values = [int(value) for value in match.groups()]
try:
if year_unknown:
month, day = values
date(2000, month, day)
return f"{month:02d}-{day:02d}"
year, month, day = values
date(year, month, day)
return f"{year}-{month:02d}-{day:02d}"
except ValueError:
return None


def read_import_csv(path: Path) -> list[dict]:
"""Read and validate an import CSV without changing integration state."""
if path.stat().st_size > MAX_IMPORT_BYTES:
raise ValueError("CSV exceeds the 1 MB import limit")

events: list[dict] = []
with path.open("r", encoding="utf-8-sig", newline="") as csv_file:
reader = csv.DictReader(csv_file)
required = {CONF_EVENT_NAME, CONF_EVENT_DATE, CONF_EVENT_TYPE}
if reader.fieldnames is None or not required.issubset(reader.fieldnames):
raise ValueError("CSV must contain name, date, and type columns")

for line_number, row in enumerate(reader, start=2):
name = (row.get(CONF_EVENT_NAME) or "").strip()
event_type = (row.get(CONF_EVENT_TYPE) or "").strip().lower()
raw_date = (row.get(CONF_EVENT_DATE) or "").strip()
raw_year_unknown = (
row.get(CONF_EVENT_YEAR_UNKNOWN) or ""
).strip().lower()
year_unknown = raw_year_unknown in {"1", "true", "yes", "on"}
if len(raw_date) <= 5:
year_unknown = True
normalised_date = _normalise_date(raw_date, year_unknown)

if not name:
raise ValueError(f"line {line_number}: name is required")
if event_type not in EVENT_TYPES:
raise ValueError(f"line {line_number}: invalid event type")
if normalised_date is None:
raise ValueError(f"line {line_number}: invalid date")

events.append(
{
"_id": str(uuid.uuid4())[:8],
CONF_EVENT_NAME: name,
CONF_EVENT_DATE: normalised_date,
CONF_EVENT_TYPE: event_type,
CONF_EVENT_CUSTOM_LABEL: (
row.get(CONF_EVENT_CUSTOM_LABEL) or ""
).strip(),
CONF_EVENT_ICON: (row.get(CONF_EVENT_ICON) or "").strip(),
CONF_EVENT_YEAR_UNKNOWN: year_unknown,
}
)

if not events:
raise ValueError("CSV contains no events")
return events


def _event_key(event: dict) -> tuple[str, str, str]:
"""Return the stable identity used for import deduplication."""
name = re.sub(r"[^\w]", "", event.get(CONF_EVENT_NAME, "").casefold())
return (
name,
event.get(CONF_EVENT_DATE, ""),
event.get(CONF_EVENT_TYPE, ""),
)


def merge_events(existing: list[dict], imported: list[dict]) -> list[dict]:
"""Merge imported records while preserving existing duplicates."""
merged = list(existing)
keys = {_event_key(event) for event in merged}
for event in imported:
key = _event_key(event)
if key not in keys:
merged.append(event)
keys.add(key)
return merged
12 changes: 11 additions & 1 deletion custom_components/life_events/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,20 @@
"year_unknown": "Year unknown (use MM-DD format and hide age/years)",
"delete_event": "🗑️ Delete this event"
}
},
"import_csv": {
"title": "Import Life Events from CSV",
"description": "Place a CSV file in the Home Assistant config directory. Required columns: name, date, type. Optional columns: year_unknown, custom_label, icon. Existing matching events are skipped unless replacement is selected.",
"data": {
"file_path": "CSV filename (relative to /config)",
"replace_existing": "Replace all existing Life Events"
}
}
},
"error": {
"invalid_date": "Invalid date. Use YYYY-MM-DD (e.g. 1990-03-15) or MM-DD (e.g. 03-15) if year is unknown."
"invalid_date": "Invalid date. Use YYYY-MM-DD (e.g. 1990-03-15) or MM-DD (e.g. 03-15) if year is unknown.",
"invalid_import_path": "The CSV must be inside the Home Assistant config directory.",
"invalid_csv": "The CSV could not be imported. Check the Home Assistant log for the failing row."
}
}
}
12 changes: 11 additions & 1 deletion custom_components/life_events/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,20 @@
"year_unknown": "Year unknown (use MM-DD format and hide age/years)",
"delete_event": "🗑️ Delete this event"
}
},
"import_csv": {
"title": "Import Life Events from CSV",
"description": "Place a CSV file in the Home Assistant config directory. Required columns: name, date, type. Optional columns: year_unknown, custom_label, icon. Existing matching events are skipped unless replacement is selected.",
"data": {
"file_path": "CSV filename (relative to /config)",
"replace_existing": "Replace all existing Life Events"
}
}
},
"error": {
"invalid_date": "Invalid date. Use YYYY-MM-DD (e.g. 1990-03-15) or MM-DD (e.g. 03-15) if year is unknown."
"invalid_date": "Invalid date. Use YYYY-MM-DD (e.g. 1990-03-15) or MM-DD (e.g. 03-15) if year is unknown.",
"invalid_import_path": "The CSV must be inside the Home Assistant config directory.",
"invalid_csv": "The CSV could not be imported. Check the Home Assistant log for the failing row."
}
}
}
72 changes: 72 additions & 0 deletions tests/test_csv_import.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Tests for Life Events CSV import helpers."""

from pathlib import Path

import pytest

from custom_components.life_events.csv_import import (
merge_events,
read_import_csv,
resolve_import_path,
)


def test_resolve_import_path_restricts_files_to_config(tmp_path: Path) -> None:
"""Relative paths stay in config and parent traversal is rejected."""
assert resolve_import_path(str(tmp_path), "events.csv") == tmp_path / "events.csv"
assert resolve_import_path(str(tmp_path), "../events.csv") is None


def test_read_import_csv_normalises_values(tmp_path: Path) -> None:
"""CSV rows are stripped, normalised, and assigned event IDs."""
import_file = tmp_path / "events.csv"
import_file.write_text(
"name,date,type,year_unknown,custom_label,icon\n"
" Sarah ,1990-3-5,BIRTHDAY,false,,\n"
"Adoption Day,9-4,custom,,Gotcha Day,mdi:paw\n",
encoding="utf-8",
)

events = read_import_csv(import_file)

assert events[0] | {"_id": "ignored"} == {
"_id": "ignored",
"name": "Sarah",
"date": "1990-03-05",
"type": "birthday",
"custom_label": "",
"icon": "",
"year_unknown": False,
}
assert events[1]["date"] == "09-04"
assert events[1]["year_unknown"] is True
assert len(events[0]["_id"]) == 8


@pytest.mark.parametrize(
"contents",
[
"name,date\nSarah,1990-03-05\n",
"name,date,type\n,1990-03-05,birthday\n",
"name,date,type\nSarah,1990-03-05,holiday\n",
"name,date,type\nSarah,2025-02-30,birthday\n",
],
)
def test_read_import_csv_rejects_invalid_rows(
tmp_path: Path, contents: str
) -> None:
"""Invalid headers and row values fail the whole import."""
import_file = tmp_path / "events.csv"
import_file.write_text(contents, encoding="utf-8")

with pytest.raises(ValueError):
read_import_csv(import_file)


def test_merge_events_skips_normalised_duplicates() -> None:
"""Deduplication ignores name punctuation and case."""
existing = [{"name": "Mum & Dad", "date": "06-12", "type": "anniversary"}]
duplicate = [{"name": "mum dad", "date": "06-12", "type": "anniversary"}]
new_event = [{"name": "Sarah", "date": "03-15", "type": "birthday"}]

assert merge_events(existing, duplicate + new_event) == existing + new_event