* Watchlist/Notifications - Resolve 'Link to Open' once per row, don't leak 'DISABLED' into notification tokens
Follow-up to #4290.
`watch.open_link` was read three times per watch list row (two hrefs plus the
new title attribute). Each read re-runs `_resolve_link()`, which for a Jinja2
templated URL builds a fresh ImmutableSandboxedEnvironment - the cost the
comment in validate_url.py already warns about. Hoisted to a single
`{% set open_link = watch.open_link %}` alongside the other per-row lookups.
`watch_open_url` was set from `watch.open_link`, which returns the string
'DISABLED' when the URL fails validation, so the default RSS body template
(`RSS_TEMPLATE_HTML_DEFAULT`) rendered `<a href="DISABLED">` for those watches.
Now falls back to the raw URL, matching the neighbouring `watch_url` token.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Rebuild template
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Compare against a resolved data_dir in the history path-traversal test
Watch.history resolves entries with os.path.realpath, so
test_normal_snapshot_entry_is_accepted compared a resolved path against an
unresolved data_dir. On macOS the datastore lives under /tmp, which is a
symlink to /private/tmp, so the assertion fails for a path that is in fact
inside the directory. The guard is correct; the test was not.
Resolve both sides, matching what the production code does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Add a test that actually exercises the history containment check
Disabling the containment check in Watch.history left every test in
TestHistoryPathTraversal passing. os.path.basename() reduces both traversal
fixtures ('/etc/passwd', '../../etc/passwd') to 'passwd', so neither reaches
the check — they stop at the os.path.exists() test below it.
A bare '..' survives basename() and resolves to the parent of data_dir, which
exists, so the containment check is what rejects it. With that check disabled
this new test is the only one in the class that fails.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: GG5533 <285285461+GG5533@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(conditions): support zero values in condition filtering and json logic conversion
- Fix filter_complete_rules dropping rules where value is 0/0.0 due to 0 == False in Python
- Fix convert_to_jsonlogic raising EmptyConditionRuleRowNotUsable on value=0 due to truthiness check
- Fix str != 'None' type comparison typo in convert_to_jsonlogic
- Add comprehensive unit tests covering zero value condition filtering, conversion, and execution
* chore: re-trigger CI checks
---------
Co-authored-by: Andrew Peabody <apeabody@users.noreply.github.com>
`tag` on POST /watch was documented as taking a tag UUID, but the value went to
add_tag(title): a UUID silently created a junk tag *titled* with that UUID and never
applied the tag the caller asked for. `tags` (UUIDs) was the only thing that worked.
- `tag=` now resolves an existing tag UUID to that tag, still falling back to title
matching/creation for names. A UUID-shaped value matching nothing is skipped with a
warning rather than becoming a group named after a UUID.
- Blank tokens ("One,,Two,") no longer store False in watch['tags'] - add_tag() returns
False for an empty title and it was appended unguarded. Consumers tolerate it
(get_all_tags_for_watch() dictfilt()s over known tags) but it is not valid data.
- add_tag()'s title search is extracted to tag_uuid_for_title(), so existence can be
tested without creating as a side effect. add_tag()'s contract is unchanged.
- api-spec: `tag` is marked `deprecated: true` (so Redoc renders the badge) and states
plainly that it takes names, not UUIDs. `tags` now says what it really does - applied
verbatim, never creates, unknown UUIDs stored as dangling refs. Rendered docs rebuilt.
Every claim in the new field docs is asserted in test_api_tags.py against the real API.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Env var - PAGE_WATCH_LIMIT enhancements
* Rebuild API docs
* Bump APi doc version
* test: cover PAGE_WATCH_LIMIT across every add path
- API create returns 429; API import returns 429 and refuses the batch whole
- quick-add and the UI importer flash the limit (importer once per file, not per row)
and hand unimported URLs back
- clone at the limit no longer KeyErrors
- an instance already over the limit still loads from disk and stays editable, only
new watches are refused
- the Info tab shows the limit only when one is set
- add_watch() with no request context returns None instead of raising from flash()
- an absent, empty or unparseable env var all mean unlimited
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Define _NO_TEMPERATURE_MODEL_KEYWORDS constant and omit temperature=0 on initial request
- Omit thinkingConfig in _thinking_extra_body for flash-lite models
- Unconditionally strip rejected sampling params and extra_body thinkingConfig on BadRequestError (handling Gemini 400 INVALID_ARGUMENT without named details)
- Add comprehensive unit tests in test_llm_client_gemini.py
* UI - LLM section tidyup
* Rebuild translatiosn
* UI - Fixing language for prompt adjustement to be more clear
* UI - clarify field action
* UI - clarify layout for sections
* WIP
* test tweaks
* Scheduler+API Bug - if an invalid timezone was set (through edit of watch or API) it could have crashed the scheduler, Added `timezone` to the official API docs
* adding missing files
Follow-up to #4340. The <think> stripper added there only matches a closed pair, but a
reasoning scratchpad routinely contains JSON of its own ("initially I thought
{"important": false}, but..."), so any leftover scratchpad lets _extract_json lock onto
a discarded intermediate answer. Three shapes slipped through, all of which inverted the
verdict to important=false and therefore silently suppressed the notification:
- opener stripped by the provider/chat template, only </think> comes back over the wire
- <thinking> spelled out in full
- unterminated block, i.e. the response was cut off mid-thought by max_tokens
The first two are now stripped. The third raises ValueError, because a truncated response
holds no answer at all - only the abandoned guess. Raising routes it to the existing
handler in evaluator.py, which passes the change through as important rather than dropping
it; parse_eval_response deliberately does not catch ValueError, since its own fallback
(important=False) would suppress the notification instead.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Simplify _to_bool using changedetectionio.strtobool.strtobool
- Strip <think>...</think> reasoning blocks in _extract_json for reasoning models
- Fix _annotate_moved_lines short-circuit so standalone relative timestamps are always annotated
- Add comprehensive unit tests in test_response_parser and test_prompt_builder
Add select to the element-priority list in the mousemove handler so a
<select> wrapped in a similarly sized <div> resolves to the select instead
of the div. The process_selected() branch for a select already exists but
was unreachable because the select never became the selected element.
Fixes#3440.
Co-authored-by: Justin Willhite <5132924+thejdubb02@users.noreply.github.com>