diff --git a/README.md b/README.md index a393c14..ae85109 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,10 @@ Alternatively, click on the button below to add the repository: 1. Go to **Settings** -> **Devices & Services** -> **Integrations**. 2. Click **Add Integration** and search for **Feedparser**. 3. Fill in at least `Name` and `Feed URL`. -4. Optional include/exclude fields are entered as comma-separated values. +4. Set the refresh interval with the duration field (minimum 1 minute). +5. Optional include/exclude fields are entered as comma-separated values. -To update parsing behavior later, open the Feedparser integration card and use **Configure** (options flow). +To update refresh or parsing behavior later, open the Feedparser integration card and use **Configure** (options flow). Saved changes reload only that feed entry so the new settings take effect immediately. ### YAML configuration (legacy) @@ -79,7 +80,7 @@ The integration supports both UI setup (recommended) and legacy YAML setup. Most | `feed_url` | Yes | URL string | - | `https://www.nu.nl/rss/Algemeen` | RSS/Atom feed URL to fetch and parse. Supports `http`, `https`, and `file` in dev/testing. | | `date_format` | No | string (`strftime`) | `%a, %b %d %I:%M %p` | `%a, %d %b %Y %H:%M:%S %Z` | Output format for date fields in feed entries. | | `local_time` | No | boolean | `false` | `true` | Converts parsed date values from feed timezone to Home Assistant local timezone. | -| `scan_interval` | No | duration object | `1 hour` | `{ hours: 1, minutes: 30 }` | Polling interval for refreshing feed data. Minimum effective value is 1 minute. | +| `scan_interval` | No | duration object | `1 hour` | `{ hours: 1, minutes: 30 }` | Polling interval for refreshing feed data. UI input must be at least 1 minute. | | `show_topn` | No | integer | `9999` | `10` | Maximum number of entries exposed in sensor attributes. | | `remove_summary_image` | No | boolean | `false` | `true` | Strips `` tags from the `summary` field. | | `inclusions` | No | list of strings (YAML) / comma-separated string (UI) | all fields | `title, link, published, image` | If set, only listed fields are kept for each entry. | @@ -93,7 +94,7 @@ The integration supports both UI setup (recommended) and legacy YAML setup. Most - **UI vs YAML input format**: - UI uses comma-separated text for `inclusions` and `exclusions`. - YAML uses proper lists. - - UI config flow uses separate scan interval fields for hours/minutes; YAML uses `scan_interval` object keys (`hours`, `minutes`). + - UI uses a single duration control for `scan_interval`; YAML uses `scan_interval` object keys such as `hours` and `minutes`. - **Date parsing**: if a feed date is malformed, parser behavior depends on feed content and fallback parsing. Due to how `custom_components` are loaded, it is normal to see a `ModuleNotFoundError` error on first boot after adding this, to resolve it, restart Home-Assistant. diff --git a/custom_components/feedparser/config_flow.py b/custom_components/feedparser/config_flow.py index 9bcc834..c961551 100644 --- a/custom_components/feedparser/config_flow.py +++ b/custom_components/feedparser/config_flow.py @@ -13,8 +13,10 @@ ConfigFlow, FlowResult, OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_NAME +from homeassistant.helpers import selector from requests_file import FileAdapter from yarl import URL @@ -37,9 +39,6 @@ ENTRY_VERSION, ) -CONF_SCAN_INTERVAL_HOURS = "scan_interval_hours" -CONF_SCAN_INTERVAL_MINUTES = "scan_interval_minutes" - def _split_csv(value: str) -> list[str]: """Split a comma-separated string into a normalized list.""" @@ -99,12 +98,23 @@ def _scan_interval_to_dict(value: object) -> dict[str, int]: def _scan_interval_from_input(user_input: Mapping[str, object]) -> dict[str, int]: """Build normalized scan interval dict from flow input.""" - if CONF_SCAN_INTERVAL in user_input: - return _scan_interval_to_dict(user_input[CONF_SCAN_INTERVAL]) + value = user_input.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + + if isinstance(value, timedelta): + total_minutes = int(value.total_seconds() // 60) + elif isinstance(value, Mapping): + days = _to_int(value.get("days"), 0) + hours = _to_int(value.get("hours"), 0) + minutes = _to_int(value.get("minutes"), 0) + seconds = _to_int(value.get("seconds"), 0) + total_minutes = (days * 24 * 60) + (hours * 60) + minutes + (seconds // 60) + else: + total_minutes = int(DEFAULT_SCAN_INTERVAL.total_seconds() // 60) + + if total_minutes < 1: + msg = "Refresh interval must be at least 1 minute" + raise vol.Invalid(msg) - hours = _to_int(user_input.get(CONF_SCAN_INTERVAL_HOURS), 0) - minutes = _to_int(user_input.get(CONF_SCAN_INTERVAL_MINUTES), 0) - total_minutes = max(1, (hours * 60) + minutes) return { "hours": total_minutes // 60, "minutes": total_minutes % 60, @@ -132,16 +142,24 @@ def _schema_with_defaults( vol.Required(CONF_DATE_FORMAT, default=date_format): str, vol.Required(CONF_LOCAL_TIME, default=local_time): bool, vol.Required( - CONF_SCAN_INTERVAL_HOURS, - default=normalized_scan_interval["hours"], - ): vol.All(vol.Coerce(int), vol.Range(min=0)), - vol.Required( - CONF_SCAN_INTERVAL_MINUTES, - default=normalized_scan_interval["minutes"], - ): vol.All(vol.Coerce(int), vol.Range(min=0, max=59)), + CONF_SCAN_INTERVAL, + default=normalized_scan_interval, + ): selector.DurationSelector( + selector.DurationSelectorConfig( + allow_negative=False, + enable_day=False, + enable_second=False, + ), + ), vol.Required(CONF_SHOW_TOPN, default=show_topn): vol.All( + selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, + step=1, + mode=selector.NumberSelectorMode.BOX, + ), + ), vol.Coerce(int), - vol.Range(min=1), ), vol.Required( CONF_REMOVE_SUMMARY_IMAGE, @@ -167,9 +185,9 @@ class FeedparserConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = ENTRY_VERSION @staticmethod - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + def async_get_options_flow(_config_entry: ConfigEntry) -> OptionsFlow: """Get the options flow for this handler.""" - return FeedparserOptionsFlow(config_entry) + return FeedparserOptionsFlow() async def async_step_user( self, @@ -180,6 +198,13 @@ async def async_step_user( if user_input is not None: feed_url = str(user_input[CONF_FEED_URL]).strip() + scan_interval: dict[str, int] | None = None + + try: + scan_interval = _scan_interval_from_input(user_input) + except vol.Invalid: + errors[CONF_SCAN_INTERVAL] = "scan_interval_too_short" + try: parsed_url = URL(feed_url) except ValueError: @@ -187,48 +212,50 @@ async def async_step_user( else: if parsed_url.scheme not in ("http", "https", "file"): errors[CONF_FEED_URL] = "invalid_url" + + if not errors: + await self.async_set_unique_id(feed_url) + self._abort_if_unique_id_configured(error="already_configured") + + try: + await self.hass.async_add_executor_job( + self._validate_feed_url, + feed_url, + ) + except requests.RequestException: + errors["base"] = "cannot_connect" else: - await self.async_set_unique_id(feed_url) - self._abort_if_unique_id_configured(error="already_configured") - - try: - await self.hass.async_add_executor_job( - self._validate_feed_url, - feed_url, - ) - except requests.RequestException: - errors["base"] = "cannot_connect" - else: - data = { - CONF_NAME: str(user_input[CONF_NAME]).strip(), - CONF_FEED_URL: feed_url, - } - options = { - CONF_DATE_FORMAT: str(user_input[CONF_DATE_FORMAT]).strip(), - CONF_LOCAL_TIME: bool(user_input[CONF_LOCAL_TIME]), - CONF_SCAN_INTERVAL: _scan_interval_from_input(user_input), - CONF_SHOW_TOPN: _to_int( - user_input[CONF_SHOW_TOPN], - DEFAULT_TOPN, - ), - CONF_REMOVE_SUMMARY_IMAGE: bool( - user_input[CONF_REMOVE_SUMMARY_IMAGE], - ), - CONF_INCLUSIONS: _split_csv( - str(user_input[CONF_INCLUSIONS]).strip(), - ), - CONF_EXCLUSIONS: _split_csv( - str(user_input[CONF_EXCLUSIONS]).strip(), - ), - } - return cast( - "FlowResult", - self.async_create_entry( - title=data[CONF_NAME], - data=data, - options=options, - ), - ) + assert scan_interval is not None + data = { + CONF_NAME: str(user_input[CONF_NAME]).strip(), + CONF_FEED_URL: feed_url, + } + options = { + CONF_DATE_FORMAT: str(user_input[CONF_DATE_FORMAT]).strip(), + CONF_LOCAL_TIME: bool(user_input[CONF_LOCAL_TIME]), + CONF_SCAN_INTERVAL: scan_interval, + CONF_SHOW_TOPN: _to_int( + user_input[CONF_SHOW_TOPN], + DEFAULT_TOPN, + ), + CONF_REMOVE_SUMMARY_IMAGE: bool( + user_input[CONF_REMOVE_SUMMARY_IMAGE], + ), + CONF_INCLUSIONS: _split_csv( + str(user_input[CONF_INCLUSIONS]).strip(), + ), + CONF_EXCLUSIONS: _split_csv( + str(user_input[CONF_EXCLUSIONS]).strip(), + ), + } + return cast( + "FlowResult", + self.async_create_entry( + title=data[CONF_NAME], + data=data, + options=options, + ), + ) data_schema = _schema_with_defaults( include_feed_identity=True, @@ -252,33 +279,43 @@ def _validate_feed_url(feed_url: str) -> None: response.raise_for_status() -class FeedparserOptionsFlow(OptionsFlow): +class FeedparserOptionsFlow(OptionsFlowWithReload): """Handle options for Feedparser.""" - automatic_reload = True - - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self._config_entry = config_entry - async def async_step_init( self, user_input: Mapping[str, object] | None = None, ) -> FlowResult: """Manage Feedparser options.""" + errors: dict[str, str] = {} + if user_input is not None: - options = { - CONF_DATE_FORMAT: str(user_input[CONF_DATE_FORMAT]).strip(), - CONF_LOCAL_TIME: bool(user_input[CONF_LOCAL_TIME]), - CONF_SCAN_INTERVAL: _scan_interval_from_input(user_input), - CONF_SHOW_TOPN: _to_int(user_input[CONF_SHOW_TOPN], DEFAULT_TOPN), - CONF_REMOVE_SUMMARY_IMAGE: bool(user_input[CONF_REMOVE_SUMMARY_IMAGE]), - CONF_INCLUSIONS: _split_csv(str(user_input[CONF_INCLUSIONS]).strip()), - CONF_EXCLUSIONS: _split_csv(str(user_input[CONF_EXCLUSIONS]).strip()), - } - return cast("FlowResult", self.async_create_entry(title="", data=options)) - - merged = {**self._config_entry.data, **self._config_entry.options} + try: + scan_interval = _scan_interval_from_input(user_input) + except vol.Invalid: + errors[CONF_SCAN_INTERVAL] = "scan_interval_too_short" + else: + options = { + CONF_DATE_FORMAT: str(user_input[CONF_DATE_FORMAT]).strip(), + CONF_LOCAL_TIME: bool(user_input[CONF_LOCAL_TIME]), + CONF_SCAN_INTERVAL: scan_interval, + CONF_SHOW_TOPN: _to_int(user_input[CONF_SHOW_TOPN], DEFAULT_TOPN), + CONF_REMOVE_SUMMARY_IMAGE: bool( + user_input[CONF_REMOVE_SUMMARY_IMAGE], + ), + CONF_INCLUSIONS: _split_csv( + str(user_input[CONF_INCLUSIONS]).strip(), + ), + CONF_EXCLUSIONS: _split_csv( + str(user_input[CONF_EXCLUSIONS]).strip(), + ), + } + return cast( + "FlowResult", + self.async_create_entry(title="", data=options), + ) + + merged = {**self.config_entry.data, **self.config_entry.options} data_schema = _schema_with_defaults( include_feed_identity=False, date_format=str(merged.get(CONF_DATE_FORMAT, DEFAULT_DATE_FORMAT)), @@ -298,5 +335,9 @@ async def async_step_init( ) return cast( "FlowResult", - self.async_show_form(step_id="init", data_schema=data_schema), + self.async_show_form( + step_id="init", + data_schema=data_schema, + errors=errors, + ), ) diff --git a/custom_components/feedparser/sensor.py b/custom_components/feedparser/sensor.py index a862a1f..491324a 100644 --- a/custom_components/feedparser/sensor.py +++ b/custom_components/feedparser/sensor.py @@ -196,7 +196,6 @@ def __init__( self._scan_interval = scan_interval self._local_time = local_time self._entries: list[dict[str, object]] = [] - self._attr_extra_state_attributes = {"entries": self._entries} self._attr_attribution = "Data retrieved using RSS feedparser" _LOGGER.debug("Feed %s: FeedParserSensor initialized - %s", self.name, self) @@ -243,8 +242,11 @@ def update(self: FeedParserSensor) -> None: self.name, self.native_value, ) - self._entries.clear() # clear the entries to avoid duplicates - self._entries.extend(self._generate_entries(parsed_feed)) + # Replace the list instead of mutating it in place. Home Assistant + # keeps references to nested attribute values in previous State objects, so + # in-place mutation would also change the previous state snapshot and hide + # genuine `entries` attribute changes from state triggers. + self._entries = self._generate_entries(parsed_feed) _LOGGER.debug( "Feed %s: Sensor state updated - %s entries", self.name, @@ -432,6 +434,8 @@ def local_time(self: FeedParserSensor, value: bool) -> None: self._local_time = value @property - def extra_state_attributes(self: FeedParserSensor) -> dict[str, list]: + def extra_state_attributes( + self: FeedParserSensor, + ) -> dict[str, list[dict[str, object]]]: """Return entity specific state attributes.""" return {"entries": self.feed_entries} diff --git a/custom_components/feedparser/strings.json b/custom_components/feedparser/strings.json index 602f69d..2dc9ffd 100644 --- a/custom_components/feedparser/strings.json +++ b/custom_components/feedparser/strings.json @@ -3,25 +3,36 @@ "step": { "user": { "title": "Feedparser", - "description": "Create a sensor from an RSS/Atom feed.", + "description": "Create a sensor from an RSS/Atom feed. Refresh and parsing options can be changed later.", "data": { "name": "Name", "feed_url": "Feed URL", "date_format": "Date format", "local_time": "Convert dates to local time", - "scan_interval_hours": "Scan interval hours", - "scan_interval_minutes": "Scan interval minutes", - "show_topn": "Number of entries", + "scan_interval": "Refresh interval", + "show_topn": "Maximum entries", "remove_summary_image": "Remove image tags from summary", - "inclusions": "Included fields (comma-separated)", - "exclusions": "Excluded fields (comma-separated)" + "inclusions": "Included fields", + "exclusions": "Excluded fields" + }, + "data_description": { + "name": "Friendly name for the sensor.", + "feed_url": "RSS or Atom feed URL. HTTP, HTTPS, and local file URLs are supported.", + "date_format": "Python strftime format used for feed dates, for example `%a, %d %b %Y %H:%M:%S %Z`.", + "local_time": "Convert feed timestamps to Home Assistant's configured local time before formatting.", + "scan_interval": "How often to refresh the feed. The minimum interval is 1 minute.", + "show_topn": "Maximum number of entries exposed in the `entries` attribute. Large values increase state size.", + "remove_summary_image": "Strip `` tags from the `summary` field. This does not remove a separate `image` field.", + "inclusions": "Optional comma-separated allowlist, for example `title, link, published`. Leave empty to keep all fields except exclusions.", + "exclusions": "Optional comma-separated fields to remove. Exclusions are applied after inclusions." } } }, "error": { "already_configured": "This feed is already configured.", "cannot_connect": "Failed to download the feed.", - "invalid_url": "URL is invalid." + "invalid_url": "URL is invalid.", + "scan_interval_too_short": "Refresh interval must be at least 1 minute." }, "abort": { "already_configured": "This feed is already configured." @@ -31,18 +42,29 @@ "step": { "init": { "title": "Feedparser options", - "description": "Update feed parsing behavior.", + "description": "Update feed refresh and parsing behavior. Changes are applied immediately by reloading this feed.", "data": { "date_format": "Date format", "local_time": "Convert dates to local time", - "scan_interval_hours": "Scan interval hours", - "scan_interval_minutes": "Scan interval minutes", - "show_topn": "Number of entries", + "scan_interval": "Refresh interval", + "show_topn": "Maximum entries", "remove_summary_image": "Remove image tags from summary", - "inclusions": "Included fields (comma-separated)", - "exclusions": "Excluded fields (comma-separated)" + "inclusions": "Included fields", + "exclusions": "Excluded fields" + }, + "data_description": { + "date_format": "Python strftime format used for feed dates, for example `%a, %d %b %Y %H:%M:%S %Z`.", + "local_time": "Convert feed timestamps to Home Assistant's configured local time before formatting.", + "scan_interval": "How often to refresh the feed. The minimum interval is 1 minute.", + "show_topn": "Maximum number of entries exposed in the `entries` attribute. Large values increase state size.", + "remove_summary_image": "Strip `` tags from the `summary` field. This does not remove a separate `image` field.", + "inclusions": "Optional comma-separated allowlist, for example `title, link, published`. Leave empty to keep all fields except exclusions.", + "exclusions": "Optional comma-separated fields to remove. Exclusions are applied after inclusions." } } + }, + "error": { + "scan_interval_too_short": "Refresh interval must be at least 1 minute." } } } diff --git a/custom_components/feedparser/translations/de.json b/custom_components/feedparser/translations/de.json new file mode 100644 index 0000000..3f0074e --- /dev/null +++ b/custom_components/feedparser/translations/de.json @@ -0,0 +1,70 @@ +{ + "config": { + "step": { + "user": { + "title": "Feedparser", + "description": "Erstelle einen Sensor aus einem RSS-/Atom-Feed. Aktualisierungs- und Verarbeitungsoptionen können später geändert werden.", + "data": { + "name": "Name", + "feed_url": "Feed-URL", + "date_format": "Datumsformat", + "local_time": "Datumsangaben in lokale Zeit umwandeln", + "scan_interval": "Aktualisierungsintervall", + "show_topn": "Maximale Einträge", + "remove_summary_image": "Bild-Tags aus der Zusammenfassung entfernen", + "inclusions": "Einbezogene Felder", + "exclusions": "Ausgeschlossene Felder" + }, + "data_description": { + "name": "Anzeigename für den Sensor.", + "feed_url": "URL eines RSS- oder Atom-Feeds. HTTP, HTTPS und lokale Datei-URLs werden unterstützt.", + "date_format": "Python-strftime-Format für Feed-Datumswerte, zum Beispiel `%a, %d %b %Y %H:%M:%S %Z`.", + "local_time": "Feed-Zeitstempel vor der Formatierung in die in Home Assistant konfigurierte lokale Zeit umwandeln.", + "scan_interval": "Wie oft der Feed aktualisiert wird. Das Mindestintervall beträgt 1 Minute.", + "show_topn": "Maximale Anzahl von Einträgen im Attribut `entries`. Große Werte erhöhen die Zustandsgröße.", + "remove_summary_image": "Entfernt ``-Tags aus dem Feld `summary`. Ein separates Feld `image` wird nicht entfernt.", + "inclusions": "Optionale, durch Kommas getrennte Positivliste, zum Beispiel `title, link, published`. Leer lassen, um alle Felder außer den ausgeschlossenen beizubehalten.", + "exclusions": "Optionale, durch Kommas getrennte Felder, die entfernt werden sollen. Ausschlüsse werden nach den Einschlüssen angewendet." + } + } + }, + "error": { + "already_configured": "Dieser Feed ist bereits konfiguriert.", + "cannot_connect": "Der Feed konnte nicht heruntergeladen werden.", + "invalid_url": "Die URL ist ungültig.", + "scan_interval_too_short": "Das Aktualisierungsintervall muss mindestens 1 Minute betragen." + }, + "abort": { + "already_configured": "Dieser Feed ist bereits konfiguriert." + } + }, + "options": { + "step": { + "init": { + "title": "Feedparser-Optionen", + "description": "Passe die Feed-Aktualisierung und -Verarbeitung an. Änderungen werden sofort angewendet, indem dieser Feed neu geladen wird.", + "data": { + "date_format": "Datumsformat", + "local_time": "Datumsangaben in lokale Zeit umwandeln", + "scan_interval": "Aktualisierungsintervall", + "show_topn": "Maximale Einträge", + "remove_summary_image": "Bild-Tags aus der Zusammenfassung entfernen", + "inclusions": "Einbezogene Felder", + "exclusions": "Ausgeschlossene Felder" + }, + "data_description": { + "date_format": "Python-strftime-Format für Feed-Datumswerte, zum Beispiel `%a, %d %b %Y %H:%M:%S %Z`.", + "local_time": "Feed-Zeitstempel vor der Formatierung in die in Home Assistant konfigurierte lokale Zeit umwandeln.", + "scan_interval": "Wie oft der Feed aktualisiert wird. Das Mindestintervall beträgt 1 Minute.", + "show_topn": "Maximale Anzahl von Einträgen im Attribut `entries`. Große Werte erhöhen die Zustandsgröße.", + "remove_summary_image": "Entfernt ``-Tags aus dem Feld `summary`. Ein separates Feld `image` wird nicht entfernt.", + "inclusions": "Optionale, durch Kommas getrennte Positivliste, zum Beispiel `title, link, published`. Leer lassen, um alle Felder außer den ausgeschlossenen beizubehalten.", + "exclusions": "Optionale, durch Kommas getrennte Felder, die entfernt werden sollen. Ausschlüsse werden nach den Einschlüssen angewendet." + } + } + }, + "error": { + "scan_interval_too_short": "Das Aktualisierungsintervall muss mindestens 1 Minute betragen." + } + } +} diff --git a/custom_components/feedparser/translations/el.json b/custom_components/feedparser/translations/el.json new file mode 100644 index 0000000..6cbe972 --- /dev/null +++ b/custom_components/feedparser/translations/el.json @@ -0,0 +1,70 @@ +{ + "config": { + "step": { + "user": { + "title": "Feedparser", + "description": "Δημιουργήστε έναν αισθητήρα από μια ροή RSS/Atom. Οι επιλογές ανανέωσης και επεξεργασίας μπορούν να αλλάξουν αργότερα.", + "data": { + "name": "Όνομα", + "feed_url": "URL ροής", + "date_format": "Μορφή ημερομηνίας", + "local_time": "Μετατροπή ημερομηνιών σε τοπική ώρα", + "scan_interval": "Διάστημα ανανέωσης", + "show_topn": "Μέγιστος αριθμός καταχωρίσεων", + "remove_summary_image": "Αφαίρεση ετικετών εικόνας από τη σύνοψη", + "inclusions": "Συμπεριλαμβανόμενα πεδία", + "exclusions": "Εξαιρούμενα πεδία" + }, + "data_description": { + "name": "Φιλικό όνομα για τον αισθητήρα.", + "feed_url": "URL ροής RSS ή Atom. Υποστηρίζονται HTTP, HTTPS και τοπικά URL αρχείων.", + "date_format": "Μορφή Python strftime που χρησιμοποιείται για τις ημερομηνίες της ροής, για παράδειγμα `%a, %d %b %Y %H:%M:%S %Z`.", + "local_time": "Μετατροπή των χρονικών σημάνσεων της ροής στην τοπική ώρα που έχει ρυθμιστεί στο Home Assistant πριν από τη μορφοποίηση.", + "scan_interval": "Πόσο συχνά ανανεώνεται η ροή. Το ελάχιστο διάστημα είναι 1 λεπτό.", + "show_topn": "Μέγιστος αριθμός καταχωρίσεων που εμφανίζονται στο χαρακτηριστικό `entries`. Μεγάλες τιμές αυξάνουν το μέγεθος της κατάστασης.", + "remove_summary_image": "Αφαιρεί τις ετικέτες `` από το πεδίο `summary`. Δεν αφαιρεί ξεχωριστό πεδίο `image`.", + "inclusions": "Προαιρετική λίστα επιτρεπόμενων πεδίων χωρισμένων με κόμμα, για παράδειγμα `title, link, published`. Αφήστε το κενό για να διατηρηθούν όλα τα πεδία εκτός από όσα εξαιρούνται.", + "exclusions": "Προαιρετικά πεδία προς αφαίρεση, χωρισμένα με κόμμα. Οι εξαιρέσεις εφαρμόζονται μετά τις συμπεριλήψεις." + } + } + }, + "error": { + "already_configured": "Αυτή η ροή έχει ήδη ρυθμιστεί.", + "cannot_connect": "Αποτυχία λήψης της ροής.", + "invalid_url": "Το URL δεν είναι έγκυρο.", + "scan_interval_too_short": "Το διάστημα ανανέωσης πρέπει να είναι τουλάχιστον 1 λεπτό." + }, + "abort": { + "already_configured": "Αυτή η ροή έχει ήδη ρυθμιστεί." + } + }, + "options": { + "step": { + "init": { + "title": "Επιλογές Feedparser", + "description": "Αλλάξτε τη συχνότητα ανανέωσης και τη συμπεριφορά επεξεργασίας της ροής. Οι αλλαγές εφαρμόζονται αμέσως με επαναφόρτωση αυτής της ροής.", + "data": { + "date_format": "Μορφή ημερομηνίας", + "local_time": "Μετατροπή ημερομηνιών σε τοπική ώρα", + "scan_interval": "Διάστημα ανανέωσης", + "show_topn": "Μέγιστος αριθμός καταχωρίσεων", + "remove_summary_image": "Αφαίρεση ετικετών εικόνας από τη σύνοψη", + "inclusions": "Συμπεριλαμβανόμενα πεδία", + "exclusions": "Εξαιρούμενα πεδία" + }, + "data_description": { + "date_format": "Μορφή Python strftime που χρησιμοποιείται για τις ημερομηνίες της ροής, για παράδειγμα `%a, %d %b %Y %H:%M:%S %Z`.", + "local_time": "Μετατροπή των χρονικών σημάνσεων της ροής στην τοπική ώρα που έχει ρυθμιστεί στο Home Assistant πριν από τη μορφοποίηση.", + "scan_interval": "Πόσο συχνά ανανεώνεται η ροή. Το ελάχιστο διάστημα είναι 1 λεπτό.", + "show_topn": "Μέγιστος αριθμός καταχωρίσεων που εμφανίζονται στο χαρακτηριστικό `entries`. Μεγάλες τιμές αυξάνουν το μέγεθος της κατάστασης.", + "remove_summary_image": "Αφαιρεί τις ετικέτες `` από το πεδίο `summary`. Δεν αφαιρεί ξεχωριστό πεδίο `image`.", + "inclusions": "Προαιρετική λίστα επιτρεπόμενων πεδίων χωρισμένων με κόμμα, για παράδειγμα `title, link, published`. Αφήστε το κενό για να διατηρηθούν όλα τα πεδία εκτός από όσα εξαιρούνται.", + "exclusions": "Προαιρετικά πεδία προς αφαίρεση, χωρισμένα με κόμμα. Οι εξαιρέσεις εφαρμόζονται μετά τις συμπεριλήψεις." + } + } + }, + "error": { + "scan_interval_too_short": "Το διάστημα ανανέωσης πρέπει να είναι τουλάχιστον 1 λεπτό." + } + } +} diff --git a/custom_components/feedparser/translations/en.json b/custom_components/feedparser/translations/en.json index 602f69d..2dc9ffd 100644 --- a/custom_components/feedparser/translations/en.json +++ b/custom_components/feedparser/translations/en.json @@ -3,25 +3,36 @@ "step": { "user": { "title": "Feedparser", - "description": "Create a sensor from an RSS/Atom feed.", + "description": "Create a sensor from an RSS/Atom feed. Refresh and parsing options can be changed later.", "data": { "name": "Name", "feed_url": "Feed URL", "date_format": "Date format", "local_time": "Convert dates to local time", - "scan_interval_hours": "Scan interval hours", - "scan_interval_minutes": "Scan interval minutes", - "show_topn": "Number of entries", + "scan_interval": "Refresh interval", + "show_topn": "Maximum entries", "remove_summary_image": "Remove image tags from summary", - "inclusions": "Included fields (comma-separated)", - "exclusions": "Excluded fields (comma-separated)" + "inclusions": "Included fields", + "exclusions": "Excluded fields" + }, + "data_description": { + "name": "Friendly name for the sensor.", + "feed_url": "RSS or Atom feed URL. HTTP, HTTPS, and local file URLs are supported.", + "date_format": "Python strftime format used for feed dates, for example `%a, %d %b %Y %H:%M:%S %Z`.", + "local_time": "Convert feed timestamps to Home Assistant's configured local time before formatting.", + "scan_interval": "How often to refresh the feed. The minimum interval is 1 minute.", + "show_topn": "Maximum number of entries exposed in the `entries` attribute. Large values increase state size.", + "remove_summary_image": "Strip `` tags from the `summary` field. This does not remove a separate `image` field.", + "inclusions": "Optional comma-separated allowlist, for example `title, link, published`. Leave empty to keep all fields except exclusions.", + "exclusions": "Optional comma-separated fields to remove. Exclusions are applied after inclusions." } } }, "error": { "already_configured": "This feed is already configured.", "cannot_connect": "Failed to download the feed.", - "invalid_url": "URL is invalid." + "invalid_url": "URL is invalid.", + "scan_interval_too_short": "Refresh interval must be at least 1 minute." }, "abort": { "already_configured": "This feed is already configured." @@ -31,18 +42,29 @@ "step": { "init": { "title": "Feedparser options", - "description": "Update feed parsing behavior.", + "description": "Update feed refresh and parsing behavior. Changes are applied immediately by reloading this feed.", "data": { "date_format": "Date format", "local_time": "Convert dates to local time", - "scan_interval_hours": "Scan interval hours", - "scan_interval_minutes": "Scan interval minutes", - "show_topn": "Number of entries", + "scan_interval": "Refresh interval", + "show_topn": "Maximum entries", "remove_summary_image": "Remove image tags from summary", - "inclusions": "Included fields (comma-separated)", - "exclusions": "Excluded fields (comma-separated)" + "inclusions": "Included fields", + "exclusions": "Excluded fields" + }, + "data_description": { + "date_format": "Python strftime format used for feed dates, for example `%a, %d %b %Y %H:%M:%S %Z`.", + "local_time": "Convert feed timestamps to Home Assistant's configured local time before formatting.", + "scan_interval": "How often to refresh the feed. The minimum interval is 1 minute.", + "show_topn": "Maximum number of entries exposed in the `entries` attribute. Large values increase state size.", + "remove_summary_image": "Strip `` tags from the `summary` field. This does not remove a separate `image` field.", + "inclusions": "Optional comma-separated allowlist, for example `title, link, published`. Leave empty to keep all fields except exclusions.", + "exclusions": "Optional comma-separated fields to remove. Exclusions are applied after inclusions." } } + }, + "error": { + "scan_interval_too_short": "Refresh interval must be at least 1 minute." } } } diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py new file mode 100644 index 0000000..ea19025 --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,39 @@ +"""Tests for the Feedparser config flow helpers.""" + +from datetime import timedelta + +import pytest +import voluptuous as vol +from homeassistant.config_entries import OptionsFlowWithReload + +from custom_components.feedparser.config_flow import ( + FeedparserOptionsFlow, + _scan_interval_from_input, +) +from custom_components.feedparser.const import CONF_SCAN_INTERVAL + + +def test_options_flow_reloads_config_entry() -> None: + """Test that saving options uses Home Assistant automatic reload support.""" + assert issubclass(FeedparserOptionsFlow, OptionsFlowWithReload) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ({"hours": 0, "minutes": 1}, {"hours": 0, "minutes": 1}), + ({"hours": 1, "minutes": 30}, {"hours": 1, "minutes": 30}), + (timedelta(hours=2), {"hours": 2, "minutes": 0}), + ], +) +def test_scan_interval_normalization(value: object, expected: dict[str, int]) -> None: + """Test refresh interval normalization.""" + assert _scan_interval_from_input({CONF_SCAN_INTERVAL: value}) == expected + + +def test_scan_interval_rejects_zero() -> None: + """Test that a zero refresh interval is rejected instead of silently changed.""" + with pytest.raises(vol.Invalid): + _scan_interval_from_input( + {CONF_SCAN_INTERVAL: {"hours": 0, "minutes": 0}}, + ) diff --git a/tests/test_sensors.py b/tests/test_sensors.py index 1010db3..0fdd2f3 100644 --- a/tests/test_sensors.py +++ b/tests/test_sensors.py @@ -3,6 +3,7 @@ import re from contextlib import nullcontext, suppress from datetime import UTC, datetime +from pathlib import Path from typing import TYPE_CHECKING import feedparser @@ -156,6 +157,55 @@ def test_update_sensor_entries_time( assert first_entry_time == first_sensor_entry_time +def test_update_preserves_previous_attribute_snapshot(tmp_path: Path) -> None: + """Test that a new poll does not mutate the previous entries snapshot.""" + feed_path = tmp_path / "snapshot.xml" + + def write_feed(title: str) -> None: + feed_path.write_text( + f""" + + + Snapshot test + https://example.com + Snapshot test + + {title} + https://example.com/item + Tue, 08 Sep 2026 12:00:00 GMT + + + +""", + encoding="utf-8", + ) + + feed_sensor = FeedParserSensor( + feed=feed_path.absolute().as_uri(), + name="snapshot", + date_format=DATE_FORMAT, + local_time=False, + show_topn=1, + remove_summary_image=False, + inclusions=["title", "link", "published"], + exclusions=[], + scan_interval=DEFAULT_SCAN_INTERVAL, + ) + + write_feed("Old title") + feed_sensor.update() + old_attributes = feed_sensor.extra_state_attributes + old_entries = old_attributes["entries"] + + write_feed("New title") + feed_sensor.update() + new_entries = feed_sensor.extra_state_attributes["entries"] + + assert old_entries is not new_entries + assert old_entries[0]["title"] == "Old title" + assert new_entries[0]["title"] == "New title" + + def test_check_duplicates(feed_sensor: FeedParserSensor) -> None: """Test that the sensor stores only unique entries.""" feed_sensor.update()