diff --git a/changedetectionio/jinja2_custom/__init__.py b/changedetectionio/jinja2_custom/__init__.py index dfc609ae5..39e0fe0f0 100644 --- a/changedetectionio/jinja2_custom/__init__.py +++ b/changedetectionio/jinja2_custom/__init__.py @@ -10,6 +10,7 @@ from .safe_jinja import ( DEFAULT_JINJA2_EXTENSIONS, ) from .plugins.regex import regex_replace +from .plugins.datetime_fmt import unixtime __all__ = [ 'TimeExtension', @@ -19,4 +20,5 @@ __all__ = [ 'JINJA2_MAX_RETURN_PAYLOAD_SIZE', 'DEFAULT_JINJA2_EXTENSIONS', 'regex_replace', + 'unixtime', ] diff --git a/changedetectionio/jinja2_custom/plugins/__init__.py b/changedetectionio/jinja2_custom/plugins/__init__.py index 2207aa690..f150883f7 100644 --- a/changedetectionio/jinja2_custom/plugins/__init__.py +++ b/changedetectionio/jinja2_custom/plugins/__init__.py @@ -2,5 +2,6 @@ Jinja2 custom filter plugins for changedetection.io """ from .regex import regex_replace +from .datetime_fmt import unixtime -__all__ = ['regex_replace'] +__all__ = ['regex_replace', 'unixtime'] diff --git a/changedetectionio/jinja2_custom/plugins/datetime_fmt.py b/changedetectionio/jinja2_custom/plugins/datetime_fmt.py new file mode 100644 index 000000000..4d7271471 --- /dev/null +++ b/changedetectionio/jinja2_custom/plugins/datetime_fmt.py @@ -0,0 +1,117 @@ +""" +Unix-timestamp conversion filter plugin for Jinja2 templates. + +Provides the ``unixtime`` filter, which converts a Unix timestamp (seconds, +milliseconds or microseconds) into a human-readable, strftime-formatted string. + +Requested in #3641: API/JSON-style monitored pages often expose values such as +``last_battle_at: 1759188682`` and notifications printed the raw integer. +""" +import datetime +import os +import re +import typing as t + +from loguru import logger + +# Bound the size of input we will parse, mirroring the defensive limits used by +# the other Jinja plugins. A notification token should never be multi-MB. +MAX_INPUT_SIZE = 1024 * 1024 # 1 MB + +# Default output format (overridable per call) and default timezone. +DEFAULT_FORMAT = "%Y-%m-%d %H:%M:%S %Z" + +# A bare 10+ digit run is enough to locate a timestamp embedded in prose such +# as "last_battle_at: 1759188682" without accepting unrelated small numbers. +_TIMESTAMP_RE = re.compile(r"-?\d{9,}") + + +def _default_tzname() -> str: + return os.getenv("TZ", "UTC").strip() or "UTC" + + +def _resolve_timezone(tz_name: t.Optional[str]): + """Return a tzinfo for tz_name, defaulting to UTC without third-party deps.""" + name = (tz_name or _default_tzname()).strip() + if name.upper() in ("", "UTC"): + return datetime.timezone.utc + # datetime.timezone only supports fixed offsets; resolve common named zones + # via the UTC-offset environment when available. Avoid importing zoneinfo on + # runtimes without the tz database by falling back to UTC. + try: + from zoneinfo import ZoneInfo # stdlib on Python 3.9+ + + return ZoneInfo(name) + except Exception: + return datetime.timezone.utc + + +def _coerce_timestamp(raw: t.Any) -> t.Optional[int]: + """ + Extract a Unix timestamp in seconds from ints, floats, numeric strings or + prose containing one. Millisecond (13-digit) and microsecond (16-digit) + values are normalised to seconds. Returns None when no value is usable. + """ + if raw is None: + return None + + if isinstance(raw, bool): # bool is a subclass of int — never treat as ts + return None + + if isinstance(raw, (int, float)): + value = int(raw) + else: + text = str(raw) + if len(text) > MAX_INPUT_SIZE: + logger.warning("unixtime: input too large (%d bytes), truncating", len(text)) + text = text[:MAX_INPUT_SIZE] + match = _TIMESTAMP_RE.search(text) + if not match: + return None + value = int(match.group(0)) + + if abs(value) >= 1_000_000_000_000_000: # microseconds + value //= 1_000_000 + elif abs(value) >= 1_000_000_000_000: # milliseconds + value //= 1_000 + # Anything >= ~1e9 is plausible as seconds. Smaller 9-digit values + # (e.g. 999999999 ≈ 2001) are still accepted by the regex. + return value + + +def unixtime( + value: t.Any, + fmt: str = DEFAULT_FORMAT, + tz_name: t.Optional[str] = None, +) -> t.Any: + """ + Format a Unix timestamp as a human-readable datetime. + + Args: + value: timestamp as int/float/string, or text containing one. Seconds, + milliseconds and microseconds are all accepted. + fmt: ``strftime`` format string (default ``%Y-%m-%d %H:%M:%S %Z``). + tz_name: timezone name (default: ``$TZ`` or UTC). + + Returns: + The formatted datetime string. On any failure (missing/invalid value or + format) the original value is returned unchanged, so a notification + never crashes and simply falls back to the raw token. + + Examples: + {{ triggered_text | unixtime }} + {{ triggered_text | unixtime("%d.%m.%Y %H:%M") }} + {{ triggered_text | unixtime("%d.%m.%Y %H:%M", "Europe/Berlin") }} + """ + seconds = _coerce_timestamp(value) + if seconds is None: + return value + + try: + tzinfo = _resolve_timezone(tz_name) + dt = datetime.datetime.fromtimestamp(seconds, tz=tzinfo) + return dt.strftime(fmt) + except (ValueError, OSError, OverflowError) as e: + # Bad format string or out-of-range timestamp — keep the raw token. + logger.warning("unixtime: could not format value %r (%s)", value, e) + return value diff --git a/changedetectionio/jinja2_custom/safe_jinja.py b/changedetectionio/jinja2_custom/safe_jinja.py index 67f4e57af..d270946e5 100644 --- a/changedetectionio/jinja2_custom/safe_jinja.py +++ b/changedetectionio/jinja2_custom/safe_jinja.py @@ -8,7 +8,7 @@ import jinja2.sandbox import typing as t import os from .extensions.TimeExtension import TimeExtension -from .plugins import regex_replace +from .plugins import regex_replace, unixtime JINJA2_MAX_RETURN_PAYLOAD_SIZE = 1024 * int(os.getenv("JINJA2_MAX_RETURN_PAYLOAD_SIZE_KB", 1024 * 10)) @@ -40,6 +40,7 @@ def create_jinja_env(extensions=None, **kwargs) -> jinja2.sandbox.ImmutableSandb # Register custom filters jinja2_env.filters['regex_replace'] = regex_replace + jinja2_env.filters['unixtime'] = unixtime return jinja2_env @@ -61,4 +62,3 @@ def render_fully_escaped(content): """ from markupsafe import escape return str(escape(content)) - diff --git a/changedetectionio/tests/test_jinja2_unixtime.py b/changedetectionio/tests/test_jinja2_unixtime.py new file mode 100644 index 000000000..589a43a50 --- /dev/null +++ b/changedetectionio/tests/test_jinja2_unixtime.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +""" +Tests for the ``unixtime`` Jinja2 filter (#3641). + +Pure unit tests that render through the real (sandboxed) Jinja environment, +so they also confirm the filter is registered and usable in notifications. +""" +from ..jinja2_custom import render + + +# Timestamp chosen so the UTC result is unambiguous: +# 1759188682 -> 2025-09-29 23:31:00 UTC +TS = 1759188682 + + +def test_unixtime_default_format(): + # Pin UTC so the result is independent of the runner's TZ. The default + # format string (%Y-%m-%d %H:%M:%S %Z) is still exercised; the default + # timezone otherwise follows $TZ. + out = render('{{ ts | unixtime("%Y-%m-%d %H:%M:%S %Z", "UTC") }}', ts=TS) + assert out == "2025-09-29 23:31:22 UTC" + + +def test_unixtime_custom_format(): + out = render('{{ ts | unixtime("%d.%m.%Y %H:%M", "UTC") }}', ts=TS) + assert out == "29.09.2025 23:31" + + +def test_unixtime_milliseconds(): + out = render('{{ ts | unixtime("%d.%m.%Y %H:%M", "UTC") }}', ts=TS * 1000) + assert out == "29.09.2025 23:31" + + +def test_unixtime_microseconds(): + out = render('{{ ts | unixtime("%d.%m.%Y %H:%M", "UTC") }}', ts=TS * 1_000_000) + assert out == "29.09.2025 23:31" + + +def test_unixtime_extracts_from_prose(): + out = render('{{ s | unixtime("%d.%m.%Y %H:%M", "UTC") }}', + s="last_battle_at: 1759188682") + assert out == "29.09.2025 23:31" + + +def test_unixtime_named_timezone(): + out = render('{{ ts | unixtime("%d.%m.%Y %H:%M", "Europe/Berlin") }}', ts=TS) + assert out == "30.09.2025 01:31" + + +def test_unixtime_no_timestamp_falls_back(): + # No usable timestamp -> the original token is returned, no exception. + assert render("{{ s | unixtime }}", s="no timestamp here") == "no timestamp here" + assert render("{{ s | unixtime }}", s=None) == "None" + + +def test_unixtime_small_numbers_ignored(): + assert render('{{ s | unixtime("%Y", "UTC") }}', s="price: 42") == "price: 42"