mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-09-28 00:06:06 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e3ad7f45e |
@@ -52,19 +52,6 @@ jobs:
|
||||
git diff --stat changedetectionio/translations
|
||||
exit 1
|
||||
fi
|
||||
- name: Check translation overlay
|
||||
# Deliberately after extract_messages above, so overrides are validated against a freshly
|
||||
# extracted messages.pot. An overlay entry is keyed on the exact upstream msgid: when a
|
||||
# string is reworded upstream the override stops matching and silently reverts to upstream
|
||||
# wording. This is the only thing that makes that visible.
|
||||
# See changedetectionio/translations_overlay/README.md
|
||||
if: hashFiles('changedetectionio/translations_overlay/**/*.po') != ''
|
||||
run: |
|
||||
find changedetectionio/translations_overlay -name "*.po" | while read f; do
|
||||
echo "Checking $f"
|
||||
msgfmt --check-format -o /dev/null "$f"
|
||||
done
|
||||
python changedetectionio/translations_overlay/manage.py check
|
||||
|
||||
lint-template-i18n:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -116,6 +103,15 @@ jobs:
|
||||
sys.exit(1)
|
||||
PYEOF
|
||||
|
||||
test-application-3-10:
|
||||
# Only run on push to master (including PR merges)
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||
needs: [lint-code, lint-translations, lint-template-i18n]
|
||||
uses: ./.github/workflows/test-stack-reusable-workflow.yml
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
|
||||
test-application-3-11:
|
||||
# Always run
|
||||
needs: [lint-code, lint-translations, lint-template-i18n]
|
||||
|
||||
@@ -62,18 +62,6 @@ jobs:
|
||||
echo "---- Built for Python ${{ env.PYTHON_VERSION }} -----"
|
||||
docker run test-changedetectionio bash -c 'pip list'
|
||||
|
||||
- name: License compliance - no strong copyleft in shipped image
|
||||
run: |
|
||||
# Runs against the built image, i.e. exactly what ships, with the entrypoint
|
||||
# bypassed so EXTRA_PACKAGES cannot pull in the opt-in AGPL osint plugin.
|
||||
# --partial-match is required: plain --fail-on is an exact string match and
|
||||
# would silently miss declarations like "GPL-2.0-or-later".
|
||||
docker run --rm --entrypoint /bin/bash test-changedetectionio -c '
|
||||
pip install --quiet pip-licenses &&
|
||||
pip-licenses --partial-match \
|
||||
--fail-on="GPL-2.0;GPL-3.0;AGPL;GNU General Public License;GNU Affero General Public License"
|
||||
'
|
||||
|
||||
- name: We should be Python ${{ env.PYTHON_VERSION }} ...
|
||||
run: |
|
||||
docker run test-changedetectionio bash -c 'python3 --version'
|
||||
@@ -213,20 +201,10 @@ jobs:
|
||||
|
||||
- name: Playwright - Specific tests in built container
|
||||
run: |
|
||||
# Deliberately fail fast - the first failure is nearly always the real problem and it
|
||||
# keeps the run short. Each file is wrapped in its own log group and the failing one is
|
||||
# named explicitly, because everything after it is SKIPPED rather than run, and that is
|
||||
# otherwise easy to misread as "the whole browser suite broke".
|
||||
for t in tests/fetchers/test_content.py tests/test_errorhandling.py tests/visualselector/test_fetch_data.py tests/fetchers/test_custom_js_before_content.py tests/fetchers/test_renavigation.py; do
|
||||
echo "::group::pytest $t"
|
||||
if ! docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio \
|
||||
bash -c "cd changedetectionio;pytest -vv --capture=tee-sys --showlocals --tb=long --live-server-host=0.0.0.0 --live-server-port=5004 $t"; then
|
||||
echo "::endgroup::"
|
||||
echo "::error::$t FAILED - stopping here. Any later test file and the remaining steps of this job were SKIPPED, not failed."
|
||||
exit 1
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
done
|
||||
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest -vv --capture=tee-sys --showlocals --tb=long --live-server-host=0.0.0.0 --live-server-port=5004 tests/fetchers/test_content.py'
|
||||
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest -vv --capture=tee-sys --showlocals --tb=long --live-server-host=0.0.0.0 --live-server-port=5004 tests/test_errorhandling.py'
|
||||
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest -vv --capture=tee-sys --showlocals --tb=long --live-server-host=0.0.0.0 --live-server-port=5004 tests/visualselector/test_fetch_data.py'
|
||||
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest -vv --capture=tee-sys --showlocals --tb=long --live-server-host=0.0.0.0 --live-server-port=5004 tests/fetchers/test_custom_js_before_content.py'
|
||||
|
||||
- name: Playwright - Headers and requests
|
||||
run: |
|
||||
@@ -264,17 +242,10 @@ jobs:
|
||||
|
||||
- name: Pyppeteer - Specific tests in built container
|
||||
run: |
|
||||
# Fail fast, but name the file that failed - see the note in the playwright job above
|
||||
for t in tests/fetchers/test_content.py tests/test_errorhandling.py tests/visualselector/test_fetch_data.py tests/fetchers/test_custom_js_before_content.py tests/fetchers/test_renavigation.py; do
|
||||
echo "::group::pytest $t"
|
||||
if ! docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio \
|
||||
bash -c "cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 $t"; then
|
||||
echo "::endgroup::"
|
||||
echo "::error::$t FAILED - stopping here. Any later test file and the remaining steps of this job were SKIPPED, not failed."
|
||||
exit 1
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
done
|
||||
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/fetchers/test_content.py'
|
||||
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/test_errorhandling.py'
|
||||
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/visualselector/test_fetch_data.py'
|
||||
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/fetchers/test_custom_js_before_content.py'
|
||||
|
||||
- name: Pyppeteer - Headers and requests checks
|
||||
run: |
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Minimum supported version
|
||||
target-version = "py311"
|
||||
target-version = "py310"
|
||||
|
||||
# Formatting options
|
||||
line-length = 100
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Generally
|
||||
|
||||
In any commercial activity involving 'Hosting' (as defined herein), whether in part or in full, this license must be executed and adhered to.
|
||||
|
||||
# Commercial License Agreement
|
||||
|
||||
This Commercial License Agreement ("Agreement") is entered into by and between Web Technologies s.r.o. here-in ("Licensor") and (your company or personal name) _____________ ("Licensee"). This Agreement sets forth the terms and conditions under which Licensor provides its software ("Software") and services to Licensee for the purpose of reselling the software either in part or full, as part of any commercial activity where the activity involves a third party.
|
||||
|
||||
### Definition of Hosting
|
||||
|
||||
For the purposes of this Agreement, "hosting" means making the functionality of the Program or modified version available to third parties as a service. This includes, without limitation:
|
||||
- Enabling third parties to interact with the functionality of the Program or modified version remotely through a computer network.
|
||||
- Offering a service the value of which entirely or primarily derives from the value of the Program or modified version.
|
||||
- Offering a service that accomplishes for users the primary purpose of the Program or modified version.
|
||||
|
||||
## 1. Grant of License
|
||||
Subject to the terms and conditions of this Agreement, Licensor grants Licensee a non-exclusive, non-transferable license to install, use, and resell the Software. Licensee may:
|
||||
- Resell the Software as part of a service offering or as a standalone product.
|
||||
- Host the Software on a server and provide it as a hosted service (e.g., Software as a Service - SaaS).
|
||||
- Integrate the Software into a larger product or service that is then sold or provided for commercial purposes, where the software is used either in part or full.
|
||||
|
||||
## 2. License Fees
|
||||
Licensee agrees to pay Licensor the license fees specified in the ordering document. License fees are due and payable as specified in the ordering document. The fees may include initial licensing costs and recurring fees based on the number of end users, instances of the Software resold, or revenue generated from the resale activities.
|
||||
|
||||
## 3. Resale Conditions
|
||||
Licensee must comply with the following conditions when reselling the Software, whether the software is resold in part or full:
|
||||
- Provide end users with access to the source code under the same open-source license conditions as provided by Licensor.
|
||||
- Clearly state in all marketing and sales materials that the Software is provided under a commercial license from Licensor, and provide a link back to https://changedetection.io.
|
||||
- Ensure end users are aware of and agree to the terms of the commercial license prior to resale.
|
||||
- Do not sublicense or transfer the Software to third parties except as part of an authorized resale activity.
|
||||
|
||||
## 4. Hosting and Provision of Services
|
||||
Licensee may host the Software (either in part or full) on its servers and provide it as a hosted service to end users. The following conditions apply:
|
||||
- Licensee must ensure that all hosted versions of the Software comply with the terms of this Agreement.
|
||||
- Licensee must provide Licensor with regular reports detailing the number of end users and instances of the hosted service.
|
||||
- Any modifications to the Software made by Licensee for hosting purposes must be made available to end users under the same open-source license conditions, unless agreed otherwise.
|
||||
|
||||
## 5. Services
|
||||
Licensor will provide support and maintenance services as described in the support policy referenced in the ordering document should such an agreement be signed by all parties. Additional fees may apply for support services provided to end users resold by Licensee.
|
||||
|
||||
## 6. Reporting and Audits
|
||||
Licensee agrees to provide Licensor with regular reports detailing the number of instances, end users, and revenue generated from the resale of the Software. Licensor reserves the right to audit Licensee’s records to ensure compliance with this Agreement.
|
||||
|
||||
## 7. Term and Termination
|
||||
This Agreement shall commence on the effective date and continue for the period set forth in the ordering document unless terminated earlier in accordance with this Agreement. Either party may terminate this Agreement if the other party breaches any material term and fails to cure such breach within thirty (30) days after receipt of written notice.
|
||||
|
||||
## 8. Limitation of Liability and Disclaimer of Warranty
|
||||
Executing this commercial license does not waive the Limitation of Liability or Disclaimer of Warranty as stated in the open-source LICENSE provided with the Software. The Software is provided "as is," without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, and noninfringement. In no event shall the authors or copyright holders be liable for any claim, damages, or other liability, whether in an action of contract, tort, or otherwise, arising from, out of, or in connection with the Software or the use or other dealings in the Software.
|
||||
|
||||
## 9. Governing Law
|
||||
This Agreement shall be governed by and construed in accordance with the laws of the Czech Republic.
|
||||
|
||||
## Contact Information
|
||||
For commercial licensing inquiries, please contact contact@changedetection.io and dgtlmoon@gmail.com.
|
||||
@@ -9,10 +9,3 @@ Install the development and test dependencies with `pip install -r requirements-
|
||||
Please be sure that all new functionality has a matching test!
|
||||
|
||||
Use `pytest` to validate/test, you can run the existing tests as `pytest tests/test_notification.py` for example
|
||||
|
||||
### New dependencies
|
||||
|
||||
Please check the licence before adding anything to `requirements.txt`. MIT, BSD,
|
||||
Apache-2.0, LGPL and MPL are fine; GPL and AGPL are not, since they would
|
||||
relicense the whole project. CI enforces this. There is usually a permissive
|
||||
equivalent - ask in the PR if you're not sure.
|
||||
|
||||
-25
@@ -101,31 +101,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libxrender-dev \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Actually generate the locales. Installing the `locales` package above only
|
||||
# ships /etc/locale.gen - it does not build any locale, so the image had just
|
||||
# C, C.utf8 and POSIX. That made the `ENV LC_ALL=en_US.UTF-8` below unsatisfiable:
|
||||
# locale.setlocale() in flask_app.py failed, fell back to C, and the
|
||||
# format_number_locale / format_int_locale Jinja filters silently lost their
|
||||
# thousands separators - 1234567.89 rendered as "1234567.89" rather than
|
||||
# "1,234,567.89" in the restock/price overview, which is the very thing the
|
||||
# `locales` package was added for.
|
||||
#
|
||||
# More than en_US is generated so that operators can override LC_ALL / LANG and
|
||||
# get formatting for their own region (de_DE gives 1.234.567,89, fr_FR gives
|
||||
# 1 234 567,89). Costs ~21MB and ~16s of build time.
|
||||
#
|
||||
# This list mirrors the UI translations in changedetectionio/translations - one
|
||||
# glibc locale per language we ship a translation for, so any language a user
|
||||
# can pick in the UI also has a working locale. Keep the two in sync when adding
|
||||
# a translation. The territory for each bare language code comes from CLDR's
|
||||
# likely-subtags (cs -> cs_CZ, ja -> ja_JP, ko -> ko_KR, uk -> uk_UA, zh ->
|
||||
# zh_CN, zh_Hant_TW -> zh_TW), NOT from uppercasing the language code.
|
||||
RUN for l in cs_CZ de_DE en_GB en_US es_ES fr_FR id_ID it_IT ja_JP ko_KR \
|
||||
pl_PL pt_BR ru_RU tr_TR uk_UA zh_CN zh_TW; do \
|
||||
sed -i "s/^# *${l}.UTF-8 UTF-8/${l}.UTF-8 UTF-8/" /etc/locale.gen; \
|
||||
done \
|
||||
&& locale-gen
|
||||
|
||||
|
||||
# https://stackoverflow.com/questions/58701233/docker-logs-erroneously-appears-empty-until-container-stops
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
@@ -186,7 +186,7 @@
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright (c) Leigh Morresi and the changedetection.io contributors
|
||||
Copyright 2025 Web Technologies s.r.o.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
||||
@@ -13,7 +13,6 @@ recursive-include changedetectionio/store *
|
||||
recursive-include changedetectionio/templates *
|
||||
recursive-include changedetectionio/tests *
|
||||
recursive-include changedetectionio/translations *
|
||||
recursive-include changedetectionio/translations_overlay *
|
||||
recursive-include changedetectionio/widgets *
|
||||
prune changedetectionio/static/package-lock.json
|
||||
prune changedetectionio/static/styles/node_modules
|
||||
|
||||
@@ -66,11 +66,6 @@ Easily organise and monitor prices for products from the dashboard, get alerts a
|
||||
|
||||
[<img src="docs/restock-overview.png" style="max-width:100%;" alt="Easily keep an eye on product price changes directly from the UI" title="Easily keep an eye on product price changes directly from the UI" />](https://changedetection.io?src=github)
|
||||
|
||||
|
||||
#### Monitor price change and restock trends over time as a graph, identify patterns, buy at the right time!
|
||||
|
||||
[<img src="docs/price-tracking.jpeg" style="max-width:100%;" />](https://changedetection.io?src=github)
|
||||
|
||||
Set price change notification parameters, upper and lower price, price change percentage and more.
|
||||
Always know when a product for sale drops in price.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
|
||||
# Semver means never use .01, or 00. Should be .1.
|
||||
__version__ = '0.60.7'
|
||||
__version__ = '0.55.8'
|
||||
|
||||
from changedetectionio.strtobool import strtobool
|
||||
from json.decoder import JSONDecodeError
|
||||
|
||||
@@ -194,15 +194,6 @@ class Import(Resource):
|
||||
|
||||
urls_to_import.append(url)
|
||||
|
||||
# PAGE_WATCH_LIMIT - refuse the whole batch rather than importing an arbitrary prefix of
|
||||
# it, so a 429 always means "nothing was created" and the caller can retry as-is
|
||||
watch_limit = self.datastore.watch_limit
|
||||
if watch_limit is not None:
|
||||
current_watch_count = len(self.datastore.data['watching'])
|
||||
if current_watch_count + len(urls_to_import) > watch_limit:
|
||||
return (f"Watch limit reached ({current_watch_count}/{watch_limit} watches), importing "
|
||||
f"{len(urls_to_import)} URL(s) would exceed it. No watches were imported.", 429)
|
||||
|
||||
# For small imports, process synchronously for immediate feedback
|
||||
if len(urls_to_import) < IMPORT_SWITCH_TO_BACKGROUND_THRESHOLD:
|
||||
added = []
|
||||
|
||||
@@ -18,42 +18,6 @@ from ..notification import valid_notification_formats
|
||||
from ..notification.handler import newline_re
|
||||
|
||||
|
||||
def validate_time_schedule_limit(json_data):
|
||||
"""
|
||||
Validate the optional timezone inside time_schedule_limit.
|
||||
|
||||
The edit form runs validateTimeZoneName on this field, but the API did not -
|
||||
the OpenAPI schema for time_schedule_limit does not declare a `timezone`
|
||||
property at all and does not set additionalProperties:false, so any string
|
||||
passed straight through and was stored.
|
||||
|
||||
That matters because the scheduler resolves it with arrow.now(tz), which
|
||||
raises on an unknown zone. Before the ticker thread was hardened, a single
|
||||
authenticated PUT with a bogus timezone stopped scheduling for EVERY watch
|
||||
on the instance. It is now contained to the one watch, but that watch would
|
||||
still silently never be checked again, so reject it at the boundary.
|
||||
|
||||
Returns None if valid, or an error message string if invalid.
|
||||
"""
|
||||
schedule = json_data.get('time_schedule_limit')
|
||||
if not isinstance(schedule, dict):
|
||||
return None
|
||||
|
||||
tz_name = schedule.get('timezone')
|
||||
if not tz_name:
|
||||
return None
|
||||
|
||||
if not isinstance(tz_name, str):
|
||||
return "time_schedule_limit.timezone must be a string IANA timezone name, e.g. 'Europe/Berlin'."
|
||||
|
||||
from zoneinfo import available_timezones
|
||||
if tz_name.strip() not in available_timezones():
|
||||
return (f"time_schedule_limit.timezone '{tz_name}' is not a valid timezone name. "
|
||||
f"Use an IANA name such as 'Europe/Berlin' or 'UTC'.")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def validate_time_between_check_required(json_data):
|
||||
"""
|
||||
Validate that at least one time interval is specified when not using default settings.
|
||||
@@ -141,7 +105,6 @@ class Watch(Resource):
|
||||
watch['last_changed'] = watch_obj.last_changed
|
||||
watch['viewed'] = watch_obj.viewed
|
||||
watch['link'] = watch_obj.link
|
||||
watch['open_link'] = watch_obj.open_link
|
||||
|
||||
# Resolved processor config: tag override wins over watch-level config (mirrors restock processor logic)
|
||||
import json
|
||||
@@ -196,11 +159,6 @@ class Watch(Resource):
|
||||
if validation_error:
|
||||
return validation_error, 400
|
||||
|
||||
# An invalid timezone here makes the watch permanently unschedulable
|
||||
validation_error = validate_time_schedule_limit(request.json)
|
||||
if validation_error:
|
||||
return validation_error, 400
|
||||
|
||||
# Validate notification_urls if provided
|
||||
if 'notification_urls' in request.json:
|
||||
from wtforms import ValidationError
|
||||
@@ -294,20 +252,6 @@ class WatchHistory(Resource):
|
||||
abort(404, message='No watch exists with the UUID of {}'.format(uuid))
|
||||
return watch.history, 200
|
||||
|
||||
# Delete all history/snapshots for a watch, but keep the watch itself
|
||||
# curl -X DELETE http://localhost:5000/api/v1/watch/<uuid_str:uuid>/history
|
||||
@auth.check_token
|
||||
@validate_openapi_request('deleteWatchHistory')
|
||||
def delete(self, uuid):
|
||||
"""Clear all snapshot history for a watch (the watch itself is kept)."""
|
||||
if not self.datastore.data['watching'].get(uuid):
|
||||
abort(404, message='No watch exists with the UUID of {}'.format(uuid))
|
||||
|
||||
# Same call as the UI "Clear history" button - wipes snapshots/screenshots and
|
||||
# resets last_checked etc, while preserving the watch and its processor config
|
||||
self.datastore.clear_watch_history(uuid)
|
||||
return 'OK', 204
|
||||
|
||||
|
||||
class WatchSingleHistory(Resource):
|
||||
def __init__(self, **kwargs):
|
||||
@@ -544,11 +488,6 @@ class CreateWatch(Resource):
|
||||
if validation_error:
|
||||
return validation_error, 400
|
||||
|
||||
# An invalid timezone here makes the watch permanently unschedulable
|
||||
validation_error = validate_time_schedule_limit(json_data)
|
||||
if validation_error:
|
||||
return validation_error, 400
|
||||
|
||||
# Validate notification_urls if provided
|
||||
if 'notification_urls' in json_data:
|
||||
from wtforms import ValidationError
|
||||
@@ -575,12 +514,6 @@ class CreateWatch(Resource):
|
||||
|
||||
del extras['url']
|
||||
|
||||
# PAGE_WATCH_LIMIT - checked up front so a blocked add is reported as 429 rather than
|
||||
# being guessed at from add_watch() returning None
|
||||
if self.datastore.watch_limit_reached():
|
||||
current_watch_count = len(self.datastore.data['watching'])
|
||||
return f"Watch limit reached ({current_watch_count}/{self.datastore.watch_limit} watches). Cannot add more watches.", 429
|
||||
|
||||
new_uuid = self.datastore.add_watch(url=url, extras=extras, tag=tags)
|
||||
|
||||
# Save processor config to separate JSON file
|
||||
@@ -591,6 +524,16 @@ class CreateWatch(Resource):
|
||||
# worker_pool.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': new_uuid}))
|
||||
return {'uuid': new_uuid}, 201
|
||||
else:
|
||||
# Check if it was a limit issue
|
||||
page_watch_limit = os.getenv('PAGE_WATCH_LIMIT')
|
||||
if page_watch_limit:
|
||||
try:
|
||||
page_watch_limit = int(page_watch_limit)
|
||||
current_watch_count = len(self.datastore.data['watching'])
|
||||
if current_watch_count >= page_watch_limit:
|
||||
return f"Watch limit reached ({current_watch_count}/{page_watch_limit} watches). Cannot add more watches.", 429
|
||||
except ValueError:
|
||||
pass
|
||||
return "Invalid or unsupported URL", 400
|
||||
|
||||
@auth.check_token
|
||||
@@ -611,7 +554,6 @@ class CreateWatch(Resource):
|
||||
'last_checked': watch['last_checked'],
|
||||
'last_error': watch['last_error'],
|
||||
'link': watch.link,
|
||||
'open_link': watch.open_link,
|
||||
'page_title': watch['page_title'],
|
||||
'tags': [*tags], # Unpack dict keys to list (can't use list() since variable named 'list')
|
||||
'title': watch['title'],
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import os
|
||||
|
||||
from flask import Blueprint, render_template, request, jsonify, make_response, flash, redirect, url_for
|
||||
from flask_babel import gettext
|
||||
from flask import Blueprint, render_template, request, jsonify, make_response
|
||||
from loguru import logger
|
||||
|
||||
from changedetectionio import forms, strtobool
|
||||
from changedetectionio import forms
|
||||
from changedetectionio.auth_decorator import login_optionally_required
|
||||
from . import browser_config
|
||||
from changedetectionio.store import ChangeDetectionStore
|
||||
from changedetectionio.validate_url import is_fetch_url_allowed
|
||||
|
||||
@@ -20,32 +16,17 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
from changedetectionio.llm.evaluator import get_llm_config as _get_llm_config
|
||||
from changedetectionio.llm.ui_strings import LLM_INTENT_WATCH_PLACEHOLDER
|
||||
|
||||
# Same gate that hides the sidebar link (sidebar-nav.html): with no browser that can
|
||||
# render a preview there is nothing for the visual selector to work on, so bounce
|
||||
# direct navigation rather than serving a page whose only outcome is an error.
|
||||
if not browser_config.has_visual_browser(datastore):
|
||||
flash(gettext("Adding a watch with a browser needs an interactive browser "
|
||||
"(screenshots + element data) - none is configured."), 'error')
|
||||
return redirect(url_for('watchlist.index'))
|
||||
|
||||
form = forms.quickWatchForm(None)
|
||||
llm_configured = bool(_get_llm_config(datastore))
|
||||
|
||||
# Start on the browser the live preview would actually use
|
||||
form.fetch_backend.data = browser_config.default_visual_browser(datastore)
|
||||
|
||||
return render_template(
|
||||
"add-watch-ui.html",
|
||||
browser_backend_disabled=strtobool(os.getenv('ADD_WATCH_UI_BROWSER_BACKEND_DISABLED', 'False')),
|
||||
form=form,
|
||||
llm_configured=llm_configured,
|
||||
llm_intent_watch_placeholder=LLM_INTENT_WATCH_PLACEHOLDER,
|
||||
# Listed but not selectable (the system default when it can't render a preview)
|
||||
unusable_browsers=browser_config.unusable_values(datastore),
|
||||
system_default_browser=browser_config.system_default_description(datastore),
|
||||
)
|
||||
|
||||
@add_watch_ui_blueprint.route("/snapshot", methods=['POST'])
|
||||
@add_watch_ui_blueprint.route("/snapshot", methods=['GET'])
|
||||
@login_optionally_required
|
||||
def add_watch_ui_snapshot():
|
||||
"""One-shot live fetch of an arbitrary URL for the Add Watch visual selector.
|
||||
@@ -55,10 +36,6 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
connect, "Goto site", grab the screenshot + xpath element data, then tear
|
||||
the browser down again. Element selection then happens client-side on the
|
||||
returned data, exactly like the watch Edit page's visual selector.
|
||||
|
||||
POST-only and CSRF protected on purpose: this drives a real browser fetch and
|
||||
writes a temporary watch dir, so as a GET it could be triggered cross-origin
|
||||
(or by any tag/link that issues a GET) without the operator's consent.
|
||||
"""
|
||||
import base64
|
||||
from changedetectionio.blueprint.browser_steps import (
|
||||
@@ -78,36 +55,21 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
# backslash/parser-differential rejection of GHSA-rph4-96w6-q594 (GHSA-56fq-63vj-9992).
|
||||
# Note this fetch never reaches difference_detection_processor.call_browser(), so it gets
|
||||
# no gating from there - it has to validate for itself.
|
||||
url = (request.form.get('url') or '').strip()
|
||||
if not url.lower().startswith('http'):
|
||||
url='https://' + url
|
||||
|
||||
url = (request.args.get('url') or '').strip()
|
||||
ok, reason = is_fetch_url_allowed(url)
|
||||
if not ok:
|
||||
logger.warning(f"Add-watch snapshot: refused '{url}' - {reason}")
|
||||
return make_response(reason, 400)
|
||||
|
||||
# Which browser to preview with. The page posts the one picked in its browser list;
|
||||
# with nothing asked for we fall back to whatever it would have preselected.
|
||||
# Either way it has to be able to render a preview - the plain HTTP client
|
||||
# produces no screenshot and no element data, so previewing with it is pointless
|
||||
# (and it used to be the silent default here, see the system-default bug).
|
||||
fetcher_name = (request.form.get('fetch_backend') or '').strip() or browser_config.default_visual_browser(datastore)
|
||||
if not fetcher_name or not browser_config.is_visual_capable(fetcher_name, datastore):
|
||||
logger.warning(f"Add-watch snapshot: refused browser '{fetcher_name}' for '{url}'")
|
||||
return make_response('No interactive browser available that can render a live preview '
|
||||
'(needs screenshots + element data)', 400)
|
||||
|
||||
# acquire_browser_for_fetcher() looks the name up as a fetcher class, so 'system'
|
||||
# has to be collapsed to the real backend first or a fetcher that launches its own
|
||||
# browser would be skipped in favour of the CDP endpoint.
|
||||
resolved_fetcher = browser_config.resolve_backend(fetcher_name, datastore)
|
||||
logger.debug(f"Add-watch snapshot: fetching '{url}' using '{fetcher_name}' (resolved: '{resolved_fetcher}')")
|
||||
# Use whatever fetcher the application is configured to use by default
|
||||
# (e.g. CloakBrowser, Playwright/sockpuppet) so the preview matches real checks.
|
||||
fetcher_name = datastore.data['settings']['application'].get('fetch_backend', 'html_requests')
|
||||
logger.debug(f"Add-watch snapshot: fetching '{url}' using system default fetcher '{fetcher_name}'")
|
||||
|
||||
async def _fetch_snapshot():
|
||||
keepalive_ms = 30 * 1000
|
||||
browser, playwright_context = await acquire_browser_for_fetcher(
|
||||
resolved_fetcher, proxy=None, keepalive_ms=keepalive_ms
|
||||
fetcher_name, proxy=None, keepalive_ms=keepalive_ms
|
||||
)
|
||||
|
||||
stepper = browsersteps_live_ui(playwright_browser=browser, proxy=None, start_url=url)
|
||||
@@ -157,13 +119,6 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
with open(os.path.join(temp_dir, "preload-fetch.json"), 'w', encoding='utf-8') as f:
|
||||
json.dump({"content": html, "status_code": 200,
|
||||
"headers": {"content-type": "text/html"}}, f)
|
||||
# This directory becomes the new watch's data_dir on submit, so the watch's own
|
||||
# settings file is where the previewing browser belongs: record it here and the
|
||||
# saved watch checks with the browser that actually rendered this snapshot,
|
||||
# instead of falling back to a system default that may not even do screenshots.
|
||||
# Read back by make_temporary_watch_active_watch(); commit() then rewrites it in full.
|
||||
with open(os.path.join(temp_dir, "watch.json"), 'w', encoding='utf-8') as f:
|
||||
json.dump({"fetch_backend": fetcher_name}, f)
|
||||
except Exception as e:
|
||||
logger.error(f"Add-watch snapshot: could not park temporary data for {url}: {e}")
|
||||
temp_uuid = None
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
"""Which content fetchers can drive the Add-Watch live preview / visual selector.
|
||||
|
||||
Single source of truth for "can this browser render a live preview": the Add-Watch
|
||||
browser list, the /snapshot endpoint and the submit-time form validator all resolve
|
||||
through here, so the UI can never offer - and the server can never accept - a fetcher
|
||||
that is unable to produce what the visual selector needs.
|
||||
|
||||
The preview is rendered by browsersteps_live_ui() (see blueprint/browser_steps), so a
|
||||
usable browser needs `supports_browser_steps` *as well as* screenshots and xpath
|
||||
element data. That third flag is what rules out Selenium/WebDriver: it can screenshot
|
||||
during a normal check, but it cannot drive the interactive session the preview needs
|
||||
(acquire_browser_for_fetcher() would quietly connect to PLAYWRIGHT_DRIVER_URL instead,
|
||||
which is not the browser the user picked). It also rules out settings.requests
|
||||
extra_browsers, which are WebDriver connection URLs, so they are not offered here.
|
||||
"""
|
||||
|
||||
from loguru import logger
|
||||
|
||||
SYSTEM_DEFAULT = 'system'
|
||||
|
||||
# Every flag a fetcher must set to be usable for the Add-Watch live preview.
|
||||
REQUIRED_CAPABILITIES = (
|
||||
'supports_browser_steps',
|
||||
'supports_screenshots',
|
||||
'supports_xpath_element_data',
|
||||
)
|
||||
|
||||
|
||||
def is_visual_capable(fetch_backend, datastore):
|
||||
"""True when this backend can render the Add-Watch live preview.
|
||||
|
||||
An unknown name - including anything a client made up - resolves to no fetcher
|
||||
class and so to all-False capabilities, so it can never pass. That is what makes
|
||||
the posted value safe without any string filtering of our own.
|
||||
|
||||
Reads the flags off the class the same way Watch.fetcher_supports_screenshots does,
|
||||
rather than via pluggy_interface.get_fetcher_capabilities(): that logs every lookup
|
||||
at INFO, and this runs for the whole fetcher list on any page carrying the quick-add
|
||||
form. Plugin fetchers are registered as module attributes, so they resolve here too.
|
||||
"""
|
||||
from changedetectionio import content_fetchers
|
||||
from changedetectionio.content_fetchers.base import FetcherCapabilities
|
||||
|
||||
name = fetch_backend or SYSTEM_DEFAULT
|
||||
if name == SYSTEM_DEFAULT:
|
||||
name = datastore.data['settings']['application'].get('fetch_backend') or 'html_requests'
|
||||
|
||||
caps = FetcherCapabilities.from_fetcher(getattr(content_fetchers, name, None))
|
||||
missing = [flag for flag in REQUIRED_CAPABILITIES if not getattr(caps, flag, False)]
|
||||
logger.debug(f"Add-watch browser '{fetch_backend or SYSTEM_DEFAULT}' -> '{name}': "
|
||||
f"{', '.join(f'{k}={v}' for k, v in caps.model_dump().items())} - "
|
||||
f"{'usable for live preview' if not missing else 'not offered, missing ' + ', '.join(missing)}")
|
||||
return not missing
|
||||
|
||||
|
||||
def resolve_backend(fetch_backend, datastore):
|
||||
"""The concrete fetcher name behind a choice, so 'system' can be acted on.
|
||||
|
||||
Needed because acquire_browser_for_fetcher() looks the name up as a class: handing
|
||||
it 'system' would skip a fetcher that launches its own browser (CloakBrowser) and
|
||||
fall through to the CDP endpoint instead.
|
||||
"""
|
||||
from changedetectionio import content_fetchers
|
||||
|
||||
_fetcher_class, resolved, _custom_url = content_fetchers.resolve_content_fetcher(
|
||||
{'fetch_backend': fetch_backend or SYSTEM_DEFAULT}, datastore)
|
||||
return resolved
|
||||
|
||||
|
||||
def list_visual_browser_choices(datastore):
|
||||
"""(value, label) for every fetcher that can drive the visual selector.
|
||||
|
||||
is_visual_capable() logs each candidate's capabilities as it goes, so a user
|
||||
wondering why their browser isn't in the list can see the missing flag at debug
|
||||
level.
|
||||
"""
|
||||
from changedetectionio import content_fetchers
|
||||
|
||||
choices = [(name, str(description)) for name, description in content_fetchers.available_fetchers()
|
||||
if is_visual_capable(name, datastore)]
|
||||
logger.debug(f"Add-watch browsers offered for the live preview: "
|
||||
f"{[name for name, _label in choices] or 'none'}")
|
||||
return choices
|
||||
|
||||
|
||||
def has_visual_browser(datastore):
|
||||
"""True when at least one installed browser can render a live preview.
|
||||
|
||||
Gates the whole Add-Watch page (sidebar link + the route itself): without one there
|
||||
is nothing for the visual selector to work on, so the page can only fail.
|
||||
"""
|
||||
return bool(list_visual_browser_choices(datastore))
|
||||
|
||||
|
||||
def default_visual_browser(datastore):
|
||||
"""Which browser the Add-Watch page should start on.
|
||||
|
||||
'system' when the global default happens to be capable (so the new watch keeps
|
||||
following the system setting), otherwise the first capable browser, and None when
|
||||
there is nothing usable at all.
|
||||
"""
|
||||
if is_visual_capable(SYSTEM_DEFAULT, datastore):
|
||||
return SYSTEM_DEFAULT
|
||||
choices = list_visual_browser_choices(datastore)
|
||||
return choices[0][0] if choices else None
|
||||
|
||||
|
||||
def system_default_description(datastore):
|
||||
"""Label for whatever backend 'system' currently points at."""
|
||||
from changedetectionio import content_fetchers
|
||||
|
||||
system_backend = datastore.data['settings']['application'].get('fetch_backend') or 'html_requests'
|
||||
return str(dict(content_fetchers.available_fetchers()).get(system_backend, system_backend))
|
||||
|
||||
|
||||
def radio_choices(datastore):
|
||||
"""(value, label) for the Add-Watch browser radio list, system default first.
|
||||
|
||||
'System settings default' is always listed - with the reason in its own label when
|
||||
the global default cannot render a preview, because greying it out explains why it
|
||||
is unavailable where silently dropping it does not. Which entries are unusable is
|
||||
reported separately by unusable_values(); WTForms cannot carry per-option render_kw
|
||||
through a RadioField, so the disabled attribute is applied when rendering.
|
||||
"""
|
||||
from flask_babel import gettext
|
||||
|
||||
described = system_default_description(datastore)
|
||||
|
||||
if is_visual_capable(SYSTEM_DEFAULT, datastore):
|
||||
system_label = gettext('System settings default (%(browser)s)', browser=described)
|
||||
else:
|
||||
system_label = gettext('System settings default (%(browser)s - no live preview)', browser=described)
|
||||
|
||||
return [(SYSTEM_DEFAULT, system_label)] + list_visual_browser_choices(datastore)
|
||||
|
||||
|
||||
def unusable_values(datastore):
|
||||
"""Values that are listed for explanation only and must render disabled."""
|
||||
return set() if is_visual_capable(SYSTEM_DEFAULT, datastore) else {SYSTEM_DEFAULT}
|
||||
@@ -14,12 +14,6 @@ $(document).ready(() => {
|
||||
const $includeFilters = $('#include_filters');
|
||||
const $temporaryUuid = $('#temporary_uuid');
|
||||
|
||||
// When the LLM intent box isn't available (LLM_FEATURES_DISABLED / not configured) the
|
||||
// template renders no "Select by element" checkbox - element selection is the only thing
|
||||
// this page can do, so it is on from the start and can't be turned off.
|
||||
const selectionAlwaysOn = $byElement.length === 0;
|
||||
const selectionEnabled = () => selectionAlwaysOn || $byElement.is(':checked');
|
||||
|
||||
const vs = window.initVisualSelector({
|
||||
$canvas: $('#selector-canvas'),
|
||||
$includeFilters: $includeFilters,
|
||||
@@ -28,7 +22,7 @@ $(document).ready(() => {
|
||||
$fetchingNotice: $('#add-watch-spinner .fetching-update-notice'),
|
||||
$wrapper: $wrapper,
|
||||
$clearButton: $clear,
|
||||
enableSelection: selectionAlwaysOn, // otherwise off until the user opts into "Select by element"
|
||||
enableSelection: false, // off until the user opts into "Select by element"
|
||||
processorIsImage: false,
|
||||
// The snapshot comes from the live browser-steps capture, so scale X by the page
|
||||
// CSS width (browser_width) like browser-steps.js - handles device-scale-factor != 1.
|
||||
@@ -42,8 +36,8 @@ $(document).ready(() => {
|
||||
$error.toggle(which === 'error');
|
||||
const ready = which === 'ready';
|
||||
$wrapper.toggle(ready);
|
||||
$xpathRow.toggle(ready && selectionEnabled());
|
||||
$clear.toggle(ready && selectionEnabled());
|
||||
$xpathRow.toggle(ready && $byElement.is(':checked'));
|
||||
$clear.toggle(ready && $byElement.is(':checked'));
|
||||
}
|
||||
|
||||
function fetchSnapshot() {
|
||||
@@ -59,18 +53,7 @@ $(document).ready(() => {
|
||||
|
||||
$.ajax({
|
||||
url: add_watch_snapshot_url,
|
||||
// POST, never GET - this makes the server-side browser fetch a URL of our
|
||||
// choosing, so it must not be triggerable cross-origin. csrf.js adds the
|
||||
// X-CSRFToken header to every non-GET ajax call; the CSRF field on the form
|
||||
// is sent too so it works even if that handler hasn't run yet.
|
||||
method: 'POST',
|
||||
// Preview with the browser picked in the list - that same browser is what
|
||||
// gets saved on the watch, so what you see here is what it will check with.
|
||||
data: {
|
||||
url: url,
|
||||
fetch_backend: $('input[name="fetch_backend"]:checked').val() || '',
|
||||
csrf_token: $('#new-watch-form input[name="csrf_token"]').val() || '',
|
||||
},
|
||||
data: {url: url},
|
||||
dataType: 'json',
|
||||
}).done((data) => {
|
||||
showState('ready');
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
<!-- TOP : enter the URL and fetch a live preview -->
|
||||
<div id="add-watch-url-row">
|
||||
{{ render_nolabel_field(form.url, placeholder="www.website.com/page.html", required=true, class="pure-input-1") }}
|
||||
{{ render_nolabel_field(form.url, placeholder="https://...", required=true, class="pure-input-1") }}
|
||||
<button type="button" id="add-watch-go" class="pure-button pure-button-primary">{{ _('Go') }}</button>
|
||||
</div>
|
||||
|
||||
@@ -51,33 +51,14 @@
|
||||
<!-- RIGHT : options -->
|
||||
<div id="add-watch-options-pane">
|
||||
<div class="add-watch-option-group" id="quick-watch-processor-type">
|
||||
{{ render_simple_field(form.processor, label=_('What do you want to achieve?')) }}
|
||||
{{ render_simple_field(form.processor) }}
|
||||
</div>
|
||||
{%- if not browser_backend_disabled -%}
|
||||
<div class="add-watch-option-group" id="quick-watch-fetch-backend">
|
||||
{# Rendered by hand rather than with render_field(): the system-default entry
|
||||
is listed for explanation and has to render disabled, and WTForms can't
|
||||
carry a per-option disabled attribute through a RadioField's choices. #}
|
||||
<label>{{ _('Select a browser') }}</label>
|
||||
<ul>
|
||||
{%- for browser in form.fetch_backend -%}
|
||||
{%- set unusable = browser.data in unusable_browsers -%}
|
||||
<li{% if unusable %} class="unusable" title="{{ _('%(browser)s cannot render a live preview, choose a browser below', browser=system_default_browser) }}"{% endif %}>
|
||||
{{ browser(disabled=true) if unusable else browser() }}{{ browser.label }}
|
||||
</li>
|
||||
{%- endfor -%}
|
||||
</ul>
|
||||
</div>
|
||||
{%- endif -%}
|
||||
|
||||
<div class="add-watch-option-group" id="by-element-toggle-group">
|
||||
{%- if llm_configured -%}
|
||||
<label class="pure-checkbox" for="by-element-toggle">
|
||||
<input type="checkbox" id="by-element-toggle"> {{ _('Select by element') }}
|
||||
</label>
|
||||
{%- endif -%}
|
||||
{# No LLM intent available? Then narrowing by element is the only thing this
|
||||
page can do - selection is always on, so nothing is labelled or offered
|
||||
here, just the hint on how to use the preview. #}
|
||||
<span class="pure-form-message-inline">{{ _('Hover & click the preview to watch just one part of the page.') }}</span>
|
||||
<a id="clear-selector" class="pure-button button-secondary button-xsmall" style="display: none;">{{ _('Clear selection') }}</a>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -60,13 +60,13 @@ def create_backup(datastore_path, watches: dict, tags: dict = None):
|
||||
|
||||
# Create a list file with just the URLs, so it's easier to port somewhere else in the future
|
||||
list_file = "url-list.txt"
|
||||
with open(os.path.join(datastore_path, list_file), "w", encoding="utf-8") as f:
|
||||
with open(os.path.join(datastore_path, list_file), "w") as f:
|
||||
for uuid in watches:
|
||||
url = watches[uuid]["url"]
|
||||
f.write("{}\r\n".format(url))
|
||||
list_with_tags_file = "url-list-with-tags.txt"
|
||||
with open(
|
||||
os.path.join(datastore_path, list_with_tags_file), "w", encoding="utf-8"
|
||||
os.path.join(datastore_path, list_with_tags_file), "w"
|
||||
) as f:
|
||||
for uuid in watches:
|
||||
url = watches[uuid].get('url')
|
||||
@@ -98,7 +98,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
backups_blueprint.register_blueprint(construct_restore_blueprint(datastore))
|
||||
backup_threads = []
|
||||
|
||||
@backups_blueprint.route("/request-backup", methods=['POST'])
|
||||
@backups_blueprint.route("/request-backup", methods=['GET'])
|
||||
@login_optionally_required
|
||||
def request_backup():
|
||||
if any(thread.is_alive() for thread in backup_threads):
|
||||
|
||||
@@ -35,10 +35,8 @@
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="{{ url_for('backups.request_backup') }}" style="display: inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="pure-button pure-button-primary">{{ _('Create backup') }}</button>
|
||||
</form>
|
||||
<a class="pure-button pure-button-primary"
|
||||
href="{{ url_for('backups.request_backup') }}">{{ _('Create backup') }}</a>
|
||||
{% if available_backups %}
|
||||
{# POST + CSRF token: this permanently deletes every backup archive, so it must
|
||||
not be reachable from a bare GET (an <img src=...> on any page the operator
|
||||
|
||||
@@ -296,7 +296,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
return browsersteps_start_session
|
||||
|
||||
|
||||
@browser_steps_blueprint.route("/browsersteps_start_session", methods=['POST'])
|
||||
@browser_steps_blueprint.route("/browsersteps_start_session", methods=['GET'])
|
||||
@login_optionally_required
|
||||
def browsersteps_start_session():
|
||||
# A new session was requested, return sessionID
|
||||
|
||||
@@ -100,7 +100,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
results = _recalc_check_status(uuid=uuid)
|
||||
return results
|
||||
|
||||
@check_proxies_blueprint.route("/<uuid_str:uuid>/start", methods=['POST'])
|
||||
@check_proxies_blueprint.route("/<uuid_str:uuid>/start", methods=['GET'])
|
||||
@login_optionally_required
|
||||
def start_check(uuid):
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ class Importer():
|
||||
self.good = 0
|
||||
self.remaining_data = []
|
||||
self.import_profile = None
|
||||
self.limit_reported = False
|
||||
|
||||
@abstractmethod
|
||||
def run(self,
|
||||
@@ -26,21 +25,6 @@ class Importer():
|
||||
datastore):
|
||||
pass
|
||||
|
||||
def watch_limit_hit(self, datastore, flash):
|
||||
"""True once PAGE_WATCH_LIMIT leaves no room - the caller must then stop importing.
|
||||
|
||||
Asked before each add_watch() so the limit is reported once for the whole file.
|
||||
add_watch() would otherwise flash the same error again for every remaining row.
|
||||
"""
|
||||
if not datastore.watch_limit_reached():
|
||||
return False
|
||||
|
||||
if not self.limit_reported:
|
||||
self.limit_reported = True
|
||||
flash(datastore.watch_limit_message(), 'error')
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class import_url_list(Importer):
|
||||
"""
|
||||
@@ -60,7 +44,7 @@ class import_url_list(Importer):
|
||||
if (len(urls) > 5000):
|
||||
flash(gettext("Importing 5,000 of the first URLs from your list, the rest can be imported again."))
|
||||
|
||||
for idx, url in enumerate(urls):
|
||||
for url in urls:
|
||||
url = url.strip()
|
||||
if not len(url):
|
||||
continue
|
||||
@@ -75,12 +59,6 @@ class import_url_list(Importer):
|
||||
# Up to 5000 per batch so we dont flood the server
|
||||
# @todo validators.url will fail when you add your own IP etc
|
||||
if len(url) and 'http' in url.lower() and good < 5000:
|
||||
if self.watch_limit_hit(datastore, flash):
|
||||
# Hand back every line we didn't get to (originals, tags included) so they
|
||||
# land in the textarea to retry once there's room
|
||||
self.remaining_data.extend(u.strip() for u in urls[idx:] if u.strip())
|
||||
break
|
||||
|
||||
extras = None
|
||||
if processor:
|
||||
extras = {'processor': processor}
|
||||
@@ -129,9 +107,6 @@ class import_distill_io_json(Importer):
|
||||
extras = {'title': d.get('name', None)}
|
||||
|
||||
if len(d['uri']) and good < 5000:
|
||||
if self.watch_limit_hit(datastore, flash):
|
||||
break
|
||||
|
||||
try:
|
||||
# @todo we only support CSS ones at the moment
|
||||
if d_config['selections'][0]['frames'][0]['excludes'][0]['type'] == 'css':
|
||||
@@ -225,9 +200,6 @@ class import_xlsx_wachete(Importer):
|
||||
# Don't bother processing anything else on this row
|
||||
continue
|
||||
|
||||
if self.watch_limit_hit(datastore, flash):
|
||||
break
|
||||
|
||||
new_uuid = datastore.add_watch(url=data['url'].strip(),
|
||||
extras=extras,
|
||||
tag=data.get('folder'),
|
||||
@@ -309,9 +281,6 @@ class import_xlsx_custom(Importer):
|
||||
|
||||
# At minimum a URL is required.
|
||||
if url:
|
||||
if self.watch_limit_hit(datastore, flash):
|
||||
break
|
||||
|
||||
new_uuid = datastore.add_watch(url=url,
|
||||
extras=extras,
|
||||
tag=tags,
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
"""Left-rail (action sidebar) display modes.
|
||||
|
||||
A leaf module on purpose: forms.py, flask_app.py and model/App.py all need these, and
|
||||
model/App.py seeding its default from forms.py would drag the whole form stack (~550
|
||||
modules) into the model layer.
|
||||
"""
|
||||
from flask_babel import lazy_gettext as _l
|
||||
|
||||
# The complete set of left-rail modes - flask_app.get_sidebar_mode_class() maps these
|
||||
# (and only these) onto body classes, so a new mode here needs a new mapping there.
|
||||
MENU_SIDEBAR_ACTIONMODES = [
|
||||
('expandable', _l('Expand on hover')), # Slim icon rail that expands on hover/focus
|
||||
('pinned-expanded', _l('Always expanded')), # Always expanded, never collapses
|
||||
('minimal', _l('Stays minimal')), # Always small, never expands
|
||||
]
|
||||
MENU_SIDEBAR_ACTIONMODES_DEFAULT = 'expandable'
|
||||
@@ -14,7 +14,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q: PriorityQueue
|
||||
|
||||
price_data_follower_blueprint = Blueprint('price_data_follower', __name__)
|
||||
|
||||
@price_data_follower_blueprint.route("/<uuid_str:uuid>/accept", methods=['POST'])
|
||||
@price_data_follower_blueprint.route("/<uuid_str:uuid>/accept", methods=['GET'])
|
||||
@login_optionally_required
|
||||
def accept(uuid):
|
||||
datastore.data['watching'][uuid]['track_ldjson_price_data'] = PRICE_DATA_TRACK_ACCEPT
|
||||
@@ -24,7 +24,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q: PriorityQueue
|
||||
worker_pool.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
|
||||
return redirect(url_for("watchlist.index"))
|
||||
|
||||
@price_data_follower_blueprint.route("/<uuid_str:uuid>/reject", methods=['POST'])
|
||||
@price_data_follower_blueprint.route("/<uuid_str:uuid>/reject", methods=['GET'])
|
||||
@login_optionally_required
|
||||
def reject(uuid):
|
||||
datastore.data['watching'][uuid]['track_ldjson_price_data'] = PRICE_DATA_TRACK_REJECT
|
||||
|
||||
@@ -24,4 +24,4 @@ RSS_TEMPLATE_PLAINTEXT_DEFAULT = "<pre>{{watch_label}} had a change.\n\n{{diff}}
|
||||
|
||||
# @todo add some [edit]/[history]/[goto] etc links
|
||||
# @todo need {{watch_edit_link}} + delete + history link token
|
||||
RSS_TEMPLATE_HTML_DEFAULT = "<html><body>\n<h4><a href=\"{{watch_open_url}}\">{{watch_label}}</a></h4>\n<p>{{diff}}</p>\n</body></html>\n"
|
||||
RSS_TEMPLATE_HTML_DEFAULT = "<html><body>\n<h4><a href=\"{{watch_url}}\">{{watch_label}}</a></h4>\n<p>{{diff}}</p>\n</body></html>\n"
|
||||
|
||||
@@ -105,7 +105,7 @@ def construct_single_watch_routes(rss_blueprint, datastore):
|
||||
fe = fg.add_entry()
|
||||
title_suffix = f"Change @ {res['original_context']['change_datetime']}"
|
||||
populate_feed_entry(fe, watch, res.get('body', ''), guid, timestamp_to,
|
||||
link={'href': watch.open_link_override or watch.get('url')}, title_suffix=title_suffix)
|
||||
link={'href': watch.get('url')}, title_suffix=title_suffix)
|
||||
add_watch_categories(fe, watch, datastore)
|
||||
|
||||
response = make_response(fg.rss_str())
|
||||
|
||||
@@ -261,8 +261,6 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
llm_show_costs=llm_show_costs,
|
||||
python_version=python_version,
|
||||
uptime_seconds=uptime_seconds,
|
||||
# None unless PAGE_WATCH_LIMIT is set, which hides the row entirely
|
||||
watch_limit=datastore.watch_limit,
|
||||
available_timezones=sorted(available_timezones()),
|
||||
emailprefix=os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False),
|
||||
extra_notification_token_placeholder_info=datastore.get_unique_notification_token_placeholders_available(),
|
||||
@@ -278,7 +276,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
|
||||
return output
|
||||
|
||||
@settings_blueprint.route("/reset-api-key", methods=['POST'])
|
||||
@settings_blueprint.route("/reset-api-key", methods=['GET'])
|
||||
@login_optionally_required
|
||||
def settings_reset_api_key():
|
||||
secret = secrets.token_hex(16)
|
||||
@@ -295,7 +293,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
logs=notification_debug_log if len(notification_debug_log) else ["Notification logs are empty - no notifications sent yet."])
|
||||
return output
|
||||
|
||||
@settings_blueprint.route("/toggle-all-paused", methods=['POST'])
|
||||
@settings_blueprint.route("/toggle-all-paused", methods=['GET'])
|
||||
@login_optionally_required
|
||||
def toggle_all_paused():
|
||||
current_state = datastore.data['settings']['application'].get('all_paused', False)
|
||||
@@ -309,7 +307,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
|
||||
return redirect(url_for('watchlist.index'))
|
||||
|
||||
@settings_blueprint.route("/toggle-all-muted", methods=['POST'])
|
||||
@settings_blueprint.route("/toggle-all-muted", methods=['GET'])
|
||||
@login_optionally_required
|
||||
def toggle_all_muted():
|
||||
current_state = datastore.data['settings']['application'].get('all_muted', False)
|
||||
|
||||
@@ -130,7 +130,7 @@ def construct_llm_blueprint(datastore: ChangeDetectionStore):
|
||||
logger.exception("LLM model list full traceback:")
|
||||
return jsonify({'models': [], 'error': str(e)}), 400
|
||||
|
||||
@llm_blueprint.route("/test", methods=['POST'])
|
||||
@llm_blueprint.route("/test", methods=['GET'])
|
||||
@login_optionally_required
|
||||
def llm_test():
|
||||
from flask import request
|
||||
|
||||
@@ -208,7 +208,7 @@ nav
|
||||
</div>
|
||||
</div>
|
||||
<div class="pure-control-group">
|
||||
<button type="submit" formmethod="post" formnovalidate formaction="{{url_for('settings.settings_reset_api_key')}}" class="pure-button button-small button-cancel">{{ _('Regenerate API key') }}</button>
|
||||
<a href="{{url_for('settings.settings_reset_api_key')}}" class="pure-button button-small button-cancel">{{ _('Regenerate API key') }}</a>
|
||||
</div>
|
||||
<div class="pure-control-group">
|
||||
<h4>{{ _('Chrome Extension') }}</h4>
|
||||
@@ -281,16 +281,11 @@ nav
|
||||
</div>
|
||||
<div class="pure-control-group">
|
||||
{{ render_checkbox_field(form.application.form.ui.form.favicons_enabled, class="") }}
|
||||
<span class="pure-form-message-inline">{{ _('Enable or Disable Favicons next to the watch list') }}</span><br>
|
||||
<span class="pure-form-message-inline">{{ _('Disabling this can speed up your page fetches because the favicon does not need to be fetched.') }}</span>
|
||||
<span class="pure-form-message-inline">{{ _('Enable or Disable Favicons next to the watch list') }}</span>
|
||||
</div>
|
||||
<div class="pure-control-group">
|
||||
{{ render_checkbox_field(form.application.form.ui.use_page_title_in_list) }}
|
||||
</div>
|
||||
<div class="pure-control-group">
|
||||
{{ render_checkbox_field(form.application.form.ui.use_share_watch) }}
|
||||
<span class="pure-form-message-inline">{{ _('This copies your watch to a temporary hosted database and returns a link to make sharing/copying the watch to other people') }}</span>
|
||||
</div>
|
||||
<div class="pure-control-group">
|
||||
{{ render_field(form.application.form.pager_size) }}
|
||||
<span class="pure-form-message-inline">{{ _('Number of items per page in the watch overview list, 0 to disable.') }}</span>
|
||||
@@ -396,9 +391,6 @@ nav
|
||||
<div class="tab-pane-inner" id="info">
|
||||
<p><strong>{{ _('Uptime:') }}</strong> {{ uptime_seconds|format_duration }}</p>
|
||||
<p><strong>{{ _('Python version:') }}</strong> {{ python_version }}</p>
|
||||
{% if watch_limit is not none %}
|
||||
<p><strong>{{ _('Maximum number of page watches:') }}</strong> {{ watch_limit }}</p>
|
||||
{% endif %}
|
||||
<p><strong>{{ _('Plugins active:') }}</strong></p>
|
||||
{% if active_plugins %}
|
||||
<ul>
|
||||
|
||||
@@ -577,10 +577,7 @@
|
||||
if (mult.trim()) params.set('local_token_multiplier', mult.trim());
|
||||
|
||||
try {
|
||||
const resp = await fetch('{{ url_for("settings.llm.llm_test") }}?' + params, {
|
||||
method: 'POST',
|
||||
headers: {'X-CSRFToken': csrftoken}
|
||||
});
|
||||
const resp = await fetch('{{ url_for("settings.llm.llm_test") }}?' + params);
|
||||
const data = await resp.json();
|
||||
if (data.ok) {
|
||||
result.style.cssText = 'display:block; background:rgba(39,174,96,0.08); border:1px solid rgba(39,174,96,0.3); border-radius:5px; padding:0.6em 0.85em; font-size:0.88em; line-height:1.45;';
|
||||
|
||||
@@ -62,7 +62,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
|
||||
return redirect(url_for('tags.tags_overview_page'))
|
||||
|
||||
@tags_blueprint.route("/mute/<uuid_str:uuid>", methods=['POST'])
|
||||
@tags_blueprint.route("/mute/<uuid_str:uuid>", methods=['GET'])
|
||||
@login_optionally_required
|
||||
def mute(uuid):
|
||||
tag = datastore.data['settings']['application']['tags'].get(uuid)
|
||||
@@ -189,9 +189,6 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
'watch': default,
|
||||
'extra_notification_token_placeholder_info': datastore.get_unique_notification_token_placeholders_available(),
|
||||
'llm_configured': bool(_get_llm_config(datastore)),
|
||||
# Tells the shared AI include it is rendering a group, not a watch — `watch` above
|
||||
# is this tag, so it cannot be used to tell the two apart.
|
||||
'llm_group_edit': True,
|
||||
}
|
||||
|
||||
included_content = {}
|
||||
|
||||
@@ -10,7 +10,6 @@ from wtforms.fields.simple import BooleanField
|
||||
from flask_babel import lazy_gettext as _l
|
||||
|
||||
from changedetectionio.blueprint.tags.colour import CSS_HEX_COLOUR_REGEX
|
||||
from changedetectionio.widgets.ternary_boolean import TernaryNoneBooleanField
|
||||
from changedetectionio.processors.restock_diff.forms import processor_settings_form as restock_settings_form
|
||||
from changedetectionio.llm.ui_strings import LLM_INTENT_TAG_PLACEHOLDER
|
||||
from changedetectionio.llm.evaluator import (
|
||||
@@ -29,22 +28,7 @@ class group_restock_settings_form(restock_settings_form):
|
||||
validators=[validators.Optional(),
|
||||
validators.Regexp(CSS_HEX_COLOUR_REGEX,
|
||||
message=_l('Must be a hex colour, for example #4f8ef7'))])
|
||||
# The group's one and only AI control. Same key as the per-watch switch (see forms.py) but
|
||||
# ternary, because a group has a third useful answer: "no opinion, leave it to each watch"
|
||||
# (the default). See tag_llm_decision() for the semantics of each state — #4204.
|
||||
# @NOTE! In the near future this stops being a ternary bool and becomes a *profile*
|
||||
# selector — pick one of the configured LLM profiles, or 'off', or inherit. The
|
||||
# field name is already the future one; only the widget and the True/False/None
|
||||
# value space need to change, so keep reads going through tag_llm_decision().
|
||||
llm_backend_profile = TernaryNoneBooleanField(
|
||||
_l('AI for watches in this group'),
|
||||
default=None,
|
||||
yes_text=_l('On, use the settings below'),
|
||||
no_text=_l('Off for every watch'),
|
||||
none_text=_l('Leave it to each watch'),
|
||||
)
|
||||
|
||||
llm_intent = TextAreaField('AI Change Intent - Notify me when..',
|
||||
llm_intent = TextAreaField('AI Change Intent',
|
||||
validators=[validators.Optional(), validators.Length(max=2000)],
|
||||
render_kw={"rows": "5", "placeholder": LLM_INTENT_TAG_PLACEHOLDER})
|
||||
|
||||
@@ -54,7 +38,7 @@ class group_restock_settings_form(restock_settings_form):
|
||||
default='')
|
||||
|
||||
llm_change_summary_mode = RadioField(
|
||||
_l('Change Summary prompt - Append or Replace the default?'),
|
||||
_l('How this prompt combines with the inherited one'),
|
||||
choices=[
|
||||
(LLM_PROMPT_MODE_REPLACE, _l('Replace the inherited prompt')),
|
||||
(LLM_PROMPT_MODE_APPEND, _l('Append to the inherited prompt')),
|
||||
|
||||
@@ -27,9 +27,9 @@
|
||||
<div class="tabs collapsable">
|
||||
<ul>
|
||||
<li class="tab" id=""><a href="#general">{{ _('General') }}</a></li>
|
||||
{# Always shown, like the watch edit page: with no provider configured the pane
|
||||
explains how to set one up (and carries the AI switches through on save). #}
|
||||
{% if llm_configured %}
|
||||
<li class="tab"><a href="#ai-llm">{{ _('AI / LLM') }}</a></li>
|
||||
{% endif %}
|
||||
<li class="tab"><a href="#filters-and-triggers">{{ _('Filters & Triggers') }}</a></li>
|
||||
{% if extra_tab_content %}
|
||||
<li class="tab"><a href="#extras_tab">{{ extra_tab_content }}</a></li>
|
||||
@@ -92,9 +92,11 @@
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
{% if llm_configured %}
|
||||
<div class="tab-pane-inner" id="ai-llm">
|
||||
{% include "edit/include_llm_intent.html" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="tab-pane-inner" id="filters-and-triggers">
|
||||
{# TRANSLATORS: CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303 #}
|
||||
|
||||
@@ -64,18 +64,20 @@ html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} {
|
||||
<td colspan="3">{{ _('No website organisational tags/groups configured') }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<form method="POST" style="display: inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
{% for uuid, tag in available_tags %}
|
||||
{#-{{ loop.cycle('pure-table-odd', 'pure-table-even') }}-#}
|
||||
<tr id="{{ uuid }}" class="">
|
||||
<td class="watch-controls">
|
||||
<button formaction="{{url_for('tags.mute', uuid=tag.uuid)}}" type="submit" class="bare-btn link-mute state-{{'on' if tag.notification_muted else 'off'}}" aria-label="{{ _('Mute notifications') }}" title="{{ _('Mute notifications') }}"><i data-feather="{{ 'bell-off' if tag.notification_muted else 'bell' }}" class="icon icon-mute"></i></button>
|
||||
<a class="link-mute state-{{'on' if tag.notification_muted else 'off'}}" href="{{url_for('tags.mute', uuid=tag.uuid)}}" aria-label="{{ _('Mute notifications') }}" title="{{ _('Mute notifications') }}"><i data-feather="{{ 'bell-off' if tag.notification_muted else 'bell' }}" class="icon icon-mute"></i></a>
|
||||
</td>
|
||||
<td class="watch-count">{{ "{:,}".format(tag_count[uuid]) if uuid in tag_count else 0 }}</td>
|
||||
<td class="title-col inline"> <a href="{{url_for('watchlist.index', tag=uuid) }}" class="watch-tag-list tag-{{ tag.title|sanitize_tag_class }}">{{ tag.title }}</a></td>
|
||||
<td>
|
||||
<button formaction="{{ url_for('ui.form_watch_checknow', tag=uuid) }}" type="submit" class="cdio-btn"><i data-feather="refresh-cw"></i>{{ _('Recheck') }}</button>
|
||||
<a class="cdio-btn" href="{{ url_for('tags.form_tag_edit', uuid=uuid) }}">{{ _('Edit') }}</a>
|
||||
<form method="POST" action="{{ url_for('ui.form_watch_checknow', tag=uuid) }}" style="display: inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="cdio-btn"><i data-feather="refresh-cw"></i>{{ _('Recheck') }}</button>
|
||||
</form>
|
||||
<a class="cdio-btn cdio-btn--danger"
|
||||
href="{{ url_for('tags.delete', uuid=uuid) }}"
|
||||
data-method="POST"
|
||||
@@ -98,7 +100,6 @@ html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} {
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</form>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import time
|
||||
import threading
|
||||
from blinker import signal
|
||||
from flask import Blueprint, request, redirect, url_for, flash, render_template, session, current_app, abort
|
||||
from flask import Blueprint, request, redirect, url_for, flash, render_template, session, current_app
|
||||
from flask_babel import gettext
|
||||
from loguru import logger
|
||||
|
||||
@@ -293,9 +293,6 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_pool,
|
||||
uuid = list(datastore.data['watching'].keys()).pop()
|
||||
|
||||
new_uuid = datastore.clone(uuid)
|
||||
if not new_uuid:
|
||||
# Refused (e.g. PAGE_WATCH_LIMIT) - the reason is already flashed
|
||||
return redirect(url_for('watchlist.index'))
|
||||
|
||||
if not datastore.data['watching'].get(uuid).get('paused'):
|
||||
worker_pool.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=5, item={'uuid': new_uuid}))
|
||||
@@ -406,12 +403,10 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_pool,
|
||||
|
||||
return redirect(url_for('watchlist.index'))
|
||||
|
||||
@ui_blueprint.route("/share-url/<uuid_str:uuid>", methods=['POST'])
|
||||
|
||||
@ui_blueprint.route("/share-url/<uuid_str:uuid>", methods=['GET'])
|
||||
@login_optionally_required
|
||||
def form_share_put_watch(uuid):
|
||||
if not datastore.data['settings']['application']['ui'].get('use_share_watch'):
|
||||
abort(403, description="Access denied")
|
||||
|
||||
"""Given a watch UUID, upload the info and return a share-link
|
||||
the share-link can be imported/added"""
|
||||
import requests
|
||||
@@ -457,7 +452,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_pool,
|
||||
|
||||
return redirect(url_for('watchlist.index'))
|
||||
|
||||
@ui_blueprint.route("/language/auto-detect", methods=['POST'])
|
||||
@ui_blueprint.route("/language/auto-detect", methods=['GET'])
|
||||
def delete_locale_language_session_var_if_it_exists():
|
||||
"""Clear the session locale preference to auto-detect from browser Accept-Language header"""
|
||||
if 'locale' in session:
|
||||
|
||||
@@ -8,57 +8,30 @@ from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from changedetectionio.store import ChangeDetectionStore
|
||||
from changedetectionio.auth_decorator import login_optionally_required
|
||||
from changedetectionio.time_handler import default_timezone_name, is_within_schedule
|
||||
from changedetectionio.time_handler import is_within_schedule
|
||||
from changedetectionio import worker_pool
|
||||
from changedetectionio.llm.evaluator import get_llm_config as _get_llm_config
|
||||
|
||||
def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData):
|
||||
edit_blueprint = Blueprint('ui_edit', __name__, template_folder="../ui/templates")
|
||||
|
||||
def _watch_tags(watch):
|
||||
"""(uuid, tag) for this watch's tags, in its own tag order, skipping UUIDs we don't know."""
|
||||
tags = datastore.data['settings']['application'].get('tags', {})
|
||||
return [(tag_uuid, tags[tag_uuid]) for tag_uuid in watch.get('tags', []) if tag_uuid in tags]
|
||||
|
||||
def _resolve_llm_group_overrides(watch, datastore) -> dict:
|
||||
"""
|
||||
For each LLM field (llm_intent, llm_change_summary): if the watch has no own
|
||||
value but a linked group does, return {'value': ..., 'group_name': ..., 'group_uuid': ...}
|
||||
so the edit template can show the inherited value as the textarea placeholder and link
|
||||
back to the group that supplied it.
|
||||
Returns None for each field when the watch has its own value (nothing inherited).
|
||||
|
||||
Only groups whose AI setting is "On" lend their prompts — the same gate the evaluator
|
||||
applies via resolve_llm_field(), so the placeholder always reflects what will actually
|
||||
run. See llm/evaluator.py:tag_llm_decision().
|
||||
value but a linked tag does, return {'value': ..., 'group_name': ...} so the
|
||||
edit template can render the textarea as readonly with a group-sourced placeholder.
|
||||
Returns None for each field when the watch has its own value (editable).
|
||||
"""
|
||||
from changedetectionio.llm.evaluator import tag_llm_applies_to_watches, tag_llm_decision
|
||||
|
||||
result = {'llm_intent': None, 'llm_change_summary': None, 'llm_backend_profile': None}
|
||||
|
||||
# AI on/off is not a "fill in the blank" field: a group that has taken the decision
|
||||
# (On or Off, i.e. not "leave it to each watch") decides for every watch in it (#4204),
|
||||
# so report it and let the template show the watch's own checkbox as overridden.
|
||||
for tag_uuid, tag in _watch_tags(watch):
|
||||
if tag_llm_decision(tag) is not None:
|
||||
result['llm_backend_profile'] = {
|
||||
'value': tag_llm_decision(tag),
|
||||
'group_name': tag.get('title', 'tag'),
|
||||
'group_uuid': tag_uuid,
|
||||
}
|
||||
break
|
||||
|
||||
result = {'llm_intent': None, 'llm_change_summary': None}
|
||||
for field in ('llm_intent', 'llm_change_summary'):
|
||||
if (watch.get(field) or '').strip():
|
||||
continue # watch has its own value — editable, no group override
|
||||
for tag_uuid, tag in _watch_tags(watch):
|
||||
if not tag_llm_applies_to_watches(tag):
|
||||
continue
|
||||
if (tag.get(field) or '').strip():
|
||||
for tag_uuid in watch.get('tags', []):
|
||||
tag = datastore.data['settings']['application'].get('tags', {}).get(tag_uuid)
|
||||
if tag and (tag.get(field) or '').strip():
|
||||
result[field] = {
|
||||
'value': tag.get(field).strip(),
|
||||
'group_name': tag.get('title', 'tag'),
|
||||
'group_uuid': tag_uuid,
|
||||
}
|
||||
break
|
||||
return result
|
||||
@@ -238,16 +211,6 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
||||
extra_update_obj['filter_text_replaced'] = True
|
||||
extra_update_obj['filter_text_removed'] = True
|
||||
|
||||
# A group that has taken the AI on/off decision owns that control, so the edit page
|
||||
# renders it disabled (see include_llm_intent.html). A disabled checkbox isn't
|
||||
# submitted at all, and for a checkbox "not submitted" is indistinguishable from
|
||||
# "unticked" — so don't take this field from the form while a group decides. The
|
||||
# watch keeps its own preference untouched, ready for when the group stops deciding.
|
||||
# Resolved against the watch's *stored* tags — i.e. what the page was rendered from,
|
||||
# so attaching or detaching a group in this same save is still honoured correctly.
|
||||
if _resolve_llm_group_overrides(datastore.data['watching'][uuid], datastore).get('llm_backend_profile'):
|
||||
extra_update_obj['llm_backend_profile'] = datastore.data['watching'][uuid].get('llm_backend_profile', True)
|
||||
|
||||
# Because wtforms doesn't support accessing other data in process_ , but we convert the CSV list of tags back to a list of UUIDs
|
||||
tag_uuids = []
|
||||
if form.data.get('tags'):
|
||||
@@ -290,14 +253,13 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
||||
watch = datastore.data['watching'].get(uuid)
|
||||
|
||||
if watch.get('time_between_check_use_default'):
|
||||
time_schedule_limit = datastore.data['settings']['requests'].get('time_schedule_limit') or {}
|
||||
time_schedule_limit = datastore.data['settings']['requests'].get('time_schedule_limit', {})
|
||||
else:
|
||||
time_schedule_limit = watch.get('time_schedule_limit') or {}
|
||||
time_schedule_limit = watch.get('time_schedule_limit')
|
||||
|
||||
tz_name = default_timezone_name(
|
||||
time_schedule_limit.get('timezone')
|
||||
or datastore.data['settings']['application'].get('scheduler_timezone_default')
|
||||
)
|
||||
tz_name = time_schedule_limit.get('timezone')
|
||||
if not tz_name:
|
||||
tz_name = datastore.data['settings']['application'].get('scheduler_timezone_default', os.getenv('TZ', 'UTC').strip())
|
||||
|
||||
if time_schedule_limit and time_schedule_limit.get('enabled'):
|
||||
try:
|
||||
@@ -305,12 +267,9 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
||||
default_tz=tz_name
|
||||
)
|
||||
except Exception as e:
|
||||
# Only decides whether to queue an immediate recheck — the watch is
|
||||
# already saved by this point. Returning a bare `False` from a view
|
||||
# made Flask raise TypeError and the save appeared to fail with a 500.
|
||||
logger.error(
|
||||
f"{uuid} - Recheck scheduler, error handling timezone, check skipped - TZ name '{tz_name}' - {str(e)}")
|
||||
is_in_schedule = False
|
||||
return False
|
||||
|
||||
#############################
|
||||
if not datastore.data['watching'][uuid].get('paused') and is_in_schedule:
|
||||
@@ -510,22 +469,15 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
||||
|
||||
uuid = request.args.get('uuid','')
|
||||
if datastore.data["watching"].get(uuid):
|
||||
# Build the new list and REBIND it. Appending in place bypasses
|
||||
# watch_base.__setitem__, so the watch is never flagged as edited and
|
||||
# the "content unchanged since last check" skip stays active — the new
|
||||
# ignore_text would then not take effect until the page changed on its
|
||||
# own. Assigning the key marks the watch edited and forces reprocessing.
|
||||
ignore_text = list(datastore.data["watching"][uuid]['ignore_text'])
|
||||
if mode == 'exact':
|
||||
for l in selection.splitlines():
|
||||
ignore_text.append(l.strip())
|
||||
datastore.data["watching"][uuid]['ignore_text'].append(l.strip())
|
||||
elif mode == 'digit-regex':
|
||||
for l in selection.splitlines():
|
||||
# Replace any series of numbers with a regex
|
||||
s = re.escape(l.strip())
|
||||
s = re.sub(r'[0-9]+', r'\\d+', s)
|
||||
ignore_text.append('/' + s + '/')
|
||||
datastore.data["watching"][uuid]['ignore_text'] = ignore_text
|
||||
datastore.data["watching"][uuid]['ignore_text'].append('/' + s + '/')
|
||||
|
||||
# Save the updated ignore_text
|
||||
datastore.data["watching"][uuid].commit()
|
||||
|
||||
@@ -70,8 +70,6 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
# use the same as when it is triggered, but then override it with the form test values
|
||||
n_object = NotificationContextData({
|
||||
'watch_url': request.form.get('window_url', "https://changedetection.io"),
|
||||
# Falls back to watch_url when the watch has no 'Link to Open' set
|
||||
'watch_open_url': watch.open_link_override or request.form.get('window_url', "https://changedetection.io"),
|
||||
'notification_urls': notification_urls
|
||||
})
|
||||
|
||||
|
||||
@@ -104,8 +104,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
output = render_template("preview.html",
|
||||
capabilities=capabilities,
|
||||
content=content,
|
||||
current_diff_label=watch.label,
|
||||
current_diff_url=watch.open_link,
|
||||
current_diff_url=watch['url'],
|
||||
current_version=timestamp,
|
||||
extra_stylesheets=extra_stylesheets,
|
||||
extra_title=f" - {gettext('Diff')} - {watch.label} @ {timestamp}",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
{% endif %}
|
||||
|
||||
const highlight_submit_ignore_url="{{url_for('ui.ui_edit.highlight_submit_ignore_url', uuid=uuid)}}";
|
||||
const watch_url= {{watch_a.open_link|tojson}};
|
||||
const watch_url= {{watch_a.link|tojson}};
|
||||
|
||||
// Initial scroll position: if set, scroll to this line number in #difference on page load
|
||||
const initialScrollToLineNumber = {{ initial_scroll_line_number|default('null') }};
|
||||
@@ -20,7 +20,6 @@
|
||||
<script src="{{url_for('static_content', group='js', filename='diff-overview.js')}}" defer></script>
|
||||
|
||||
|
||||
<div id="diff-header">
|
||||
<div id="settings">
|
||||
<form class="pure-form " action="{{ url_for("ui.ui_diff.diff_history_page", uuid=uuid) }}" method="GET" id="diff-form">
|
||||
<fieldset class="diff-fieldset">
|
||||
@@ -50,10 +49,6 @@
|
||||
{#<button type="submit" class="pure-button pure-button-primary reset-margin">Go</button>#}
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
{# Collapses the diff options below into a popover. Hidden until
|
||||
diff-overview.js takes over the fieldset, so with scripting off the
|
||||
options stay inline and reachable. #}
|
||||
<button type="button" id="diff-filters-toggle" class="pure-button" aria-expanded="false" aria-controls="diff-style">{{ _('Filters') }} ▾</button>
|
||||
<fieldset id="diff-style">
|
||||
<span>
|
||||
<label for="diffWords" class="pure-checkbox">
|
||||
@@ -92,13 +87,9 @@
|
||||
</fieldset>
|
||||
{%- if versions|length >= 2 -%}
|
||||
<div id="keyboard-nav">
|
||||
{# In the sticky bar the prompt and the two words are hidden and
|
||||
only the arrows show, so each link repeats its label as the
|
||||
accessible name and the tooltip - without them a screen reader
|
||||
is left reading the arrow glyph itself. #}
|
||||
<strong>{{ _('Keyboard:') }} </strong>
|
||||
<a href="" class="pure-button pure-button-primary" id="btn-previous" title="{{ _('Previous') }}" aria-label="{{ _('Previous') }}"> ← <span class="keyboard-nav-label">{{ _('Previous') }}</span></a>
|
||||
<a class="pure-button pure-button-primary" id="btn-next" href="" title="{{ _('Next') }}" aria-label="{{ _('Next') }}"> → <span class="keyboard-nav-label">{{ _('Next') }}</span></a>
|
||||
<a href="" class="pure-button pure-button-primary" id="btn-previous"> ← {{ _('Previous') }}</a>
|
||||
<a class="pure-button pure-button-primary" id="btn-next" href=""> → {{ _('Next') }}</a>
|
||||
</div>
|
||||
{%- endif -%}
|
||||
</form>
|
||||
@@ -114,13 +105,11 @@
|
||||
{% if last_error_text %}<li class="tab" id="error-text-tab"><a href="#error-text">{{ _('Error Text') }}</a></li> {% endif %}
|
||||
{% if last_error_screenshot %}<li class="tab" id="error-screenshot-tab"><a href="#error-screenshot">{{ _('Error Screenshot') }}</a></li> {% endif %}
|
||||
<li class="tab" id="text-tab"><a href="#text">{{ _('Text') }}</a></li>
|
||||
<li class="tab" id="screenshot-tab"><a href="#screenshot">{{ _('Screenshot') }}</a></li>
|
||||
<li class="tab" id="screenshot-tab"><a href="#screenshot">{{ _('Current screenshot') }}</a></li>
|
||||
<li class="tab" id="extract-tab"><a href="{{ url_for('ui.ui_diff.diff_history_page_extract_GET', uuid=uuid)}}">{{ _('Extract Data') }}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>{# /diff-header #}
|
||||
|
||||
<div id="diff-ui">
|
||||
<div class="tab-pane-inner" id="error-text">
|
||||
<div class="snapshot-age error">{{watch_a.error_text_ctime|format_seconds_ago}} {{ _('seconds ago.') }}</div>
|
||||
|
||||
@@ -100,23 +100,6 @@
|
||||
{{ render_field(form.title, class="m-d", placeholder=watch.label) }}
|
||||
<span class="pure-form-message-inline">{{ _('Automatically uses the page title if found, you can also use your own title/description here') }}</span>
|
||||
</div>
|
||||
<div class="pure-control-group">
|
||||
<details class="form-wrapper">
|
||||
<summary>{{ _('Extra Page Title and Link options') }}</summary>
|
||||
<br>
|
||||
<div class=" border-fieldset">
|
||||
<div class="pure-control-group">
|
||||
{{ render_field(form.link_to_open, placeholder="https://...", class="m-d") }}
|
||||
<span class="pure-form-message-inline">{{ _('Optional - links in the list, history and notifications open this URL instead. Does not change the URL being checked.') }}</span>
|
||||
</div>
|
||||
<div class="pure-control-group">
|
||||
{{ render_ternary_field(form.use_page_title_in_list) }}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group time-between-check border-fieldset">
|
||||
|
||||
{{ render_checkbox_field(form.time_between_check_use_default, class="use-default-timecheck") }}
|
||||
@@ -136,6 +119,13 @@
|
||||
</div>
|
||||
<br>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
{{ render_checkbox_field(form.filter_failure_notification_send) }}
|
||||
<span class="pure-form-message-inline">
|
||||
{{ _('Sends a notification when the filter can no longer be seen on the page, good for knowing when the page changed and your filter will not work anymore.') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="pure-control-group">
|
||||
{{ render_field(form.history_snapshot_max_length, class="history_snapshot_max_length") }}
|
||||
<span class="pure-form-message-inline">{{ _('Limit collection of history snapshots for each watch to this number of history items.') }}
|
||||
@@ -143,7 +133,9 @@
|
||||
{{ _('Set to empty to use system settings default') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
{{ render_ternary_field(form.use_page_title_in_list) }}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
@@ -258,7 +250,7 @@ Math: {{ 1 + 1 }}") }}
|
||||
<div class="flex-wrapper" >
|
||||
|
||||
<div id="browser-steps-ui" class="noselect">
|
||||
<div class="noselect" id="browsersteps-selector-wrapper">
|
||||
<div class="noselect" id="browsersteps-selector-wrapper" style="width: 100%">
|
||||
<span class="loader" >
|
||||
<span id="browsersteps-click-start">
|
||||
<h2 >{{ _('Click here to Start') }}</h2>
|
||||
@@ -303,12 +295,6 @@ Math: {{ 1 + 1 }}") }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="pure-control-group">
|
||||
{{ render_checkbox_field(form.filter_failure_notification_send) }}
|
||||
<span class="pure-form-message-inline">
|
||||
{{ _('Sends a notification when the filter can no longer be seen on the page, good for knowing when the page changed and your filter will not work anymore.') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="field-group" id="notification-field-group">
|
||||
{% if has_default_notification_urls %}
|
||||
<div class="inline-warning">
|
||||
@@ -521,26 +507,6 @@ Math: {{ 1 + 1 }}") }}
|
||||
<td>{{ _('Server type reply') }}</td>
|
||||
<td>{{ watch.get('remote_server_reply') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>capabilities.supports_xpath_element_data</code></td>
|
||||
<td>{{ _('Yes') if capabilities.supports_xpath_element_data else _('No') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>capabilities.supports_screenshots</code></td>
|
||||
<td>{{ _('Yes') if capabilities.supports_screenshots else _('No') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>capabilities.supports_visual_selector</code></td>
|
||||
<td>{{ _('Yes') if capabilities.supports_visual_selector else _('No') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>supports_browser_steps</code></td>
|
||||
<td>{{ _('Yes') if capabilities.supports_browser_steps else _('No') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>visual_selector_data_ready</code></td>
|
||||
<td>{{ _('Yes') if visual_selector_data_ready else _('No') }}</td>
|
||||
</tr>
|
||||
{% if not llm_features_disabled and settings_application.get('llm', {}).get('model') %}
|
||||
<tr>
|
||||
<td>{{ _('AI tokens (last check)') }}</td>
|
||||
|
||||
@@ -91,13 +91,6 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
||||
processor = request.form.get('processor', processors.get_default_processor())
|
||||
llm_intent = request.form.get('llm_intent', '').strip()
|
||||
extras = {'paused': add_paused, 'processor': processor}
|
||||
|
||||
# Browser picked on the Add Watch page (validated against the live-preview capable
|
||||
# list by the form). Absent from the watch-list quick-add, which leaves the new
|
||||
# watch on the system default as before. A snapshot that recorded its own browser
|
||||
# overrides this inside make_temporary_watch_active_watch().
|
||||
if form.fetch_backend.data:
|
||||
extras['fetch_backend'] = form.fetch_backend.data
|
||||
if llm_intent:
|
||||
extras['llm_intent'] = llm_intent
|
||||
|
||||
|
||||
@@ -18,24 +18,6 @@ from changedetectionio.blueprint.watchlist.row_context import watch_row_context
|
||||
def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData):
|
||||
watchlist_blueprint = Blueprint('watchlist', __name__, template_folder="templates")
|
||||
|
||||
@watchlist_blueprint.route("/toggle", methods=['POST'])
|
||||
@login_optionally_required
|
||||
def toggle():
|
||||
op = request.args.get('op')
|
||||
uuid = request.args.get('uuid')
|
||||
watch = datastore.data['watching'].get(uuid)
|
||||
|
||||
if not watch:
|
||||
flash(_('Watch not found'), 'error')
|
||||
else:
|
||||
if op == 'pause':
|
||||
watch.toggle_pause()
|
||||
elif op == 'mute':
|
||||
watch.toggle_mute()
|
||||
watch.commit()
|
||||
|
||||
return redirect(url_for('watchlist.index', tag=request.args.get('tag')))
|
||||
|
||||
@watchlist_blueprint.route("/", methods=['GET'])
|
||||
@login_optionally_required
|
||||
def index():
|
||||
@@ -54,6 +36,17 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
||||
if request.args.get('rss'):
|
||||
return redirect(url_for('rss.feed', tag=active_tag_uuid))
|
||||
|
||||
op = request.args.get('op')
|
||||
if op:
|
||||
uuid = request.args.get('uuid')
|
||||
if op == 'pause':
|
||||
datastore.data['watching'][uuid].toggle_pause()
|
||||
elif op == 'mute':
|
||||
datastore.data['watching'][uuid].toggle_mute()
|
||||
|
||||
datastore.data['watching'][uuid].commit()
|
||||
return redirect(url_for('watchlist.index', tag = active_tag_uuid))
|
||||
|
||||
# Sort by last_changed and add the uuid which is usually the key..
|
||||
sorted_watches = []
|
||||
active_processor = request.args.get('processor', '').strip()
|
||||
|
||||
@@ -30,10 +30,10 @@ def watch_row_context(datastore, active_tag_uuid=None, queued_uuids=None):
|
||||
|
||||
return {
|
||||
'active_tag_uuid': active_tag_uuid,
|
||||
# Also decides cols_required on the page, so page and row must agree or a pushed row
|
||||
# ends up with a different <td> count than the table header — which is why it is
|
||||
# computed here and not passed separately by the page.
|
||||
'any_has_restock_price_processor': datastore.any_watches_have_processor_by_name("restock_diff"),
|
||||
# Kept 0 (rather than any_watches_have_processor_by_name) while the price column is
|
||||
# disabled — it also decides cols_required on the page, so page and row must agree or
|
||||
# a pushed row ends up with a different <td> count than the table header.
|
||||
'any_has_restock_price_processor': 0,
|
||||
'datastore': datastore,
|
||||
'has_proxies': datastore.proxy_list,
|
||||
'processor_descriptions': processors.get_processor_descriptions(),
|
||||
|
||||
@@ -11,12 +11,8 @@
|
||||
{%- set checking_now = is_checking_now(watch) -%}
|
||||
{%- set history_n = watch.history_n -%}
|
||||
{%- set favicon = watch.get_favicon_filename() -%}
|
||||
{# Resolved once per row: each read re-validates the URL and, for a Jinja2 templated
|
||||
URL, builds a fresh sandboxed environment - see validate_url.py #}
|
||||
{%- set open_link = watch.open_link -%}
|
||||
{%- set error_texts = watch.compile_error_texts(has_proxies=has_proxies) -%}
|
||||
{%- set system_use_url_watchlist = datastore.data['settings']['application']['ui'].get('use_page_title_in_list') -%}
|
||||
{%- set system_use_share = datastore.data['settings']['application']['ui'].get('use_share_watch') -%}
|
||||
{# Class settings mirrored in changedetectionio/static/js/realtime.js for the frontend #}
|
||||
{# loop.cycle('pure-table-odd', 'pure-table-even'),#}
|
||||
{%- set row_classes = [
|
||||
@@ -39,25 +35,18 @@
|
||||
<td class="inline checkbox-uuid" ><div><input name="uuids" type="checkbox" value="{{ watch.uuid}} " >{# <span class="counter-i">{{ loop.index+pagination.skip }}</span>#}</div></td>
|
||||
<td class="inline watch-controls">
|
||||
<div>
|
||||
{%- set pause_action = url_for('watchlist.toggle', op='pause', uuid=watch.uuid, tag=active_tag_uuid) -%}
|
||||
{%- set mute_action = url_for('watchlist.toggle', op='mute', uuid=watch.uuid, tag=active_tag_uuid) -%}
|
||||
<button type="submit" formmethod="post" formaction="{{ pause_action }}" class="bare-btn ajax-op state-off pause-toggle" data-op="pause" aria-label="{{ _('Pause checks') }}" title="{{ _('Pause checks') }}"><i data-feather="pause" class="icon icon-pause"></i></button>
|
||||
<button type="submit" formmethod="post" formaction="{{ pause_action }}" class="bare-btn ajax-op state-on pause-toggle" data-op="pause" style="display: none" aria-label="{{ _('UnPause checks') }}" title="{{ _('UnPause checks') }}"><i data-feather="play" class="icon icon-unpause"></i></button>
|
||||
<button type="submit" formmethod="post" formaction="{{ mute_action }}" class="bare-btn ajax-op state-off mute-toggle" data-op="mute" aria-label="{{ _('Mute notification') }}" title="{{ _('Mute notification') }}"><i data-feather="bell" class="icon icon-mute"></i></button>
|
||||
<button type="submit" formmethod="post" formaction="{{ mute_action }}" class="bare-btn ajax-op state-on mute-toggle" data-op="mute" style="display: none" aria-label="{{ _('UnMute notification') }}" title="{{ _('UnMute notification') }}"><i data-feather="bell-off" class="icon icon-mute"></i></button>
|
||||
<a class="ajax-op state-off pause-toggle" data-op="pause" aria-label="{{ _('Pause checks') }}" title="{{ _('Pause checks') }}" href="{{url_for('watchlist.index', op='pause', uuid=watch.uuid, tag=active_tag_uuid)}}"><i data-feather="pause" class="icon icon-pause"></i></a>
|
||||
<a class="ajax-op state-on pause-toggle" data-op="pause" style="display: none" aria-label="{{ _('UnPause checks') }}" title="{{ _('UnPause checks') }}" href="{{url_for('watchlist.index', op='pause', uuid=watch.uuid, tag=active_tag_uuid)}}"><i data-feather="play" class="icon icon-unpause"></i></a>
|
||||
<a class="ajax-op state-off mute-toggle" data-op="mute" aria-label="{{ _('Mute notification') }}" title="{{ _('Mute notification') }}" href="{{url_for('watchlist.index', op='mute', uuid=watch.uuid, tag=active_tag_uuid)}}"><i data-feather="bell" class="icon icon-mute"></i></a>
|
||||
<a class="ajax-op state-on mute-toggle" data-op="mute" style="display: none" aria-label="{{ _('UnMute notification') }}" title="{{ _('UnMute notification') }}" href="{{url_for('watchlist.index', op='mute', uuid=watch.uuid, tag=active_tag_uuid)}}"><i data-feather="bell-off" class="icon icon-mute"></i></a>
|
||||
</div>
|
||||
</td>
|
||||
<td class="watch-processor">
|
||||
{%- if watch['processor'] and watch['processor'] in processor_badge_texts -%}
|
||||
<a href="{{ url_for('watchlist.index', tag=active_tag_uuid, processor=watch['processor']) }}" class="processor-badge processor-badge-{{ watch['processor'] }}{{ ' active' if active_processor == watch['processor'] else '' }}"
|
||||
title="{{ processor_descriptions.get(watch['processor'], watch['processor']) }}">{{ processor_badge_texts[watch['processor']] }}</a>
|
||||
{%- endif -%}
|
||||
</td>
|
||||
|
||||
<td class="title-col inline">
|
||||
<div class="grid-wrapper">
|
||||
{% if 'favicons_enabled' not in ui_settings or ui_settings['favicons_enabled'] %}
|
||||
<div class="favicon">
|
||||
<a target="_blank" rel="noopener" href="{{ open_link }}">
|
||||
<a target="_blank" rel="noopener" href="{{ watch.link.replace('source:','') }}">
|
||||
{# Intersection Observer lazy loading: store real URL in data-src, load only when visible in viewport #}
|
||||
<img alt="Favicon thumbnail"
|
||||
class="favicon lazy-favicon"
|
||||
@@ -74,13 +63,16 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="watch-text-info">
|
||||
{%- if watch['processor'] and watch['processor'] in processor_badge_texts -%}
|
||||
<a href="{{ url_for('watchlist.index', tag=active_tag_uuid, processor=watch['processor']) }}" class="processor-badge processor-badge-{{ watch['processor'] }}{{ ' active' if active_processor == watch['processor'] else '' }}" title="{{ processor_descriptions.get(watch['processor'], watch['processor']) }}">{{ processor_badge_texts[watch['processor']] }}</a>
|
||||
{%- endif -%}
|
||||
<span class="watch-title">
|
||||
{% if system_use_url_watchlist or watch.get('use_page_title_in_list') %}
|
||||
{{ watch.label }}
|
||||
{% else %}
|
||||
{{ watch.get('title') or watch.link }}
|
||||
{% endif %}
|
||||
<a class="external" target="_blank" rel="noopener" href="{{ open_link }}" title="{{ open_link }}"><i data-feather="external-link"></i></a>
|
||||
<a class="external" target="_blank" rel="noopener" href="{{ watch.link.replace('source:','') }}"> </a>
|
||||
</span>
|
||||
|
||||
{%- for watch_tag_uuid, watch_tag in datastore.get_all_tags_for_watch(watch['uuid']).items() -%}
|
||||
@@ -89,27 +81,20 @@
|
||||
<div class="error-text" style="display:none;">{{ error_texts|safe }}</div>
|
||||
{%- if watch['processor'] == 'text_json_diff' -%}
|
||||
{%- if watch['has_ldjson_price_data'] and not watch['track_ldjson_price_data'] -%}
|
||||
<div class="ldjson-price-track-offer">Switch to Restock & Price watch mode? <button type="submit" formmethod="post" formaction="{{url_for('price_data_follower.accept', uuid=watch.uuid)}}" class="pure-button button-xsmall">Yes</button> <button type="submit" formmethod="post" formaction="{{url_for('price_data_follower.reject', uuid=watch.uuid)}}" class="bare-btn bare-btn--link">No</button></div>
|
||||
<div class="ldjson-price-track-offer">Switch to Restock & Price watch mode? <a href="{{url_for('price_data_follower.accept', uuid=watch.uuid)}}" class="pure-button button-xsmall">Yes</a> <a href="{{url_for('price_data_follower.reject', uuid=watch.uuid)}}" class="">No</a></div>
|
||||
{%- endif -%}
|
||||
{%- endif -%}
|
||||
|
||||
</div>
|
||||
<div class="status-icons">
|
||||
{%- if system_use_share -%} <button type="submit" formmethod="post" formaction="{{url_for('ui.form_share_put_watch', uuid=watch.uuid)}}" class="bare-btn link-spread"><img src="{{url_for('static_content', group='images', filename='spread.svg')}}" class="status-icon icon icon-spread" title="{{ _('Create a link to share watch config with others') }}" ></button>{%- endif -%}
|
||||
<a class="link-spread" href="{{url_for('ui.form_share_put_watch', uuid=watch.uuid)}}"><img src="{{url_for('static_content', group='images', filename='spread.svg')}}" class="status-icon icon icon-spread" title="{{ _('Create a link to share watch config with others') }}" ></a>
|
||||
{%- set effective_fetcher = watch.get_fetch_backend if watch.get_fetch_backend != "system" else system_default_fetcher -%}
|
||||
{%- if effective_fetcher and ("html_webdriver" in effective_fetcher or "html_" in effective_fetcher or "extra_browser_" in effective_fetcher) -%}
|
||||
{{ effective_fetcher|fetcher_status_icons }}
|
||||
{%- endif -%}
|
||||
{%- if watch.is_pdf -%}<img class="status-icon" src="{{url_for('static_content', group='images', filename='pdf-icon.svg')}}" title="Converting PDF to text" alt="Converting PDF to text" >{%- endif -%}
|
||||
{%- if watch.is_pdf -%}<img class="status-icon" src="{{url_for('static_content', group='images', filename='pdf-icon.svg')}}" alt="Converting PDF to text" >{%- endif -%}
|
||||
{%- if watch.has_browser_steps -%}<img class="status-icon status-browsersteps" src="{{url_for('static_content', group='images', filename='steps.svg')}}" alt="Browser Steps is enabled" >{%- endif -%}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</td>
|
||||
|
||||
{%- if any_has_restock_price_processor -%}
|
||||
<td class="restock-and-price col-restock-price">
|
||||
{%- if watch['processor'] == 'restock_diff' -%}
|
||||
{#- @todo - this could be injected somehow watch.extra_row_info or something -#}
|
||||
<div class="restock-info-wrap">
|
||||
@@ -150,9 +135,15 @@
|
||||
{%- endif -%}
|
||||
</div>
|
||||
{%- endif -%}
|
||||
</div>
|
||||
|
||||
</td>
|
||||
{#
|
||||
{%- if any_has_restock_price_processor -%}
|
||||
<td class="restock-and-price">
|
||||
</td>
|
||||
{%- endif -%}
|
||||
|
||||
#}
|
||||
{#last_checked becomes fetch-start-time#}
|
||||
<td class="last-checked" data-timestamp="{{ watch.last_checked }}" data-fetchduration={{ watch.fetch_time }} data-eta_complete="{{ watch.last_checked+watch.fetch_time }}" data-label="{{ _('Checked') }}">
|
||||
<div class="spinner-wrapper" style="display:none;" >
|
||||
@@ -172,9 +163,7 @@
|
||||
<div>
|
||||
{%- set target_attr = ' target="' ~ watch.uuid ~ '"' if datastore.data['settings']['application']['ui'].get('open_diff_in_new_tab') else '' -%}
|
||||
<a href="" class="already-in-queue-button recheck cdio-btn cdio-btn--primary cdio-btn--sm" style="display: none;" disabled="disabled"><i data-feather="clock"></i>{{ _('Queued') }}</a>
|
||||
<button type="submit" formmethod="post"
|
||||
formaction="{{ url_for('ui.form_watch_checknow', uuid=watch.uuid, tag=request.args.get('tag')) }}"
|
||||
data-op='recheck' class="ajax-op recheck cdio-btn cdio-btn--primary cdio-btn--sm"><i data-feather="refresh-cw"></i>{{ _('Recheck') }}</button>
|
||||
<a href="{{ url_for('ui.form_watch_checknow', uuid=watch.uuid, tag=request.args.get('tag')) }}" data-op='recheck' class="ajax-op recheck cdio-btn cdio-btn--primary cdio-btn--sm"><i data-feather="refresh-cw"></i>{{ _('Recheck') }}</a>
|
||||
<a href="{{ url_for('ui.ui_edit.edit_page', uuid=watch.uuid, tag=active_tag_uuid)}}#general" class="cdio-btn cdio-btn--primary cdio-btn--sm">{{ _('Edit') }}</a>
|
||||
<a href="{{ url_for('ui.ui_diff.diff_history_page', uuid=watch.uuid)}}" {{target_attr}} class="cdio-btn cdio-btn--primary cdio-btn--sm history-link ai-history-btn" style="display: none;" data-uuid="{{ watch.uuid }}" data-summary-url="{{ url_for('ui.ui_diff.diff_llm_summary', uuid=watch.uuid) }}" data-processor-data-url="{{ url_for('ui.ui_diff.diff_history_page_processor_data', uuid=watch.uuid) }}"><span class="btn-label-history">{{ _('History') }}</span><span class="btn-label-summary">✨ {{ _('Summary') }}</span></a>
|
||||
<a href="{{ url_for('ui.ui_preview.preview_page', uuid=watch.uuid)}}" {{target_attr}} class="cdio-btn cdio-btn--primary cdio-btn--sm preview-link" style="display: none;">{{ _('Preview') }}</a>
|
||||
|
||||
@@ -140,10 +140,8 @@ window.watchOverviewI18n = {
|
||||
priceLow: {{ _('Currently low')|tojson }},
|
||||
priceTypical: {{ _('Currently typical')|tojson }},
|
||||
priceHigh: {{ _('Currently high')|tojson }},
|
||||
{# TRANSLATORS: {pct} is a percentage including the % sign, e.g. "80%" #}
|
||||
cheaperThan: {{ _('cheaper than {pct} of tracked prices')|tojson }},
|
||||
{# TRANSLATORS: {pct} is a percentage including the % sign, e.g. "80%" #}
|
||||
pricierThan: {{ _('more expensive than {pct} of tracked prices')|tojson }},
|
||||
cheaperThan: {{ _('cheaper than %s% of tracked prices')|tojson }},
|
||||
pricierThan: {{ _('more expensive than %s% of tracked prices')|tojson }},
|
||||
typicalNote: {{ _('around the usual price')|tojson }},
|
||||
avgLabel: {{ _('avg')|tojson }}
|
||||
};
|
||||
@@ -156,7 +154,7 @@ window.watchOverviewI18n = {
|
||||
<div id="add-watch-url-row">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<label for="url"><strong>{{ _('Web page URL') }}</strong></label><br>
|
||||
{{ render_nolabel_field(form.url, placeholder="www.website.com/page.html", required=true, class="pure-input-1") }}
|
||||
{{ render_nolabel_field(form.url, placeholder="https://...", required=true, class="pure-input-1") }}
|
||||
<button type="submit" id="add-watch-go" class="pure-button pure-button-primary">{{ _('Watch') }}</button>
|
||||
</div>
|
||||
{% if llm_configured %}
|
||||
@@ -195,6 +193,13 @@ window.watchOverviewI18n = {
|
||||
{% endfor %}
|
||||
</template>
|
||||
{% endif %}
|
||||
{# "Select all matching" banner — shown by watch-overview.js when the whole
|
||||
visible page is selected but more matching rows exist on other pages. #}
|
||||
<div id="select-all-banner" class="select-all-banner" style="display: none;"
|
||||
data-select-all-tmpl="{{ _('All %(page)s on this page are selected.') }}"
|
||||
data-select-all-action="{{ _('Select all %(total)s matching') }}"
|
||||
data-all-selected-tmpl="{{ _('All %(total)s matching are selected.') }}"
|
||||
data-clear-action="{{ _('Clear selection') }}"></div>
|
||||
|
||||
<div id="stats_row">
|
||||
<div class="left">{%- if watches|length >= pagination.per_page -%}{{ pagination.info }}{%- endif -%}
|
||||
@@ -203,7 +208,11 @@ window.watchOverviewI18n = {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{%- if search_q -%}<div id="search-result-info">{{ _('Searching') }} "<strong><i>{{search_q}}</i></strong>"</div>{%- endif -%}
|
||||
|
||||
|
||||
{%- set sort_order = sort_order or 'asc' -%}
|
||||
{%- set sort_attribute = sort_attribute or 'last_changed' -%}
|
||||
{%- set pagination_page = request.args.get('page', 0) -%}
|
||||
@@ -278,7 +287,6 @@ window.watchOverviewI18n = {
|
||||
</div>
|
||||
|
||||
<div id="checkbox-operations">
|
||||
<button type="button" class="pure-button button-xsmall" id="check-cancel" style="background: var(--color-background-button-cancel); color: #333;"><i data-feather="x" ></i> {{ _('Close') }}</button>
|
||||
<button type="button" class="pure-button button-secondary button-xsmall" id="check-invert"><i data-feather="repeat" ></i> {{ _('Invert') }}</button>
|
||||
<button class="pure-button button-secondary button-xsmall" name="op" value="pause"><i data-feather="pause" ></i> {{ _('Pause') }}</button>
|
||||
<button class="pure-button button-secondary button-xsmall" name="op" value="unpause"><i data-feather="play" ></i> {{ _('UnPause') }}</button>
|
||||
@@ -313,13 +321,9 @@ window.watchOverviewI18n = {
|
||||
data-confirm-title="{{ _('Delete Watches?') }}"
|
||||
data-confirm-message="{{ _('<p><strong>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>') }}"
|
||||
data-confirm-button="{{ _('Delete') }}"><i data-feather="trash" ></i> {{ _('Delete') }}</button>
|
||||
<button type="button" class="pure-button button-xsmall" id="check-cancel" style="background: var(--color-background-button-cancel); color: #333;"><i data-feather="x" ></i> {{ _('Close') }}</button>
|
||||
</div>
|
||||
<div id="select-all-banner" class="select-all-banner" style="display: none;"
|
||||
data-select-all-tmpl="{{ _('All %(page)s on this page are selected.') }}"
|
||||
data-select-all-action="{{ _('Select all %(total)s matching') }}"
|
||||
data-all-selected-tmpl="{{ _('All %(total)s matching are selected.') }}"
|
||||
data-clear-action="{{ _('Clear selection') }}">
|
||||
</div>
|
||||
|
||||
{%- set table_classes = [
|
||||
'favicon-enabled' if 'favicons_enabled' not in ui_settings or ui_settings['favicons_enabled'] else 'favicon-not-enabled',
|
||||
] -%}
|
||||
@@ -336,10 +340,9 @@ window.watchOverviewI18n = {
|
||||
<a class="{{ 'active '+link_order if sort_attribute == 'notification_muted' else 'inactive' }}" href="{{url_for('watchlist.index', sort='notification_muted', order=link_order, **active_filters)}}"><i data-feather="volume-2" style="width: 16px; height: 16px;"></i><span class='arrow {{link_order}}'></span></a>
|
||||
</div>
|
||||
</th>
|
||||
<th><!-- processor/mode --></th>
|
||||
<th id="th-webpage"><a class="{{ 'active '+link_order if sort_attribute == 'label' else 'inactive' }}" href="{{url_for('watchlist.index', sort='label', order=link_order, **active_filters)}}">{{ _('Web page') }} <span class='arrow {{link_order}}'></span></a></th>
|
||||
{%- if any_has_restock_price_processor -%}
|
||||
<th class="col-restock-price">{{ _('Restock & Price') }}</th>
|
||||
<th>{{ _('Restock & Price') }}</th>
|
||||
{%- endif -%}
|
||||
<th id="h-lastchecked"><a class="{{ 'active '+link_order if sort_attribute == 'last_checked' else 'inactive' }}" href="{{url_for('watchlist.index', sort='last_checked', order=link_order, **active_filters)}}"><span class="hide-on-mobile">{{ _('Checked') }}</span><span class="hide-on-desktop">{{ _('Checked') }}</span> <span class='arrow {{link_order}}'></span></a></th>
|
||||
<th id="h-lastchanged"><a class="{{ 'active '+link_order if sort_attribute == 'last_changed' else 'inactive' }}" href="{{url_for('watchlist.index', sort='last_changed', order=link_order, **active_filters)}}"><span class="hide-on-mobile">{{ _('Changed') }}</span><span class="hide-on-desktop">{{ _('Changed') }}</span> <span class='arrow {{link_order}}'></span></a></th>
|
||||
@@ -353,24 +356,12 @@ window.watchOverviewI18n = {
|
||||
</tr>
|
||||
{%- endif -%}
|
||||
|
||||
{%- set ns = namespace(any_in_page_had_restock=false) -%}
|
||||
{%- for watch in (watches|sort(attribute=sort_attribute, reverse=sort_order == 'asc'))|pagination_slice(skip=pagination.skip) -%}
|
||||
{%- include "watch-overview-single-row.html" -%}
|
||||
|
||||
{%- if watch['processor'] == 'restock_diff' -%}
|
||||
{%- set ns.any_in_page_had_restock = true -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{%- if not ns.any_in_page_had_restock %}
|
||||
<style>
|
||||
.col-restock-price {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
{%- endif %}
|
||||
{{ pagination.links }}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -5,43 +5,10 @@ from random import randint
|
||||
from loguru import logger
|
||||
|
||||
from changedetectionio.content_fetchers import SCREENSHOT_MAX_HEIGHT_DEFAULT
|
||||
from changedetectionio.content_fetchers.base import get_playwright_bypass_csp, manage_user_agent
|
||||
from changedetectionio.content_fetchers.base import manage_user_agent
|
||||
from changedetectionio.jinja2_custom import render as jinja_render
|
||||
from changedetectionio.validate_url import validate_fetch_url_async
|
||||
|
||||
def track_latest_navigation_response(page):
|
||||
"""Record the latest main-frame document response seen on this page, and return the holder.
|
||||
|
||||
Idempotent on purpose - every navigation would otherwise add another 'response' listener, and
|
||||
that event fires once per HTTP response (hundreds on a heavy page), so the callbacks are worth
|
||||
not duplicating. One tracker per page is installed and then shared by the fetcher and by every
|
||||
action_goto_url() call on it.
|
||||
|
||||
Returns a dict that holds {'response': <latest main-frame document response>}, or None if the
|
||||
page does not support event listeners (the unit test stubs, mainly).
|
||||
"""
|
||||
if not hasattr(page, 'on'):
|
||||
return None
|
||||
|
||||
existing = getattr(page, '_cdio_latest_navigation_response', None)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
latest = {}
|
||||
|
||||
def _keep(response):
|
||||
try:
|
||||
if response.frame == page.main_frame and response.request.is_navigation_request():
|
||||
latest['response'] = response
|
||||
except Exception as e:
|
||||
# Never let a bookkeeping listener break a fetch
|
||||
logger.debug(f"Could not record navigation response: {e}")
|
||||
|
||||
page.on("response", _keep)
|
||||
page._cdio_latest_navigation_response = latest
|
||||
return latest
|
||||
|
||||
|
||||
def browser_steps_get_valid_steps(browser_steps: list):
|
||||
if browser_steps is not None and len(browser_steps):
|
||||
valid_steps = list(filter(
|
||||
@@ -178,23 +145,8 @@ class steppable_browser_interface():
|
||||
# and private-IP SSRF possible via a browser step (GHSA-hm22-wg2m-35v4).
|
||||
await validate_fetch_url_async(value)
|
||||
|
||||
# Chrome 153+ refuses to commit a navigation when an error status arrives with a
|
||||
# zero-length body, so page.goto() raises net::ERR_HTTP_RESPONSE_CODE_FAILURE instead of
|
||||
# handing back the response. The response was received fine, we just never get it as a
|
||||
# return value, so fall back to the page's navigation-response tracker and hand that back -
|
||||
# callers then report a real "Error - 404" instead of a raw net:: string.
|
||||
navigation_response = track_latest_navigation_response(self.page)
|
||||
|
||||
now = time.time()
|
||||
try:
|
||||
response = await self.page.goto(value, timeout=0, wait_until='load')
|
||||
except Exception as e:
|
||||
if 'ERR_HTTP_RESPONSE_CODE_FAILURE' not in str(e) or not navigation_response:
|
||||
raise
|
||||
response = navigation_response['response']
|
||||
logger.debug(f"Navigation was aborted by the browser (empty body on an error status), "
|
||||
f"recovered status {response.status} from the response event")
|
||||
|
||||
response = await self.page.goto(value, timeout=0, wait_until='load')
|
||||
logger.debug(f"Time to goto URL {time.time()-now:.2f}s")
|
||||
return response
|
||||
|
||||
@@ -415,7 +367,7 @@ class browsersteps_live_ui(steppable_browser_interface):
|
||||
# @todo handle multiple contexts, bind a unique id from the browser on each req?
|
||||
self.context = await self.playwright_browser.new_context(
|
||||
accept_downloads=False, # Should never be needed
|
||||
bypass_csp=get_playwright_bypass_csp(),
|
||||
bypass_csp=True, # This is needed to enable JavaScript execution on GitHub and others
|
||||
extra_http_headers=self.headers,
|
||||
ignore_https_errors=True,
|
||||
proxy=proxy,
|
||||
@@ -562,3 +514,4 @@ class browsersteps_live_ui(steppable_browser_interface):
|
||||
pass
|
||||
|
||||
return (screenshot, xpath_data)
|
||||
|
||||
|
||||
@@ -34,13 +34,7 @@ CUSTOM_OPERATIONS = {
|
||||
def filter_complete_rules(ruleset):
|
||||
rules = [
|
||||
rule for rule in ruleset
|
||||
if all(
|
||||
rule.get(k) is not None
|
||||
and rule.get(k) is not False
|
||||
and rule.get(k) != ""
|
||||
and rule.get(k) != "None"
|
||||
for k in ("operator", "field", "value")
|
||||
)
|
||||
if all(value not in ("", False, "None", None) for value in [rule["operator"], rule["field"], rule["value"]])
|
||||
]
|
||||
return rules
|
||||
|
||||
@@ -60,20 +54,12 @@ def convert_to_jsonlogic(logic_operator: str, rule_dict: list):
|
||||
field = condition["field"]
|
||||
value = condition["value"]
|
||||
|
||||
if (
|
||||
not operator
|
||||
or operator == 'None'
|
||||
or not field
|
||||
or value is None
|
||||
or value is False
|
||||
or value == ""
|
||||
or value == "None"
|
||||
):
|
||||
if not operator or operator == 'None' or not value or not field:
|
||||
raise EmptyConditionRuleRowNotUsable()
|
||||
|
||||
# Convert value to int/float if possible
|
||||
try:
|
||||
if isinstance(value, str) and "." in value and value != "None":
|
||||
if isinstance(value, str) and "." in value and str != "None":
|
||||
value = float(value)
|
||||
else:
|
||||
value = int(value)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import re
|
||||
|
||||
import pluggy
|
||||
from price_parser import Price
|
||||
from loguru import logger
|
||||
from flask_babel import lazy_gettext as _l
|
||||
|
||||
@@ -69,7 +70,6 @@ def register_field_choices():
|
||||
@hookimpl
|
||||
def add_data(current_watch_uuid, application_datastruct, ephemeral_data):
|
||||
|
||||
from price_parser import Price
|
||||
res = {}
|
||||
if 'text' in ephemeral_data:
|
||||
res['page_filtered_text'] = ephemeral_data['text']
|
||||
|
||||
@@ -14,11 +14,7 @@ global_hookimpl = pluggy.HookimplMarker("changedetectionio")
|
||||
|
||||
def levenshtein_ratio_recent_history(watch, incoming_text=None):
|
||||
try:
|
||||
# rapidfuzz (MIT) instead of Levenshtein (GPL-2.0-or-later), to keep the
|
||||
# shipped deps free of strong copyleft. Indel.normalized_similarity is the
|
||||
# exact equivalent of Levenshtein.ratio - Levenshtein.normalized_similarity is not.
|
||||
from rapidfuzz.distance.Levenshtein import distance
|
||||
from rapidfuzz.distance.Indel import normalized_similarity as ratio
|
||||
from Levenshtein import ratio, distance
|
||||
k = list(watch.history.keys())
|
||||
a = None
|
||||
b = None
|
||||
|
||||
@@ -4,7 +4,6 @@ from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
from changedetectionio.content_fetchers import BrowserStepsStepException
|
||||
from changedetectionio.strtobool import strtobool
|
||||
|
||||
|
||||
class FetcherCapabilities(BaseModel):
|
||||
@@ -28,16 +27,6 @@ class FetcherCapabilities(BaseModel):
|
||||
})
|
||||
|
||||
|
||||
def get_playwright_bypass_csp():
|
||||
"""Return whether Playwright-compatible browser contexts should bypass CSP.
|
||||
|
||||
Bypassing CSP remains enabled by default for backward compatibility. Some
|
||||
remote CDP implementations do not support ``Page.setBypassCSP``; operators
|
||||
can disable the option by setting ``PLAYWRIGHT_BYPASS_CSP=false``.
|
||||
"""
|
||||
return strtobool(os.getenv('PLAYWRIGHT_BYPASS_CSP', 'true'))
|
||||
|
||||
|
||||
def manage_user_agent(headers, current_ua=''):
|
||||
"""
|
||||
Basic setting of user-agent
|
||||
@@ -90,7 +79,6 @@ class Fetcher():
|
||||
screenshot_format = None
|
||||
status_code = None
|
||||
webdriver_js_execute_code = None
|
||||
worker_id = None
|
||||
xpath_data = None
|
||||
xpath_element_js = ""
|
||||
|
||||
@@ -121,11 +109,6 @@ class Fetcher():
|
||||
if kwargs and 'lock_viewport_elements' in kwargs:
|
||||
self.lock_viewport_elements = kwargs.get('lock_viewport_elements')
|
||||
|
||||
# Which async worker is driving this fetch, subclasses use it to keep per-worker browser
|
||||
# state (profile dirs etc) apart, stays None when we're not called from a worker
|
||||
if kwargs and 'worker_id' in kwargs:
|
||||
self.worker_id = kwargs.get('worker_id')
|
||||
|
||||
|
||||
@classmethod
|
||||
def get_status_icon_data(cls):
|
||||
|
||||
@@ -8,7 +8,7 @@ from loguru import logger
|
||||
|
||||
from changedetectionio.content_fetchers import SCREENSHOT_MAX_HEIGHT_DEFAULT, visualselector_xpath_selectors, \
|
||||
SCREENSHOT_SIZE_STITCH_THRESHOLD, SCREENSHOT_MAX_TOTAL_HEIGHT, XPATH_ELEMENT_JS, INSTOCK_DATA_JS, FAVICON_FETCHER_JS
|
||||
from changedetectionio.content_fetchers.base import Fetcher, get_playwright_bypass_csp, manage_user_agent
|
||||
from changedetectionio.content_fetchers.base import Fetcher, manage_user_agent
|
||||
from changedetectionio.content_fetchers.exceptions import PageUnloadable, Non200ErrorCodeReceived, EmptyReply, ScreenshotUnavailable, \
|
||||
BrowserStepsStepException
|
||||
|
||||
@@ -284,9 +284,7 @@ class fetcher(Fetcher):
|
||||
# Use the default one configured in the App.py model that's passed from fetch_site_status.py
|
||||
context = await browser.new_context(
|
||||
accept_downloads=False, # Should never be needed
|
||||
# Enabled by default because sites such as GitHub need it for injected JavaScript.
|
||||
# Some CDP implementations do not support Page.setBypassCSP, so allow operators to disable it.
|
||||
bypass_csp=get_playwright_bypass_csp(),
|
||||
bypass_csp=True, # This is needed to enable JavaScript execution on GitHub and others
|
||||
extra_http_headers=request_headers,
|
||||
ignore_https_errors=True,
|
||||
proxy=self.proxy,
|
||||
@@ -296,20 +294,6 @@ class fetcher(Fetcher):
|
||||
|
||||
self.page = await context.new_page()
|
||||
|
||||
# Track the LATEST main-frame document response for the whole fetch, not just the one
|
||||
# goto() returns. This app compares the text of the page the browser ends up on, and a
|
||||
# site that gates with an interstitial (503/429 + meta-refresh) navigates to the real
|
||||
# page *during* the extra_wait below - judging the fetch on the first response fails a
|
||||
# watch whose content is present and fine. Same for plain client-side redirects.
|
||||
# Shared with action_goto_url() so only one 'response' listener exists on the page.
|
||||
from changedetectionio.browser_steps.browser_steps import track_latest_navigation_response
|
||||
# Must be an identity check - the tracker hands back the same (initially empty, so
|
||||
# falsy) dict the listener writes into, and `or {}` would quietly swap in a different
|
||||
# one that never gets updated.
|
||||
latest_navigation_response = track_latest_navigation_response(self.page)
|
||||
if latest_navigation_response is None:
|
||||
latest_navigation_response = {}
|
||||
|
||||
# Listen for all console events and handle errors
|
||||
self.page.on("console", lambda msg: logger.debug(f"Playwright console: Watch URL: {url} {msg.type}: {msg.text} {msg.args}"))
|
||||
|
||||
@@ -350,25 +334,6 @@ class fetcher(Fetcher):
|
||||
extra_wait = int(os.getenv("WEBDRIVER_DELAY_BEFORE_CONTENT_READY", 5)) + self.render_extract_delay
|
||||
await self.page.wait_for_timeout(extra_wait * 1000)
|
||||
|
||||
# A meta-refresh or client-side redirect usually lands during that wait, so judge the
|
||||
# fetch on the document we are actually about to extract rather than the first one.
|
||||
latest = latest_navigation_response.get('response')
|
||||
if latest is not None and latest is not response:
|
||||
logger.debug(f"Page navigated again while waiting, judging the fetch on {latest.url} "
|
||||
f"(status {latest.status}) instead of the first response for {url}")
|
||||
response = latest
|
||||
try:
|
||||
self.headers = await response.all_headers()
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not refresh headers from the final document: {e}")
|
||||
|
||||
# Don't extract while a navigation is mid-flight, that is what produces
|
||||
# "Execution context was destroyed, most likely because of a navigation"
|
||||
try:
|
||||
await self.page.wait_for_load_state('load', timeout=extra_wait * 1000)
|
||||
except Exception as e:
|
||||
logger.debug(f"Page did not reach a settled load state, continuing anyway: {e}")
|
||||
|
||||
try:
|
||||
self.status_code = response.status
|
||||
except Exception as e:
|
||||
@@ -506,3 +471,4 @@ class PlaywrightFetcherPlugin:
|
||||
playwright_plugin = PlaywrightFetcherPlugin()
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import gc
|
||||
import json
|
||||
import os
|
||||
import websockets.exceptions
|
||||
@@ -9,18 +10,11 @@ from loguru import logger
|
||||
from changedetectionio.content_fetchers import SCREENSHOT_MAX_HEIGHT_DEFAULT, visualselector_xpath_selectors, \
|
||||
SCREENSHOT_SIZE_STITCH_THRESHOLD, SCREENSHOT_DEFAULT_QUALITY, XPATH_ELEMENT_JS, INSTOCK_DATA_JS, \
|
||||
SCREENSHOT_MAX_TOTAL_HEIGHT, FAVICON_FETCHER_JS
|
||||
from changedetectionio import gc_debounce
|
||||
from changedetectionio.content_fetchers.base import Fetcher, get_playwright_bypass_csp, manage_user_agent
|
||||
from changedetectionio.content_fetchers.base import Fetcher, manage_user_agent
|
||||
from changedetectionio.content_fetchers.exceptions import PageUnloadable, Non200ErrorCodeReceived, EmptyReply, BrowserFetchTimedOut, \
|
||||
BrowserConnectError
|
||||
|
||||
|
||||
async def _configure_puppeteer_csp(page):
|
||||
"""Enable CSP bypass without requiring unsupported CDP methods when disabled."""
|
||||
if get_playwright_bypass_csp():
|
||||
await page.setBypassCSP(True)
|
||||
|
||||
|
||||
# Bug 3 in Playwright screenshot handling
|
||||
# Some bug where it gives the wrong screenshot size, but making a request with the clip set first seems to solve it
|
||||
|
||||
@@ -203,10 +197,6 @@ class fetcher(Fetcher):
|
||||
def __init__(self, proxy_override=None, custom_browser_connection_url=None, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
|
||||
# Renderer crashes recorded during the fetch, see the 'error' handler in fetch_page().
|
||||
# Set up here so run()'s finally can always report, even if we never got as far as a page.
|
||||
self.page_errors = []
|
||||
|
||||
if custom_browser_connection_url:
|
||||
self.browser_connection_is_custom = True
|
||||
self.browser_connection_url = custom_browser_connection_url
|
||||
@@ -236,7 +226,6 @@ class fetcher(Fetcher):
|
||||
|
||||
async def quit(self, watch=None):
|
||||
watch_uuid = watch.get('uuid') if watch else 'unknown'
|
||||
closed_something = bool(getattr(self, 'page', None) or getattr(self, 'browser', None))
|
||||
|
||||
# Close page
|
||||
try:
|
||||
@@ -264,16 +253,8 @@ class fetcher(Fetcher):
|
||||
|
||||
logger.info(f"[{watch_uuid}] Cleanup puppeteer complete")
|
||||
|
||||
# Only collect if this call actually closed something.
|
||||
#
|
||||
# quit() runs twice per check - from run()'s finally, then again from the worker's
|
||||
# safety net - and it sets self.page/self.browser to None in its own finally
|
||||
# blocks. The second call therefore closes nothing, creates no garbage and breaks
|
||||
# no cycles, but still paid for a full stop-the-world collection: measured at 88
|
||||
# calls across 51 checks, roughly half of them reclaiming nothing. The pyppeteer
|
||||
# page/connection/session graph is genuinely cyclic, so the first call still runs.
|
||||
if closed_something:
|
||||
gc_debounce.collect('puppeteer.quit')
|
||||
# Force garbage collection to release resources
|
||||
gc.collect()
|
||||
|
||||
async def fetch_page(self,
|
||||
current_include_filters,
|
||||
@@ -331,23 +312,6 @@ class fetcher(Fetcher):
|
||||
self.browser = None
|
||||
raise
|
||||
|
||||
# A renderer crash makes pyppeteer emit Page 'error' (PageError('Page crashed!')).
|
||||
# pyee re-raises an 'error' emission that has no listener, and that raise escapes into
|
||||
# Connection._onMessage, whose catch-all disposes the entire connection - so one dead
|
||||
# tab takes the whole browser with it and every later call reports the misleading
|
||||
# "Session closed. Most likely the page has been closed." Attaching a listener keeps
|
||||
# the failure local, named, and recoverable.
|
||||
#
|
||||
# A single page load can emit 'error' more than once (an iframe renderer going down, then
|
||||
# the main one), so collect them all rather than keeping only the last.
|
||||
self.page_errors = []
|
||||
|
||||
def _handle_page_error(e):
|
||||
self.page_errors.append(e)
|
||||
logger.error(f"[{watch_uuid}] Page error (the renderer likely crashed, often OOM): {e}")
|
||||
|
||||
self.page.on('error', _handle_page_error)
|
||||
|
||||
# Add console handler to capture console.log from favicon fetcher
|
||||
#self.page.on('console', lambda msg: logger.debug(f"Browser console [{msg.type}]: {msg.text}"))
|
||||
|
||||
@@ -383,7 +347,7 @@ class fetcher(Fetcher):
|
||||
# Attempt to strip 'HeadlessChrome' etc
|
||||
await self.page.setUserAgent(manage_user_agent(headers=request_headers, current_ua=await self.page.evaluate('navigator.userAgent')))
|
||||
|
||||
await _configure_puppeteer_csp(self.page)
|
||||
await self.page.setBypassCSP(True)
|
||||
if request_headers:
|
||||
await self.page.setExtraHTTPHeaders(request_headers)
|
||||
|
||||
@@ -408,192 +372,56 @@ class fetcher(Fetcher):
|
||||
# Enable Network domain to detect when first bytes arrive
|
||||
await self.page._client.send('Network.enable')
|
||||
|
||||
# Navigate (bounded), then wait the configured "wait n seconds before extracting text"
|
||||
# delay, then stop whatever is still loading, then extract. The delay is measured from
|
||||
# when navigation finished, not from when it started, because the point of it is to let
|
||||
# JS-rendered content appear *after* load - anchoring it to the start would quietly give a
|
||||
# slow-loading page almost no settle time.
|
||||
#
|
||||
# Only that delay is a user-facing setting. The navigation bound above is a safety net with
|
||||
# a sane default, not a tuning knob, so there is still one number for users to think about.
|
||||
#
|
||||
# There is no way to know a page is "finished" - plenty of sites navigate as part of their
|
||||
# normal design, and some sit forever on a subresource that never answers. So the delay
|
||||
# restarts if the MAIN frame replaces its document (a redirect or interstitial gets the
|
||||
# same settle time the first document got), iframes do not restart it, and it is capped so
|
||||
# a page that re-navigates in a loop cannot extend it indefinitely.
|
||||
max_content_ready_resets = int(os.getenv("BROWSER_CONTENT_READY_MAX_RESETS", 2))
|
||||
# Now set up the frame navigation handlers
|
||||
async def handle_frame_navigation(event=None):
|
||||
# Wait n seconds after the frameStartedLoading, not from any frameStartedLoading/frameStartedNavigating
|
||||
logger.debug(f"Frame navigated: {event}")
|
||||
w = extra_wait - 2 if extra_wait > 4 else 2
|
||||
logger.debug(f"Waiting {w} seconds before calling Page.stopLoading...")
|
||||
await asyncio.sleep(w)
|
||||
|
||||
async def wait_for_content_ready_then_stop_loading():
|
||||
main_frame_id = self.page.mainFrame._id
|
||||
renavigated = asyncio.Event()
|
||||
# Check if page still exists (might have been closed due to error during sleep)
|
||||
if not self.page or not hasattr(self.page, '_client'):
|
||||
logger.debug("Page already closed, skipping stopLoading")
|
||||
return
|
||||
|
||||
def _on_main_frame_navigation(event):
|
||||
if event.get('frameId') == main_frame_id:
|
||||
renavigated.set()
|
||||
logger.debug("Issuing stopLoading command...")
|
||||
await self.page._client.send('Page.stopLoading')
|
||||
logger.debug("stopLoading command sent!")
|
||||
|
||||
self.page._client.on('Page.frameStartedLoading', _on_main_frame_navigation)
|
||||
self.page._client.on('Page.frameStoppedLoading', lambda e: logger.debug(f"Frame stopped loading: {e}"))
|
||||
try:
|
||||
resets = 0
|
||||
while True:
|
||||
renavigated.clear()
|
||||
try:
|
||||
await asyncio.wait_for(renavigated.wait(), timeout=extra_wait)
|
||||
# Main frame started a new document
|
||||
resets += 1
|
||||
if resets > max_content_ready_resets:
|
||||
# The cap is there to stop a page that re-navigates in a loop from
|
||||
# extending the fetch forever - it is NOT permission to extract
|
||||
# immediately. Breaking straight out here landed on whatever document
|
||||
# happened to be mid-flight, with zero settle time: measured against a
|
||||
# page that hops every 500ms, the fetch ended after 2.7s holding 130
|
||||
# bytes of an intermediate hop, no final document and no JS-rendered
|
||||
# content, while logging "content-ready wait of 12s elapsed".
|
||||
#
|
||||
# So spend the delay one last time, just without arming another reset.
|
||||
# Total stays bounded at (max_resets + 2) * extra_wait, and whatever we
|
||||
# extract has had the same settle time every other fetch gets.
|
||||
logger.debug(f"Main frame re-navigated {resets} times (cap "
|
||||
f"{max_content_ready_resets}), waiting {extra_wait}s once "
|
||||
f"more without restarting, then extracting regardless")
|
||||
await asyncio.sleep(extra_wait)
|
||||
break
|
||||
logger.debug(f"Main frame started a new document, restarting the {extra_wait}s "
|
||||
f"content-ready wait ({resets}/{max_content_ready_resets})")
|
||||
except asyncio.TimeoutError:
|
||||
# Quiet for the whole delay - the page is as ready as it is going to get
|
||||
break
|
||||
finally:
|
||||
self.page._client.remove_listener('Page.frameStartedLoading', _on_main_frame_navigation)
|
||||
async def setup_frame_handlers_on_first_response(event):
|
||||
# Only trigger for the main document response
|
||||
if event.get('type') == 'Document':
|
||||
logger.debug("First response received, setting up frame handlers for forced page stop load.")
|
||||
self.page._client.on('Page.frameStartedNavigating', lambda e: asyncio.create_task(handle_frame_navigation(e)))
|
||||
self.page._client.on('Page.frameStartedLoading', lambda e: asyncio.create_task(handle_frame_navigation(e)))
|
||||
self.page._client.on('Page.frameStoppedLoading', lambda e: logger.debug(f"Frame stopped loading: {e}"))
|
||||
logger.debug("First response received, setting up frame handlers for forced page stop load DONE SETUP")
|
||||
# De-register this listener - we only need it once
|
||||
self.page._client.remove_listener('Network.responseReceived', setup_frame_handlers_on_first_response)
|
||||
|
||||
# Stop whatever is still in flight so the DOM and screenshot come from what rendered,
|
||||
# rather than waiting on a subresource that may never answer
|
||||
try:
|
||||
logger.debug(f"Content-ready wait finished, issuing Page.stopLoading before extracting")
|
||||
await self.page._client.send('Page.stopLoading')
|
||||
logger.debug("stopLoading command sent!")
|
||||
|
||||
# stopLoading stops the network, not script execution. A page whose JS has pegged
|
||||
# the renderer's main thread (a runaway loop, a rAF that never settles) holds that
|
||||
# thread indefinitely, and every CDP call that needs to run script then queues
|
||||
# behind it and never returns - page.content, the xPath scraper, the favicon
|
||||
# fetcher. The fetch dies at PUPPETEER_MAX_PROCESSING_TIMEOUT_SECONDS having
|
||||
# extracted nothing, with a core spinning the entire time.
|
||||
#
|
||||
# Nothing else recovers this. Runtime.evaluate's own `timeout` parameter bounds an
|
||||
# evaluation once it starts, not time spent queued behind the running task, and
|
||||
# wrapping the call in asyncio.wait_for is worse than useless: cancelling a
|
||||
# pyppeteer request mid-flight leaves the connection unusable ("Target closed" on
|
||||
# everything after it). Terminating execution is what releases the thread -
|
||||
# measured against a deliberately spinning page, extraction went from timing out
|
||||
# to returning the full DOM in 0.0s and the renderer dropped from 1.00 to 0.08
|
||||
# cores. Safe here because stopLoading has already declared "give me what
|
||||
# rendered", and the content-ready wait above has already had its chance to let
|
||||
# late JS-rendered content appear.
|
||||
await self.page._client.send('Runtime.terminateExecution')
|
||||
logger.debug("Runtime.terminateExecution sent, any runaway page script is stopped")
|
||||
except Exception as e:
|
||||
logger.debug(f"Page.stopLoading/Runtime.terminateExecution skipped, page is most "
|
||||
f"likely already gone: {e}")
|
||||
|
||||
# Track the LATEST main-frame document response for the whole fetch, not just the one that
|
||||
# goto() happens to return. This app compares the text of the page the browser ends up on,
|
||||
# and plenty of sites navigate again after the first response:
|
||||
# - an interstitial answering 503/429 with a meta-refresh into the real 200 page, where
|
||||
# judging the first response fails a watch whose content is sitting right there
|
||||
# - a plain client-side redirect to another host (slated.com -> get.slated.com)
|
||||
# It also covers Chrome 153+, which refuses to commit a navigation when an error status
|
||||
# arrives with a zero-length body: goto() raises net::ERR_HTTP_RESPONSE_CODE_FAILURE rather
|
||||
# than returning the response, but the response itself still arrives on this event.
|
||||
navigation_response = {}
|
||||
|
||||
def _keep_navigation_response(response):
|
||||
# Note pyppeteer exposes these as properties, unlike playwright where they are methods
|
||||
if response.frame == self.page.mainFrame and response.request.isNavigationRequest:
|
||||
navigation_response['response'] = response
|
||||
|
||||
self.page.on('response', _keep_navigation_response)
|
||||
|
||||
# pyppeteer's navigation watcher is bound to the loaderId of the navigation it started. If
|
||||
# the page replaces that document (redirect/interstitial) the 'load' it waits for never
|
||||
# arrives for that loaderId, so goto() never returns - and with timeout=0 it would block
|
||||
# until the hard PUPPETEER_MAX_PROCESSING_TIMEOUT_SECONDS kill, burning a worker slot for
|
||||
# minutes on a page that is fully loaded. Bound it, then fall back to the document we can
|
||||
# see. Verified against slated.com and getastra.com, which hang indefinitely otherwise.
|
||||
nav_timeout = int(os.getenv("BROWSER_NAVIGATION_TIMEOUT_SECONDS", 30))
|
||||
# Listen for first response to trigger frame handler setup
|
||||
self.page._client.on('Network.responseReceived', setup_frame_handlers_on_first_response)
|
||||
|
||||
response = None
|
||||
attempt=0
|
||||
try:
|
||||
while not response:
|
||||
logger.debug(f"Attempting page fetch {url} attempt {attempt}")
|
||||
# Race goto() against the main frame actually firing 'load'. In the re-navigation
|
||||
# case goto() can never resolve, but the replacement document does fire 'load' -
|
||||
# usually within a few seconds - so this returns then instead of sitting out the
|
||||
# whole nav_timeout. Whichever arrives first means "the document is loaded".
|
||||
main_frame_loaded = asyncio.Event()
|
||||
main_frame_id = self.page.mainFrame._id
|
||||
while not response:
|
||||
logger.debug(f"Attempting page fetch {url} attempt {attempt}")
|
||||
asyncio.create_task(handle_frame_navigation())
|
||||
response = await self.page.goto(url, timeout=0)
|
||||
await asyncio.sleep(1 + extra_wait)
|
||||
# Check if page still exists before sending command
|
||||
if self.page and hasattr(self.page, '_client'):
|
||||
await self.page._client.send('Page.stopLoading')
|
||||
|
||||
def _on_lifecycle(event):
|
||||
if event.get('name') == 'load' and event.get('frameId') == main_frame_id:
|
||||
main_frame_loaded.set()
|
||||
|
||||
self.page._client.on('Page.lifecycleEvent', _on_lifecycle)
|
||||
goto_task = asyncio.ensure_future(self.page.goto(url, timeout=0))
|
||||
load_task = asyncio.ensure_future(main_frame_loaded.wait())
|
||||
try:
|
||||
done, _pending = await asyncio.wait({goto_task, load_task},
|
||||
timeout=nav_timeout,
|
||||
return_when=asyncio.FIRST_COMPLETED)
|
||||
|
||||
if goto_task in done:
|
||||
try:
|
||||
response = goto_task.result()
|
||||
except Exception as e:
|
||||
if 'ERR_HTTP_RESPONSE_CODE_FAILURE' not in str(e) or not navigation_response:
|
||||
raise
|
||||
response = navigation_response['response']
|
||||
logger.debug(f"Navigation was aborted by the browser (empty body on an error status), "
|
||||
f"recovered status {response.status} from the response event")
|
||||
else:
|
||||
# Either the replacement document loaded, or we ran out of patience
|
||||
response = navigation_response.get('response')
|
||||
if not response:
|
||||
raise BrowserFetchTimedOut(msg=f"Browser did not finish navigating to {url} within "
|
||||
f"{nav_timeout}s and no main-frame response was seen.")
|
||||
why = ("the page replaced the document it started on" if load_task in done
|
||||
else f"navigation did not settle within {nav_timeout}s")
|
||||
logger.warning(f"Continuing with the document actually loaded ({why}) - "
|
||||
f"status {response.status} for {response.url}")
|
||||
finally:
|
||||
self.page._client.remove_listener('Page.lifecycleEvent', _on_lifecycle)
|
||||
for t in (goto_task, load_task):
|
||||
if not t.done():
|
||||
t.cancel()
|
||||
if response:
|
||||
break
|
||||
if not response:
|
||||
logger.warning("Page did not fetch! trying again!")
|
||||
if response is None and attempt>=2:
|
||||
logger.warning(f"Content Fetcher > Response object was none (as in, the response from the browser was empty, not just the content) exiting attempt {attempt}")
|
||||
raise EmptyReply(url=url, status_code=None)
|
||||
attempt+=1
|
||||
|
||||
# Navigation is done; now honour "wait n seconds before extracting text" and then
|
||||
# force-stop whatever is still loading, so extraction always gets what rendered.
|
||||
# Awaited inline rather than fired off as a task, so nothing can outlive the fetch.
|
||||
await wait_for_content_ready_then_stop_loading()
|
||||
|
||||
# That wait is where a meta-refresh interstitial typically swaps in the real page, so
|
||||
# re-check which document we are actually on before judging the status code.
|
||||
latest = navigation_response.get('response')
|
||||
if latest is not None and latest is not response:
|
||||
logger.debug(f"Page navigated again while waiting, judging the fetch on {latest.url} "
|
||||
f"(status {latest.status}) instead of {response.url} (status {response.status})")
|
||||
response = latest
|
||||
finally:
|
||||
self.page.remove_listener('response', _keep_navigation_response)
|
||||
if response:
|
||||
break
|
||||
if not response:
|
||||
logger.warning("Page did not fetch! trying again!")
|
||||
if response is None and attempt>=2:
|
||||
logger.warning(f"Content Fetcher > Response object was none (as in, the response from the browser was empty, not just the content) exiting attempt {attempt}")
|
||||
raise EmptyReply(url=url, status_code=None)
|
||||
attempt+=1
|
||||
|
||||
self.headers = response.headers
|
||||
|
||||
@@ -654,7 +482,8 @@ class fetcher(Fetcher):
|
||||
self.screenshot = await capture_full_page(page=self.page, screenshot_format=self.screenshot_format, watch_uuid=watch_uuid, lock_viewport_elements=self.lock_viewport_elements)
|
||||
|
||||
# Force garbage collection - pyppeteer base64 decode creates temporary buffers
|
||||
gc_debounce.collect('puppeteer.after_screenshot')
|
||||
import gc
|
||||
gc.collect()
|
||||
self.xpath_data = await self.page.evaluate(XPATH_ELEMENT_JS, {
|
||||
"visualselector_xpath_selectors": visualselector_xpath_selectors,
|
||||
"max_height": MAX_TOTAL_HEIGHT
|
||||
@@ -709,15 +538,6 @@ class fetcher(Fetcher):
|
||||
except asyncio.TimeoutError:
|
||||
raise (BrowserFetchTimedOut(msg=f"Browser connected but was unable to process the page in {max_time} seconds."))
|
||||
finally:
|
||||
# Nothing consumes page_errors yet, but a crashed renderer usually means the content we
|
||||
# just extracted is partial or stale, so always say so - otherwise the only clue is a
|
||||
# confusing downstream error (or worse, a silently wrong "change detected").
|
||||
if self.page_errors:
|
||||
logger.warning(
|
||||
f"[{watch_uuid}] {len(self.page_errors)} page error(s) during this fetch of '{url}', "
|
||||
f"content may be incomplete: {'; '.join(str(e) for e in self.page_errors)}"
|
||||
)
|
||||
|
||||
# Internal cleanup on any exception/timeout - call quit() immediately
|
||||
# This prevents connection leaks during exception bursts
|
||||
# Worker.py's quit() call becomes a redundant safety net (idempotent)
|
||||
|
||||
@@ -54,98 +54,57 @@
|
||||
return 0;
|
||||
});
|
||||
|
||||
// All candidates are fetched concurrently under one shared deadline.
|
||||
//
|
||||
// Sequentially, each icon got its own fresh 2s AbortController, so a site declaring
|
||||
// five <link rel="icon"> variants spent 10s+ here - inside the page, holding a browser
|
||||
// and a worker the whole time. Simply capping the total made it worse: giving up early
|
||||
// returns no icon, nothing gets saved, favicon_is_expired() stays true and the cost is
|
||||
// paid again on the very next check, forever. Fetching in parallel bounds the wall time
|
||||
// *and* still finds a working icon, so it saves and the watch stops asking.
|
||||
//
|
||||
// Measured against a page with five hanging icons, one 404 and one good one:
|
||||
// sequential, 2s each : 10.1s, found the icon
|
||||
// total cap only : 3.0s, found nothing (then repeats every check)
|
||||
// parallel + deadline : ~3s, found the icon
|
||||
const TOTAL_BUDGET_MS = 3000;
|
||||
const timeoutMs = 2000;
|
||||
// 1 MB — matches the server-side limit in bump_favicon()
|
||||
const MAX_BYTES = 1 * 1024 * 1024;
|
||||
|
||||
const toBase64 = (blob) => new Promise(resolve => {
|
||||
// Always resolves. The previous version resolved only from onloadend and read
|
||||
// reader.result unguarded, so a FileReader failure threw inside the callback and left
|
||||
// the promise permanently pending - the whole favicon fetch then hung with nothing
|
||||
// bounding it, because clearTimeout had already fired.
|
||||
try {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => resolve(null);
|
||||
reader.onloadend = () => {
|
||||
try {
|
||||
const result = reader.result;
|
||||
resolve(result ? String(result).split(',')[1] : null);
|
||||
} catch (e) {
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
} catch (e) {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const budget = setTimeout(() => controller.abort(), TOTAL_BUDGET_MS);
|
||||
|
||||
const fetchOne = async (icon) => {
|
||||
for (const icon of icons) {
|
||||
try {
|
||||
// Inline data URI — no network fetch needed, data is already here
|
||||
if (icon.href.startsWith('data:')) {
|
||||
const match = icon.href.match(/^data:([^;]+);base64,([A-Za-z0-9+/=]+)$/);
|
||||
if (!match) return null;
|
||||
if (!match) continue;
|
||||
const mime_type = match[1];
|
||||
const base64 = match[2];
|
||||
// Rough size check: base64 is ~4/3 the binary size
|
||||
if (base64.length * 0.75 > MAX_BYTES) return null;
|
||||
if (base64.length * 0.75 > MAX_BYTES) continue;
|
||||
return { url: icon.href, mime_type, base64 };
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
const resp = await fetch(icon.href, {
|
||||
signal: controller.signal,
|
||||
redirect: 'follow'
|
||||
});
|
||||
|
||||
if (!resp.ok) return null;
|
||||
clearTimeout(timeout);
|
||||
|
||||
// Skip an oversized icon before pulling its body down the wire, where the server
|
||||
// tells us the size up front.
|
||||
const declared = parseInt(resp.headers.get('content-length') || '0', 10);
|
||||
if (declared > MAX_BYTES) return null;
|
||||
if (!resp.ok) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Still covered by the shared signal: aborting errors the body stream too. The
|
||||
// previous version cleared its timer before this line, leaving a slow or
|
||||
// never-ending body read completely unguarded.
|
||||
const blob = await resp.blob();
|
||||
if (blob.size > MAX_BYTES) return null;
|
||||
|
||||
const base64 = await toBase64(blob);
|
||||
if (!base64) return null;
|
||||
if (blob.size > MAX_BYTES) continue;
|
||||
|
||||
// Convert blob to base64
|
||||
const reader = new FileReader();
|
||||
return await new Promise(resolve => {
|
||||
reader.onloadend = () => {
|
||||
resolve({
|
||||
url: icon.href,
|
||||
mime_type: blob.type,
|
||||
base64: reader.result.split(",")[1]
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
|
||||
return { url: icon.href, mime_type: blob.type, base64 };
|
||||
} catch (e) {
|
||||
return null;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const settled = await Promise.all(icons.map(fetchOne));
|
||||
// icons[] is already in preference order (largest, then apple-touch-icon), so the
|
||||
// first success in that order is the one we want - not merely the fastest to answer.
|
||||
const best = settled.find(r => r);
|
||||
if (best) return best;
|
||||
} catch (e) {
|
||||
// fall through to "nothing found"
|
||||
} finally {
|
||||
clearTimeout(budget);
|
||||
}
|
||||
|
||||
// nothing found
|
||||
|
||||
@@ -71,7 +71,6 @@ async () => {
|
||||
'out of stock',
|
||||
'out-of-stock',
|
||||
'plus disponible',
|
||||
'producto sin stock',
|
||||
'prodotto esaurito',
|
||||
'produkt niedostępny',
|
||||
'rupture',
|
||||
@@ -109,14 +108,7 @@ async () => {
|
||||
'品切れ',
|
||||
'已售',
|
||||
'已售完',
|
||||
'품절',
|
||||
"товар закінчився",
|
||||
"немає в наявності",
|
||||
"нема в наявності",
|
||||
"закінчився",
|
||||
"знято з виробництва",
|
||||
"недоступний",
|
||||
"немає на складі"
|
||||
'품절'
|
||||
];
|
||||
|
||||
|
||||
|
||||
+213
-694
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,6 @@ from loguru import logger
|
||||
from wtforms.widgets.core import TimeInput
|
||||
from flask_babel import lazy_gettext as _l, gettext
|
||||
|
||||
from changedetectionio.blueprint.menu_modes import MENU_SIDEBAR_ACTIONMODES, MENU_SIDEBAR_ACTIONMODES_DEFAULT
|
||||
from changedetectionio.blueprint.rss import RSS_FORMAT_TYPES, RSS_TEMPLATE_TYPE_OPTIONS, RSS_TEMPLATE_HTML_DEFAULT
|
||||
from changedetectionio.llm.ui_strings import LLM_INTENT_WATCH_PLACEHOLDER
|
||||
from changedetectionio.llm.evaluator import (
|
||||
@@ -488,39 +487,6 @@ class ValidateContentFetcherIsReady(object):
|
||||
# raise ValidationError(message % (field.data, e))
|
||||
|
||||
|
||||
class ValidateKnownContentFetcher(object):
|
||||
"""The posted fetch_backend has to name a fetcher this install actually has.
|
||||
|
||||
Deliberately *not* a live-preview capability check. This validator sits on the
|
||||
shared quick-add form, whose POST endpoint is also how the watch list (and tests,
|
||||
and scripts) add a watch with any legal backend - 'html_requests' included. Which
|
||||
browsers the Add-Watch page *offers* is a rendering decision (see the add_watch_ui
|
||||
blueprint's browser_config), and whether one can render a live preview is enforced
|
||||
where that matters, in /snapshot.
|
||||
|
||||
Optional: no value posted means "leave it on the system default", as before.
|
||||
"""
|
||||
|
||||
def __init__(self, message=None):
|
||||
self.message = message
|
||||
|
||||
def __call__(self, form, field):
|
||||
from flask import current_app
|
||||
from changedetectionio import content_fetchers
|
||||
|
||||
if not field.data:
|
||||
return
|
||||
|
||||
allowed = {'system'} | {name for name, _description in content_fetchers.available_fetchers()}
|
||||
datastore = current_app.config.get('DATASTORE')
|
||||
if datastore:
|
||||
allowed |= {value for value, _label in datastore.extra_browsers}
|
||||
|
||||
if field.data not in allowed:
|
||||
logger.warning(f"Rejected unknown fetch_backend {field.data!r} - known: {sorted(allowed)}")
|
||||
raise ValidationError(self.message or gettext("Unknown fetch method."))
|
||||
|
||||
|
||||
class ValidateNotificationBodyAndTitleWhenURLisSet(object):
|
||||
"""
|
||||
Validates that they entered something in both notification title+body when the URL is set
|
||||
@@ -697,18 +663,14 @@ class ValidateCSSJSONXPATHInput(object):
|
||||
raise ValidationError("XPath not permitted in this field!")
|
||||
from lxml import etree, html
|
||||
import elementpath
|
||||
from changedetectionio.html_tools import get_safe_xpath3_parser, lxml_guard, lxml_html_parser, \
|
||||
XPATH_CODEPOINT_COLLATION
|
||||
from changedetectionio.html_tools import SafeXPath3Parser, lxml_guard, lxml_html_parser
|
||||
line = line.replace('xpath:', '')
|
||||
|
||||
try:
|
||||
# Runs on a Flask request thread - must share the worker's lxml lock.
|
||||
with lxml_guard():
|
||||
tree = html.fromstring("<html></html>", parser=lxml_html_parser())
|
||||
# Same collation the filter will actually run under, so validation
|
||||
# cannot accept an expression that then behaves differently at check time.
|
||||
elementpath.select(tree, line.strip(), parser=get_safe_xpath3_parser(),
|
||||
default_collation=XPATH_CODEPOINT_COLLATION)
|
||||
elementpath.select(tree, line.strip(), parser=SafeXPath3Parser)
|
||||
except elementpath.ElementPathError as e:
|
||||
message = field.gettext('\'%(expression)s\' is not a valid XPath expression. (%(error)s)')
|
||||
raise ValidationError(message % {'expression': line, 'error': str(e)})
|
||||
@@ -822,41 +784,11 @@ class ValidateStartsWithRegex(object):
|
||||
if not self.pattern.match(stripped):
|
||||
raise ValidationError(self.message or _l("Invalid value."))
|
||||
|
||||
def visual_browser_choices():
|
||||
"""Browsers that can render the Add-Watch live preview, as RadioField choices.
|
||||
|
||||
Lazy import (the add_watch_ui blueprint imports this module) and empty outside an
|
||||
app context, because WTForms evaluates a choices callable on field construction.
|
||||
"""
|
||||
from flask import current_app, has_app_context
|
||||
from changedetectionio.blueprint.add_watch_ui import browser_config
|
||||
|
||||
if not has_app_context():
|
||||
return []
|
||||
datastore = current_app.config.get('DATASTORE')
|
||||
return browser_config.radio_choices(datastore) if datastore else []
|
||||
|
||||
|
||||
class quickWatchForm(Form):
|
||||
url = StringField('URL', validators=[validateURL()])
|
||||
tags = StringTagUUID(_l('Group tag'), validators=[validators.Optional()])
|
||||
watch_submit_button = SubmitField(_l('Watch'), render_kw={"class": "pure-button pure-button-primary"})
|
||||
processor = RadioField(_l('Processor'), choices=lambda: processors.available_processors(), default=processors.get_default_processor)
|
||||
# Only the Add-Watch page renders this; the watch-list quick-add posts nothing, which
|
||||
# leaves the new watch on 'system' exactly as before.
|
||||
#
|
||||
# A radio list rather than a dropdown: fetcher descriptions run long (they include the
|
||||
# driver URL) and a wrapping label reads fine in a narrow pane, where a <select> would
|
||||
# either overflow or need truncating.
|
||||
#
|
||||
# choices is only what the Add-Watch page *offers* (browsers that can render a live
|
||||
# preview), so validate_choice has to stay off: this same endpoint legitimately receives
|
||||
# any installed backend from the watch-list quick-add, and pre_validate() would reject
|
||||
# e.g. 'html_requests' for not being in the offered list.
|
||||
fetch_backend = RadioField(_l('Browser'),
|
||||
choices=visual_browser_choices,
|
||||
validate_choice=False,
|
||||
validators=[ValidateKnownContentFetcher()])
|
||||
edit_and_watch_submit_button = SubmitField(_l('Edit > Watch'), render_kw={"class": "pure-button pure-button-primary"})
|
||||
|
||||
|
||||
@@ -944,7 +876,6 @@ class SingleBrowserStep(Form):
|
||||
class processor_text_json_diff_form(commonSettingsForm):
|
||||
|
||||
url = StringField(_l('Web Page URL'), validators=[validateURL()])
|
||||
link_to_open = StringField(_l('Open Link Override'), validators=[validators.Optional(), validateURL()], default='')
|
||||
tags = StringTagUUID(_l('Group Tag'), [validators.Optional()], default='')
|
||||
|
||||
time_between_check = EnhancedFormField(
|
||||
@@ -959,7 +890,7 @@ class processor_text_json_diff_form(commonSettingsForm):
|
||||
|
||||
time_between_check_use_default = BooleanField(_l('Use global settings for time between check and scheduler.'), default=False)
|
||||
|
||||
llm_intent = TextAreaField(_l('AI Change Intent - Notify me when..'), validators=[validators.Optional(), validators.Length(max=2000)],
|
||||
llm_intent = TextAreaField(_l('AI Change Intent'), validators=[validators.Optional(), validators.Length(max=2000)],
|
||||
render_kw={"rows": "5", "placeholder": LLM_INTENT_WATCH_PLACEHOLDER})
|
||||
|
||||
llm_change_summary = TextAreaField(_l('AI Change Summary'), validators=[validators.Optional(), validators.Length(max=2000)],
|
||||
@@ -967,16 +898,13 @@ class processor_text_json_diff_form(commonSettingsForm):
|
||||
default='')
|
||||
|
||||
llm_change_summary_mode = RadioField(
|
||||
_l('Change Summary prompt - Append or Replace the default?'),
|
||||
_l('How this prompt combines with the inherited one'),
|
||||
choices=[
|
||||
(LLM_PROMPT_MODE_REPLACE, _l('Replace the inherited prompt')),
|
||||
(LLM_PROMPT_MODE_APPEND, _l('Append to the inherited prompt')),
|
||||
],
|
||||
default=LLM_PROMPT_MODE_REPLACE,
|
||||
)
|
||||
# @NOTE! In the near future you should be able to select which LLM profile *OR* "off"/None for this watch/group
|
||||
# For now we use the 'future' field naming but keep the functionality simple.
|
||||
llm_backend_profile = BooleanField(_l('AI enabled for this watch?'), default=True)
|
||||
|
||||
include_filters = StringListField(_l('CSS/JSONPath/JQ/XPath Filters'), [ValidateCSSJSONXPATHInput()], default='')
|
||||
|
||||
@@ -1052,19 +980,6 @@ class processor_text_json_diff_form(commonSettingsForm):
|
||||
self.url.errors.append(gettext('Invalid template syntax: %(error)s') % {'error': e})
|
||||
result = False
|
||||
|
||||
# Attempt to validate jinja2 templates in the optional "Link to Open"
|
||||
if self.link_to_open.data and self.link_to_open.data.strip():
|
||||
try:
|
||||
jinja_render(template_str=self.link_to_open.data)
|
||||
except ModuleNotFoundError as e:
|
||||
logger.error(e)
|
||||
self.link_to_open.errors.append(gettext('Invalid template syntax configuration: %(error)s') % {'error': e})
|
||||
result = False
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
self.link_to_open.errors.append(gettext('Invalid template syntax: %(error)s') % {'error': e})
|
||||
result = False
|
||||
|
||||
# Attempt to validate jinja2 templates in the body
|
||||
if self.body.data and self.body.data.strip():
|
||||
try:
|
||||
@@ -1179,13 +1094,13 @@ class globalSettingsApplicationUIForm(Form):
|
||||
socket_io_enabled = BooleanField(_l('Realtime UI Updates Enabled'), default=True, validators=[validators.Optional()])
|
||||
favicons_enabled = BooleanField(_l('Favicons Enabled'), default=True, validators=[validators.Optional()])
|
||||
use_page_title_in_list = BooleanField(_l('Use page <title> in watch overview list')) #BooleanField=True
|
||||
use_share_watch = BooleanField(_l('Enable watch "sharing"'))
|
||||
timeago_format = SelectField(_l('Relative time format'),
|
||||
choices=[('long', _l('Long (1 minute ago)')), ('short', _l('Short (1m ago)'))],
|
||||
default='long', validators=[validators.Optional()])
|
||||
sidebar_mode = SelectField(_l('Navigation sidebar'),
|
||||
choices=MENU_SIDEBAR_ACTIONMODES,
|
||||
default=MENU_SIDEBAR_ACTIONMODES_DEFAULT, validators=[validators.Optional()])
|
||||
choices=[('collapsed', _l('Collapsed icon rail (expands on hover)')),
|
||||
('pinned', _l('Always expanded'))],
|
||||
default='collapsed', validators=[validators.Optional()])
|
||||
|
||||
# datastore.data['settings']['application']..
|
||||
class globalSettingsApplicationForm(commonSettingsForm):
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
"""
|
||||
Debounced explicit garbage collection for the watch-check hot path.
|
||||
|
||||
Several places call gc.collect() after a check to keep C-level memory (pyppeteer buffers,
|
||||
libxml2 documents, PIL, brotli) from accumulating. Individually each is reasonable. Run
|
||||
concurrently by many fetch workers they become a storm: with FETCH_WORKERS=50 and roughly
|
||||
five call sites per check, the process spends most of its time stopped in the collector,
|
||||
because every gc.collect() is a full stop-the-world pass that walks the entire heap while
|
||||
holding the GIL.
|
||||
|
||||
Measured on 153 real puppeteer checks of a live site, FETCH_WORKERS=50, an `//div` filter
|
||||
so the lxml document tree is realistic:
|
||||
|
||||
collects objects freed gc time checks/sec CPU/check RSS plateau
|
||||
one per call site 790 2,594,098 91.3s 0.766 1.373s 279.6MB
|
||||
debounced to 1s 48 2,219,010 7.3s 1.433 0.731s 275.3MB
|
||||
none at all 0 0 0.0s 1.503 0.685s 287.7MB
|
||||
|
||||
Debouncing keeps 86% of the reclamation for 6% of the collections, and resident memory
|
||||
ends up LOWER than collecting every time. It works because the collector is process-wide:
|
||||
any worker's collection breaks every other worker's cycles too, so with many workers the
|
||||
calls are overwhelmingly redundant duplicates rather than independently necessary.
|
||||
|
||||
Removing them entirely was also measured. It is slightly faster still, but it was the only
|
||||
configuration whose RSS had not plateaued by the end of the run, so it is not offered.
|
||||
|
||||
Collecting a younger generation was measured and rejected: gen 0 freed 1,136 objects
|
||||
against the full pass's 2,594,098, because objects that survive a 10-30 second fetch have
|
||||
already been promoted out of gen 0. It is cheap because it does almost nothing.
|
||||
|
||||
Environment:
|
||||
EXPLICIT_GC_MIN_INTERVAL seconds between explicit collections, process-wide.
|
||||
Default 1.0. Set 0 to collect at every call site as before.
|
||||
EXPLICIT_GC_COLLECT set false to disable explicit collection entirely. A
|
||||
measurement switch for attributing a slowdown, not a
|
||||
recommended setting - expect resident memory to drift.
|
||||
"""
|
||||
|
||||
import gc
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
from changedetectionio.strtobool import strtobool
|
||||
|
||||
ENABLED = strtobool(os.getenv('EXPLICIT_GC_COLLECT', 'true'))
|
||||
MIN_INTERVAL = float(os.getenv('EXPLICIT_GC_MIN_INTERVAL', '1.0') or 0)
|
||||
|
||||
_last_collect = 0.0
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def collect(where=None):
|
||||
"""Explicit collection for the per-check hot path, rate-limited process-wide.
|
||||
|
||||
`where` is a short label for the call site, kept so callers read clearly and so a
|
||||
future caller can log it. Returns the number of objects collected, or 0 when the call
|
||||
was debounced or disabled - matching gc.collect()'s return, so this is a drop-in
|
||||
replacement for it.
|
||||
"""
|
||||
global _last_collect
|
||||
|
||||
if not ENABLED:
|
||||
return 0
|
||||
|
||||
if MIN_INTERVAL > 0:
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
if now - _last_collect < MIN_INTERVAL:
|
||||
return 0
|
||||
_last_collect = now
|
||||
|
||||
return gc.collect()
|
||||
@@ -164,26 +164,7 @@ _DEFAULT_UNSAFE_XPATH3_FUNCTIONS = [
|
||||
]
|
||||
|
||||
|
||||
# XPath 3.1 says the default collation is Unicode codepoint collation. elementpath instead leaves
|
||||
# its collation functions pointing at locale.strxfrm / locale.strcoll, so the process-wide
|
||||
# LC_COLLATE decides what contains() means:
|
||||
#
|
||||
# def contains(self, a, b): return self.strxfrm(b) in self.strxfrm(a)
|
||||
#
|
||||
# With LC_COLLATE=C, strxfrm() is the identity and that is an ordinary substring test. With
|
||||
# LC_COLLATE=en_US.UTF-8 it returns a binary collation key, and a substring of a collation key is
|
||||
# not the collation key of the substring - so contains(), starts-with(), ends-with() and
|
||||
# substring-before/after() return false for every input, and a filter that matches 67 elements
|
||||
# matches 0 (#4437). Name tests, axes and '=' are unaffected, which is what made it look like the
|
||||
# page had changed layout.
|
||||
#
|
||||
# flask_app.py keeps LC_COLLATE in "C" for this reason, but a filter must not depend on a distant
|
||||
# module's locale bookkeeping, nor on what an operator puts in LANG/LC_ALL. Pinning the collation
|
||||
# per evaluation makes the filter mean the same thing in every deployment.
|
||||
XPATH_CODEPOINT_COLLATION = 'http://www.w3.org/2005/xpath-functions/collation/codepoint'
|
||||
|
||||
|
||||
def get_safe_xpath3_parser():
|
||||
def _build_safe_xpath3_parser():
|
||||
"""Return an XPath3Parser subclass with filesystem/environment access functions removed.
|
||||
|
||||
XPath 3.0 includes functions that can read arbitrary files or environment variables:
|
||||
@@ -215,6 +196,9 @@ def get_safe_xpath3_parser():
|
||||
return SafeXPath3Parser
|
||||
|
||||
|
||||
# Module-level singleton — built once, reused everywhere.
|
||||
SafeXPath3Parser = _build_safe_xpath3_parser()
|
||||
|
||||
# Doesn't look like python supports forward slash auto enclosure in re.findall
|
||||
# So convert it to inline flag "(?i)foobar" type configuration
|
||||
@lru_cache(maxsize=100)
|
||||
@@ -402,9 +386,7 @@ def xpath_filter(xpath_filter, html_content, append_pretty_line_formatting=False
|
||||
# This allows //title to match elements in the default namespace
|
||||
namespaces[''] = tree.nsmap[None]
|
||||
|
||||
r = elementpath.select(tree, xpath_filter.strip(), namespaces=namespaces,
|
||||
parser=get_safe_xpath3_parser(),
|
||||
default_collation=XPATH_CODEPOINT_COLLATION)
|
||||
r = elementpath.select(tree, xpath_filter.strip(), namespaces=namespaces, parser=SafeXPath3Parser)
|
||||
#@note: //title/text() now works with default namespaces (fixed by registering '' prefix)
|
||||
#@note: //title/text() wont work where <title>CDATA.. (use cdata_in_document_to_text first)
|
||||
|
||||
@@ -526,17 +508,11 @@ def _has_lone_surrogate(value):
|
||||
return any(_has_lone_surrogate(v) for v in value)
|
||||
return False
|
||||
|
||||
def _sanitize_lone_surrogate_str(value: str) -> str:
|
||||
return _LONE_SURROGATE_RE.sub('\ufffd', value)
|
||||
|
||||
def _sanitize_lone_surrogates(value):
|
||||
if isinstance(value, str):
|
||||
return _sanitize_lone_surrogate_str(value)
|
||||
return _LONE_SURROGATE_RE.sub('\ufffd', value)
|
||||
if isinstance(value, dict):
|
||||
# JSON object keys are always strings, so they only need the substitution - recursing on a
|
||||
# key would (in theory) hand back an unhashable dict/list
|
||||
return {_sanitize_lone_surrogate_str(k) if isinstance(k, str) else k: _sanitize_lone_surrogates(v)
|
||||
for k, v in value.items()}
|
||||
return {_sanitize_lone_surrogates(k): _sanitize_lone_surrogates(v) for k, v in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_sanitize_lone_surrogates(v) for v in value]
|
||||
return value
|
||||
|
||||
@@ -80,6 +80,7 @@ Note:
|
||||
This extension uses the Arrow library for timezone-aware datetime handling.
|
||||
All timezone names should be valid IANA timezone identifiers (e.g., 'America/New_York').
|
||||
"""
|
||||
import arrow
|
||||
|
||||
from jinja2 import nodes
|
||||
from jinja2.ext import Extension
|
||||
@@ -124,7 +125,6 @@ class TimeExtension(Extension):
|
||||
_datetime('UTC', '+', 'hours=2,minutes=30', '%Y-%m-%d %H:%M:%S')
|
||||
# Returns current time + 2.5 hours
|
||||
"""
|
||||
import arrow
|
||||
# Use default timezone if none specified
|
||||
if not timezone or timezone == '':
|
||||
timezone = self.environment.default_timezone
|
||||
@@ -162,7 +162,6 @@ class TimeExtension(Extension):
|
||||
_now('America/New_York', '%Y-%m-%d %H:%M:%S')
|
||||
# Returns current time in New York timezone
|
||||
"""
|
||||
import arrow
|
||||
# Use default timezone if none specified
|
||||
if not timezone or timezone == '':
|
||||
timezone = self.environment.default_timezone
|
||||
|
||||
@@ -19,7 +19,6 @@ def get_timeago_locale(flask_locale, short=False):
|
||||
- Swedish: Flask uses 'sv', timeago uses 'sv_SE'
|
||||
- Norwegian: Flask uses 'no', timeago uses 'nb_NO' or 'nn_NO'
|
||||
- Hindi: Flask uses 'hi', timeago uses 'in_HI'
|
||||
- Indonesian: Flask uses 'id', timeago uses 'in_ID'
|
||||
- Czech: Flask uses 'cs', but timeago doesn't support Czech - fallback to English
|
||||
|
||||
Args:
|
||||
@@ -47,7 +46,6 @@ def get_timeago_locale(flask_locale, short=False):
|
||||
'sv': 'sv_SE', # Swedish
|
||||
'no': 'nb_NO', # Norwegian Bokmål
|
||||
'hi': 'in_HI', # Hindi
|
||||
'id': 'in_ID', # Indonesian
|
||||
'cs': 'en', # Czech not supported by timeago, fallback to English
|
||||
'ja': 'ja', # Japanese
|
||||
'uk': 'uk', # Ukrainian
|
||||
@@ -158,7 +156,6 @@ LANGUAGE_DATA = {
|
||||
'tr': {'flag': 'fi fi-tr fis', 'name': 'Türkçe'},
|
||||
'ar': {'flag': 'fi fi-sa fis', 'name': 'العربية'},
|
||||
'hi': {'flag': 'fi fi-in fis', 'name': 'हिन्दी'},
|
||||
'id': {'flag': 'fi fi-id fis', 'name': 'Bahasa Indonesia'},
|
||||
'uk': {'flag': 'fi fi-ua fis', 'name': 'Українська'},
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ and makes the call easy to mock in tests.
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from loguru import logger
|
||||
|
||||
# Default output token cap for JSON-returning calls (intent eval, preview, setup).
|
||||
@@ -25,9 +24,6 @@ DEFAULT_TIMEOUT = int(os.getenv('LLM_TIMEOUT', 300))
|
||||
# longer deadline (Hermes-style, 30 min). Overridable via LLM_LOCAL_TIMEOUT; see
|
||||
# evaluator.resolve_llm_timeout() for how the endpoint is classified.
|
||||
DEFAULT_LOCAL_TIMEOUT = int(os.getenv('LLM_LOCAL_TIMEOUT', 1800))
|
||||
# Models and reasoning architectures that reject explicit sampling parameters (temperature/top_p)
|
||||
_NO_TEMPERATURE_MODEL_KEYWORDS = ('flash-lite', 'thinking-exp', 'o1', 'o3', 'o4')
|
||||
|
||||
DEFAULT_RETRIES = 3
|
||||
|
||||
|
||||
@@ -67,16 +63,10 @@ def _install_litellm_debug():
|
||||
logger.info("LLM client: litellm debug logging routed through loguru")
|
||||
|
||||
|
||||
def completion( # noqa: C901
|
||||
model: str,
|
||||
messages: list,
|
||||
api_key: str = None,
|
||||
api_base: str = None,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
max_tokens: int = None,
|
||||
extra_body: dict = None,
|
||||
debug: bool = False,
|
||||
) -> tuple[str, int, int, int]:
|
||||
def completion(model: str, messages: list, api_key: str = None,
|
||||
api_base: str = None, timeout: int = DEFAULT_TIMEOUT,
|
||||
max_tokens: int = None, extra_body: dict = None,
|
||||
debug: bool = False) -> tuple[str, int, int, int]:
|
||||
"""
|
||||
Call the LLM and return (response_text, total_tokens, input_tokens, output_tokens).
|
||||
Retries up to DEFAULT_RETRIES times on timeout or connection errors.
|
||||
@@ -89,7 +79,7 @@ def completion( # noqa: C901
|
||||
try:
|
||||
import litellm
|
||||
except ImportError:
|
||||
raise RuntimeError("litellm is not installed. Add it to requirements.txt.") from None
|
||||
raise RuntimeError("litellm is not installed. Add it to requirements.txt.")
|
||||
|
||||
if debug:
|
||||
_install_litellm_debug()
|
||||
@@ -100,12 +90,9 @@ def completion( # noqa: C901
|
||||
'model': model,
|
||||
'messages': messages,
|
||||
'timeout': _timeout,
|
||||
'temperature': 0,
|
||||
'max_tokens': max_tokens if max_tokens is not None else _MAX_COMPLETION_TOKENS,
|
||||
}
|
||||
_m_lower = (model or '').lower()
|
||||
if not any(k in _m_lower for k in _NO_TEMPERATURE_MODEL_KEYWORDS):
|
||||
kwargs['temperature'] = 0
|
||||
|
||||
if api_key:
|
||||
kwargs['api_key'] = api_key
|
||||
if api_base:
|
||||
@@ -135,9 +122,9 @@ def completion( # noqa: C901
|
||||
attempt += 1
|
||||
try:
|
||||
response = litellm.completion(**kwargs)
|
||||
choice = response.choices[0]
|
||||
message = choice.message
|
||||
finish = getattr(choice, 'finish_reason', None)
|
||||
choice = response.choices[0]
|
||||
message = choice.message
|
||||
finish = getattr(choice, 'finish_reason', None)
|
||||
|
||||
text = message.content or ''
|
||||
|
||||
@@ -146,9 +133,7 @@ def completion( # noqa: C901
|
||||
parts = getattr(message, 'parts', None)
|
||||
if parts:
|
||||
text = ''.join(getattr(p, 'text', '') or '' for p in parts).strip()
|
||||
logger.debug(
|
||||
f"LLM client: extracted text from message.parts ({len(parts)} parts) model={model!r}"
|
||||
)
|
||||
logger.debug(f"LLM client: extracted text from message.parts ({len(parts)} parts) model={model!r}")
|
||||
|
||||
if finish == 'length':
|
||||
logger.warning(
|
||||
@@ -164,13 +149,9 @@ def completion( # noqa: C901
|
||||
)
|
||||
|
||||
usage = getattr(response, 'usage', None)
|
||||
input_tokens = int(getattr(usage, 'prompt_tokens', 0) or 0) if usage else 0
|
||||
input_tokens = int(getattr(usage, 'prompt_tokens', 0) or 0) if usage else 0
|
||||
output_tokens = int(getattr(usage, 'completion_tokens', 0) or 0) if usage else 0
|
||||
total_tokens = (
|
||||
int(getattr(usage, 'total_tokens', 0) or 0)
|
||||
if usage
|
||||
else (input_tokens + output_tokens)
|
||||
)
|
||||
total_tokens = int(getattr(usage, 'total_tokens', 0) or 0) if usage else (input_tokens + output_tokens)
|
||||
logger.debug(
|
||||
f"LLM client: model={model!r} finish={finish!r} "
|
||||
f"tokens={total_tokens} (in={input_tokens} out={output_tokens}) "
|
||||
@@ -200,37 +181,21 @@ def completion( # noqa: C901
|
||||
raise
|
||||
|
||||
except litellm.BadRequestError as e:
|
||||
# If the provider rejected an unsupported sampling param or extra_body
|
||||
# (e.g. Gemini INVALID_ARGUMENT on thinkingConfig or temperature), drop
|
||||
# them and retry once.
|
||||
if not _stripped_sampling:
|
||||
# If the provider rejected an unsupported sampling param (and we haven't
|
||||
# already stripped them), drop them and retry once. attempt-=1 keeps this
|
||||
# off the timeout-retry budget; _stripped_sampling prevents a loop.
|
||||
msg = str(e).lower()
|
||||
if (not _stripped_sampling
|
||||
and any(p in kwargs for p in _sampling_params)
|
||||
and any(p in msg for p in _sampling_params)):
|
||||
dropped = [p for p in _sampling_params if kwargs.pop(p, None) is not None]
|
||||
extra_body = kwargs.get('extra_body')
|
||||
if isinstance(extra_body, dict):
|
||||
gen_cfg = extra_body.get('generationConfig')
|
||||
if (
|
||||
isinstance(gen_cfg, dict)
|
||||
and gen_cfg.pop('thinkingConfig', None) is not None
|
||||
):
|
||||
dropped.append('thinkingConfig')
|
||||
if not gen_cfg:
|
||||
extra_body.pop('generationConfig', None)
|
||||
if not extra_body:
|
||||
kwargs.pop('extra_body', None)
|
||||
elif 'thinkingConfig' in extra_body:
|
||||
extra_body.pop('thinkingConfig', None)
|
||||
dropped.append('thinkingConfig')
|
||||
if not extra_body:
|
||||
kwargs.pop('extra_body', None)
|
||||
|
||||
if dropped:
|
||||
_stripped_sampling = True
|
||||
attempt -= 1
|
||||
logger.warning(
|
||||
f"LLM client: model={model!r} rejected request ({e}); "
|
||||
f"stripped {dropped} and retrying once"
|
||||
)
|
||||
continue
|
||||
_stripped_sampling = True
|
||||
attempt -= 1
|
||||
logger.warning(
|
||||
f"LLM client: model={model!r} rejected sampling params {dropped} "
|
||||
f"({e}); retrying without them"
|
||||
)
|
||||
continue
|
||||
logger.warning(f"LLM call failed: model={model!r} error={e}")
|
||||
raise
|
||||
|
||||
|
||||
@@ -5,8 +5,7 @@ Two public entry points:
|
||||
- run_setup(watch, datastore) — one-time: decide if pre-filter needed
|
||||
- evaluate_change(watch, datastore, diff, current_snapshot) — per-change evaluation
|
||||
|
||||
Intent resolution: watch.llm_intent → first tag with llm_intent whose AI switch is "on"
|
||||
(see tag_llm_applies_to_watches) → None (no evaluation)
|
||||
Intent resolution: watch.llm_intent → first tag with llm_intent → None (no evaluation)
|
||||
Cache: each (intent, diff) pair is evaluated exactly once, result stored in watch.
|
||||
|
||||
Environment variable overrides (take priority over datastore settings):
|
||||
@@ -98,8 +97,6 @@ def _thinking_extra_body(model: str, budget: int) -> dict | None:
|
||||
"""
|
||||
if not model.startswith('gemini/'):
|
||||
return None
|
||||
if 'flash-lite' in model.lower():
|
||||
return None
|
||||
try:
|
||||
import litellm
|
||||
if not litellm.get_model_info(model).get('supports_reasoning'):
|
||||
@@ -247,70 +244,6 @@ def resolve_llm_timeout(llm_cfg: dict) -> int:
|
||||
# Intent resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# A group/tag has exactly one AI control (`llm_backend_profile`), and it is ternary:
|
||||
#
|
||||
# True — AI on for every watch in the group, using the group's AI settings
|
||||
# (llm_intent / llm_change_summary cascade down to the watches)
|
||||
# False — AI off for every watch in the group; its prompts are stored but never used
|
||||
# None — the group has no opinion: each watch's own AI switch and prompts apply
|
||||
#
|
||||
# On a *watch* the same key is a plain bool (on/off, default on). One control per level, so
|
||||
# there is nothing to reconcile between an "override?" flag and an "enabled?" flag.
|
||||
def tag_llm_decision(tag):
|
||||
"""This group's AI decision: True (on, use its settings), False (off), or None (no opinion)."""
|
||||
if not tag:
|
||||
return None
|
||||
value = tag.get('llm_backend_profile')
|
||||
return None if value is None else bool(value)
|
||||
|
||||
|
||||
def tag_llm_applies_to_watches(tag) -> bool:
|
||||
"""True when this group hands its AI settings down to its watches.
|
||||
|
||||
Only the "on" state does that: a group set to "off" suppresses AI for its watches rather
|
||||
than lending them prompts, and a group with no opinion leaves them alone entirely. This is
|
||||
the single gate behind both the evaluator cascade and the watch edit page's
|
||||
"From group ..." placeholder.
|
||||
"""
|
||||
return tag_llm_decision(tag) is True
|
||||
|
||||
|
||||
def _watch_tags(watch, datastore):
|
||||
"""Yield this watch's tag dicts, in the watch's own tag order, skipping unknown UUIDs."""
|
||||
for tag_uuid in watch.get('tags', []):
|
||||
tag = datastore.data['settings']['application'].get('tags', {}).get(tag_uuid)
|
||||
if tag:
|
||||
yield tag
|
||||
|
||||
|
||||
def _tags_applying_llm(watch, datastore):
|
||||
"""Yield this watch's groups, in order, that hand their AI settings to their watches."""
|
||||
for tag in _watch_tags(watch, datastore):
|
||||
if tag_llm_applies_to_watches(tag):
|
||||
yield tag
|
||||
|
||||
|
||||
def llm_enabled_for_watch(watch, datastore) -> tuple[bool, str]:
|
||||
"""Is automatic AI evaluation switched on for this watch? Returns (enabled, source).
|
||||
|
||||
See #4204 — users with hundreds of watches want AI on only a select few.
|
||||
|
||||
A group with an opinion decides for all of its watches ("the group setting overrides any
|
||||
watch on/off"), so the first such group wins over the watch's own switch; groups set to
|
||||
"leave it to each watch" are skipped. With no group deciding, the watch decides — and a
|
||||
missing key means on, so watches predating this switch keep working.
|
||||
|
||||
Only gates *automatic* spend (the worker's intent/summary passes and the restock AI
|
||||
plugin). Explicit user actions — the diff page "Summary" button, the intent preview —
|
||||
stay available, since those cost tokens only when someone deliberately clicks.
|
||||
"""
|
||||
for tag in _watch_tags(watch, datastore):
|
||||
decision = tag_llm_decision(tag)
|
||||
if decision is not None:
|
||||
return decision, tag.get('title', 'tag')
|
||||
return bool(watch.get('llm_backend_profile', True)), 'watch'
|
||||
|
||||
|
||||
def resolve_llm_field(watch, datastore, field: str) -> tuple[str, str]:
|
||||
"""
|
||||
Generic cascade resolver for any LLM per-watch field.
|
||||
@@ -321,10 +254,12 @@ def resolve_llm_field(watch, datastore, field: str) -> tuple[str, str]:
|
||||
if value:
|
||||
return value, 'watch'
|
||||
|
||||
for tag in _tags_applying_llm(watch, datastore):
|
||||
tag_value = (tag.get(field) or '').strip()
|
||||
if tag_value:
|
||||
return tag_value, tag.get('title', 'tag')
|
||||
for tag_uuid in watch.get('tags', []):
|
||||
tag = datastore.data['settings']['application'].get('tags', {}).get(tag_uuid)
|
||||
if tag:
|
||||
tag_value = (tag.get(field) or '').strip()
|
||||
if tag_value:
|
||||
return tag_value, tag.get('title', 'tag')
|
||||
|
||||
return '', ''
|
||||
|
||||
@@ -338,10 +273,12 @@ def resolve_intent(watch, datastore) -> tuple[str, str]:
|
||||
if intent:
|
||||
return intent, 'watch'
|
||||
|
||||
for tag in _tags_applying_llm(watch, datastore):
|
||||
tag_intent = (tag.get('llm_intent') or '').strip()
|
||||
if tag_intent:
|
||||
return tag_intent, tag.get('title', 'tag')
|
||||
for tag_uuid in watch.get('tags', []):
|
||||
tag = datastore.data['settings']['application'].get('tags', {}).get(tag_uuid)
|
||||
if tag:
|
||||
tag_intent = (tag.get('llm_intent') or '').strip()
|
||||
if tag_intent:
|
||||
return tag_intent, tag.get('title', 'tag')
|
||||
|
||||
return '', ''
|
||||
|
||||
@@ -612,14 +549,15 @@ def run_setup(watch, datastore, snapshot_text: str) -> None:
|
||||
def _first_tag_with_field(watch, datastore, field: str):
|
||||
"""Return (value, tag) for the first linked tag with a non-empty `field`, else ('', None).
|
||||
|
||||
Same first-match-wins order as resolve_llm_field() (so only groups opted in via
|
||||
tag_llm_applies_to_watches() count); this variant also hands back the tag itself so
|
||||
the caller can read sibling keys such as the prompt mode.
|
||||
Same first-match-wins order as resolve_llm_field(); this variant also hands back the
|
||||
tag itself so the caller can read sibling keys such as the prompt mode.
|
||||
"""
|
||||
for tag in _tags_applying_llm(watch, datastore):
|
||||
value = (tag.get(field) or '').strip()
|
||||
if value:
|
||||
return value, tag
|
||||
for tag_uuid in watch.get('tags', []):
|
||||
tag = datastore.data['settings']['application'].get('tags', {}).get(tag_uuid)
|
||||
if tag:
|
||||
value = (tag.get(field) or '').strip()
|
||||
if value:
|
||||
return value, tag
|
||||
return '', None
|
||||
|
||||
|
||||
|
||||
@@ -9,44 +9,39 @@ from .bm25_trim import trim_to_relevant
|
||||
|
||||
_AGO_RE = re.compile(r'^\d+\s+\w+\s+ago$', re.IGNORECASE)
|
||||
|
||||
SNAPSHOT_CONTEXT_CHARS = 30_000 # current page state excerpt sent alongside the diff
|
||||
SNAPSHOT_CONTEXT_CHARS = 30_000 # current page state excerpt sent alongside the diff
|
||||
|
||||
|
||||
def _annotate_moved_lines(diff_text: str) -> str:
|
||||
"""
|
||||
Pre-process a unified diff to mark lines that appear on both the + and - sides
|
||||
as [MOVED] (~ prefix) rather than genuinely added/removed. This prevents the LLM from
|
||||
as [MOVED] rather than genuinely added/removed. This prevents the LLM from
|
||||
incorrectly classifying repositioned content as new or deleted.
|
||||
Also marks standalone relative timestamps (e.g. '3 hours ago') as ~ trivial.
|
||||
|
||||
Lines are compared after stripping leading +/- and whitespace so that
|
||||
indentation changes don't prevent matching.
|
||||
"""
|
||||
lines = diff_text.splitlines()
|
||||
added_texts = {
|
||||
line[1:].strip().lower() for line in lines if line.startswith('+') and line[1:].strip()
|
||||
}
|
||||
removed_texts = {
|
||||
line[1:].strip().lower() for line in lines if line.startswith('-') and line[1:].strip()
|
||||
}
|
||||
moved_texts = added_texts & removed_texts
|
||||
added_texts = {l[1:].strip().lower() for l in lines if l.startswith('+') and l[1:].strip()}
|
||||
removed_texts = {l[1:].strip().lower() for l in lines if l.startswith('-') and l[1:].strip()}
|
||||
moved_texts = added_texts & removed_texts
|
||||
|
||||
if not moved_texts:
|
||||
return diff_text
|
||||
|
||||
result = []
|
||||
has_changes = False
|
||||
for line in lines:
|
||||
if line.startswith(('+', '-')):
|
||||
bare = line[1:].strip().lower()
|
||||
if bare in moved_texts or _AGO_RE.match(line[1:].strip()):
|
||||
result.append(f'~{line[1:]}') # ~ prefix = moved/reordered/trivial, skip
|
||||
has_changes = True
|
||||
continue
|
||||
result.append(line)
|
||||
return '\n'.join(result) if has_changes else diff_text
|
||||
return '\n'.join(result)
|
||||
|
||||
|
||||
def build_eval_prompt(
|
||||
intent: str, diff: str, current_snapshot: str = '', url: str = '', title: str = ''
|
||||
) -> str:
|
||||
def build_eval_prompt(intent: str, diff: str, current_snapshot: str = '',
|
||||
url: str = '', title: str = '') -> str:
|
||||
"""
|
||||
Build the user message for a diff evaluation call.
|
||||
The system prompt is kept separate (see build_eval_system_prompt).
|
||||
@@ -136,9 +131,8 @@ def build_preview_system_prompt() -> str:
|
||||
)
|
||||
|
||||
|
||||
def build_change_summary_prompt(
|
||||
diff: str, custom_prompt: str, current_snapshot: str = '', url: str = '', title: str = ''
|
||||
) -> str:
|
||||
def build_change_summary_prompt(diff: str, custom_prompt: str,
|
||||
current_snapshot: str = '', url: str = '', title: str = '') -> str:
|
||||
"""
|
||||
Build the user message for an AI Change Summary call.
|
||||
The user supplies their own instructions (custom_prompt); this wraps them
|
||||
|
||||
@@ -9,56 +9,16 @@ text. This module handles those cases gracefully.
|
||||
import json
|
||||
import re
|
||||
|
||||
from changedetectionio.strtobool import strtobool
|
||||
|
||||
# Positional selectors are fragile — reject them even if the LLM generates them
|
||||
_POSITIONAL_SELECTOR_RE = re.compile(
|
||||
r'nth-child|nth-of-type|:eq\(|\[\d+\]|\/\/\*\[\d', re.IGNORECASE
|
||||
r'nth-child|nth-of-type|:eq\(|\[\d+\]|\/\/\*\[\d',
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
# Reasoning models (DeepSeek-R1, Qwen reasoning, etc.) wrap their scratchpad in <think> tags.
|
||||
# Three shapes have to be handled, because the scratchpad routinely contains JSON of its own
|
||||
# ("initially I thought {"important": false}, but..."), so leaving any of it in place lets
|
||||
# _extract_json lock onto a discarded intermediate answer instead of the real one.
|
||||
_THINK_BLOCK_RE = re.compile(r'<think(?:ing)?>.*?</think(?:ing)?>', re.DOTALL | re.IGNORECASE)
|
||||
_THINK_TAIL_RE = re.compile(r'^.*</think(?:ing)?>', re.DOTALL | re.IGNORECASE)
|
||||
_THINK_OPEN_RE = re.compile(r'<think(?:ing)?>', re.IGNORECASE)
|
||||
|
||||
|
||||
def _to_bool(value, default: bool = False) -> bool:
|
||||
"""Safely coerce boolean values from LLM responses.
|
||||
|
||||
Handles native booleans, truthy/falsy integers (1/0), and string booleans
|
||||
("true", "false", "yes", "no", "1", "0") using strtobool.
|
||||
Avoids Python's bool("false") -> True bug on stringified JSON booleans.
|
||||
"""
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return strtobool(value)
|
||||
except (ValueError, AttributeError):
|
||||
return default
|
||||
|
||||
|
||||
def _extract_json(raw: str) -> str:
|
||||
"""Strip reasoning blocks, markdown fences, and extract the first JSON object.
|
||||
|
||||
Raises:
|
||||
ValueError: the response opens a reasoning block it never closes, i.e. it was cut
|
||||
off mid-thought (usually by max_tokens) and contains no answer at all. Callers
|
||||
in evaluator.py catch this and fall back safely - for diff evaluation that
|
||||
means passing the change through as important rather than silently dropping it.
|
||||
"""
|
||||
"""Strip markdown fences and extract the first JSON object."""
|
||||
raw = raw.strip()
|
||||
# Well-formed scratchpads.
|
||||
raw = _THINK_BLOCK_RE.sub('', raw).strip()
|
||||
# Some providers/chat templates emit the opening tag themselves and only the closer comes
|
||||
# back over the wire, so anything up to the last closer is still scratchpad.
|
||||
raw = _THINK_TAIL_RE.sub('', raw).strip()
|
||||
# An opener with no closer means the response was truncated part-way through reasoning.
|
||||
# There is no answer to find; the only JSON present would be a discarded intermediate one.
|
||||
if _THINK_OPEN_RE.search(raw):
|
||||
raise ValueError('LLM response contains an unterminated reasoning block (truncated?)')
|
||||
# Remove ```json ... ``` or ``` ... ``` fences
|
||||
raw = re.sub(r'^```(?:json)?\s*', '', raw, flags=re.MULTILINE)
|
||||
raw = re.sub(r'\s*```$', '', raw, flags=re.MULTILINE)
|
||||
@@ -76,7 +36,7 @@ def parse_eval_response(raw: str) -> dict:
|
||||
try:
|
||||
data = json.loads(_extract_json(raw))
|
||||
return {
|
||||
'important': _to_bool(data.get('important'), default=False),
|
||||
'important': bool(data.get('important', False)),
|
||||
'summary': str(data.get('summary', '')).strip(),
|
||||
}
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
@@ -92,7 +52,7 @@ def parse_preview_response(raw: str) -> dict:
|
||||
try:
|
||||
data = json.loads(_extract_json(raw))
|
||||
return {
|
||||
'found': _to_bool(data.get('found'), default=False),
|
||||
'found': bool(data.get('found', False)),
|
||||
'answer': str(data.get('answer', '')).strip(),
|
||||
}
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
@@ -107,7 +67,7 @@ def parse_setup_response(raw: str) -> dict:
|
||||
"""
|
||||
try:
|
||||
data = json.loads(_extract_json(raw))
|
||||
needs = _to_bool(data.get('needs_prefilter'), default=False)
|
||||
needs = bool(data.get('needs_prefilter', False))
|
||||
selector = data.get('selector') or None
|
||||
|
||||
# Sanitise: reject positional selectors
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from os import getenv
|
||||
from copy import deepcopy
|
||||
|
||||
from changedetectionio.blueprint.menu_modes import MENU_SIDEBAR_ACTIONMODES_DEFAULT
|
||||
from changedetectionio.blueprint.rss import RSS_FORMAT_TYPES, RSS_CONTENT_FORMAT_DEFAULT
|
||||
from changedetectionio.model.Tags import TagsDict
|
||||
|
||||
@@ -77,12 +76,11 @@ class model(dict):
|
||||
'webdriver_delay': None , # Extra delay in seconds before extracting text
|
||||
'ui': {
|
||||
'use_page_title_in_list': True,
|
||||
'use_share_watch': False, # "Share watch" link is a power-user feature, off by default
|
||||
'open_diff_in_new_tab': True,
|
||||
'socket_io_enabled': True,
|
||||
'favicons_enabled': True,
|
||||
'timeago_format': 'long', # 'long' = "1 minute ago", 'short' = "1m ago"
|
||||
'sidebar_mode': MENU_SIDEBAR_ACTIONMODES_DEFAULT, # one of blueprint.menu_modes.MENU_SIDEBAR_ACTIONMODES
|
||||
'sidebar_mode': 'collapsed', # 'collapsed' = slim icon rail, expands on hover; 'pinned' = always expanded
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,10 +46,6 @@ class model(EntityPersistenceMixin, watch_base):
|
||||
super(model, self).__init__(*arg, **kw)
|
||||
|
||||
self['overrides_watch'] = kw.get('default', {}).get('overrides_watch')
|
||||
# Ternary on a tag, unlike the plain bool on a watch: None ("leave it to each watch")
|
||||
# is the default, so a group never touches its watches' AI until explicitly set.
|
||||
# See llm/evaluator.py:tag_llm_decision().
|
||||
self['llm_backend_profile'] = kw.get('default', {}).get('llm_backend_profile', None)
|
||||
self['url_match_pattern'] = kw.get('default', {}).get('url_match_pattern', '')
|
||||
|
||||
if kw.get('default'):
|
||||
|
||||
@@ -30,7 +30,6 @@ from changedetectionio.validate_url import is_safe_valid_url
|
||||
|
||||
from changedetectionio.strtobool import strtobool
|
||||
from changedetectionio.jinja2_custom import render as jinja_render
|
||||
from changedetectionio import gc_debounce
|
||||
from . import watch_base
|
||||
from .persistence import EntityPersistenceMixin
|
||||
import os
|
||||
@@ -108,21 +107,9 @@ def _brotli_save(contents, filepath, mode=None, fallback_uncompressed=False):
|
||||
|
||||
logger.debug(f"Finished brotli compression - From {original_size} to {total_compressed_size} bytes.")
|
||||
|
||||
# Cleanup: drop the compressor, then force C-level memory back to the OS.
|
||||
#
|
||||
# There is deliberately no gc.collect() here. brotli.Compressor is not gc-tracked,
|
||||
# so the collector can never reclaim it - `del` frees it immediately by refcount.
|
||||
# Measured over 60 x 2.2MB compressions, RSS growth was:
|
||||
#
|
||||
# neither +0.9MB
|
||||
# gc.collect() only +0.2MB
|
||||
# malloc_trim() only +0.0MB <- does all of the work
|
||||
# both (previous) +0.0MB <- the collect contributed nothing
|
||||
#
|
||||
# malloc_trim below is the load-bearing line: brotli's retention is glibc holding
|
||||
# freed arenas, which only a trim returns. The collect cost ~31ms of stop-the-world
|
||||
# per snapshot save for no reclamation.
|
||||
# Cleanup: Delete compressor, force Python GC, then force C-level memory release
|
||||
del compressor
|
||||
gc.collect()
|
||||
|
||||
# Force release of C-level memory back to OS (since brotli is a C library)
|
||||
try:
|
||||
@@ -285,12 +272,10 @@ class model(EntityPersistenceMixin, watch_base):
|
||||
def has_unviewed(self):
|
||||
return int(self.newest_history_key) > int(self['last_viewed']) and self.__history_n >= 2
|
||||
|
||||
def _resolve_link(self, url, flash_on_template_error=True):
|
||||
"""Validate, Jinja2-render and de-'source:' a URL so it is safe to put in an href.
|
||||
@property
|
||||
def link(self):
|
||||
|
||||
Returns 'DISABLED' when the URL is missing/unsafe, or '' when its Jinja2 template
|
||||
could not be rendered.
|
||||
"""
|
||||
url = self.get('url', '')
|
||||
if not is_safe_valid_url(url):
|
||||
return 'DISABLED'
|
||||
|
||||
@@ -301,15 +286,11 @@ class model(EntityPersistenceMixin, watch_base):
|
||||
ready_url = jinja_render(template_str=url)
|
||||
except Exception as e:
|
||||
logger.critical(f"Invalid URL template for: '{url}' - {str(e)}")
|
||||
# has_request_context() guard: this is also reached from worker threads and the
|
||||
# notification path, where flash() would raise "Working outside of request context".
|
||||
from flask import has_request_context
|
||||
if flash_on_template_error and has_request_context():
|
||||
from flask import flash, url_for
|
||||
from markupsafe import Markup
|
||||
message = Markup('<a href="{}#general">The URL {} is invalid and cannot be used, click to edit</a>'.format(
|
||||
url_for('ui.ui_edit.edit_page', uuid=self.get('uuid')), url))
|
||||
flash(message, 'error')
|
||||
from flask import flash, url_for
|
||||
from markupsafe import Markup
|
||||
message = Markup('<a href="{}#general">The URL {} is invalid and cannot be used, click to edit</a>'.format(
|
||||
url_for('ui.ui_edit.edit_page', uuid=self.get('uuid')), self.get('url', '')))
|
||||
flash(message, 'error')
|
||||
return ''
|
||||
|
||||
if ready_url.startswith('source:'):
|
||||
@@ -320,34 +301,6 @@ class model(EntityPersistenceMixin, watch_base):
|
||||
return 'DISABLED'
|
||||
return ready_url
|
||||
|
||||
@property
|
||||
def link(self):
|
||||
"""The URL that actually gets fetched/checked."""
|
||||
return self._resolve_link(self.get('url', ''))
|
||||
|
||||
@property
|
||||
def open_link_override(self):
|
||||
"""The resolved optional 'Link to Open', or '' when unset/unusable."""
|
||||
override = str(self.get('link_to_open') or '').strip()
|
||||
if not override:
|
||||
return ''
|
||||
|
||||
# Don't flash on a broken template here - `link` already surfaces that for the watched URL,
|
||||
# and a bad "Link to Open" should never stop the page rendering.
|
||||
ready = self._resolve_link(override, flash_on_template_error=False)
|
||||
if not ready or ready == 'DISABLED':
|
||||
return ''
|
||||
return ready
|
||||
|
||||
@property
|
||||
def open_link(self):
|
||||
"""The URL a human should be sent to when they click through to "the site".
|
||||
|
||||
Some watches point at an API endpoint or RSS feed that is useless in a browser -
|
||||
'Link to Open' lets the user store the real page for those. Falls back to `link`.
|
||||
"""
|
||||
return self.open_link_override or self.link
|
||||
|
||||
@property
|
||||
def domain_only_from_link(self):
|
||||
from urllib.parse import urlparse
|
||||
@@ -702,7 +655,7 @@ class model(EntityPersistenceMixin, watch_base):
|
||||
|
||||
# reimport
|
||||
bump = self.history
|
||||
gc_debounce.collect('watch.history_bump')
|
||||
gc.collect()
|
||||
|
||||
# Save some text file to the appropriate path and bump the history
|
||||
# result_obj from fetch_site_status.run()
|
||||
|
||||
@@ -184,11 +184,16 @@ class watch_base(dict):
|
||||
'check_count': 0,
|
||||
'check_unique_lines': False, # On change-detected, compare against all history if its something new
|
||||
'consecutive_filter_failures': 0, # Every time the CSS/xPath filter cannot be located, reset when all is fine.
|
||||
# LLM intent-based evaluation
|
||||
'content-type': None,
|
||||
'date_created': None,
|
||||
'extract_lines_containing': [], # Keep only lines containing these substrings (plain text, case-insensitive)
|
||||
'extract_text': [], # Extract text by regex after filters
|
||||
# LLM intent-based evaluation
|
||||
'llm_intent': '', # Plain-English description of what the user cares about (change filter)
|
||||
'llm_change_summary': '', # Prompt for AI Change Summary — replaces {{ diff }} in notifications
|
||||
'llm_change_summary_mode': 'replace', # 'replace' the inherited prompt, or 'append' to it
|
||||
'llm_prefilter': None, # CSS selector derived at setup time (semantic only, e.g. "footer")
|
||||
'llm_evaluation_cache': {}, # {sha256(intent+diff): {important, summary}} - evaluated once, cached
|
||||
'fetch_backend': 'system', # plaintext, playwright etc
|
||||
'fetch_time': 0.0,
|
||||
'filter_failure_notification_send': strtobool(os.getenv('FILTER_FAILURE_NOTIFICATION_SEND_DEFAULT', 'True')),
|
||||
@@ -197,23 +202,16 @@ class watch_base(dict):
|
||||
'filter_text_replaced': True,
|
||||
'follow_price_changes': True,
|
||||
'has_ldjson_price_data': None,
|
||||
'headers': {}, # Extra headers to send
|
||||
'history_snapshot_max_length': None,
|
||||
'ignore_status_codes': None,
|
||||
'headers': {}, # Extra headers to send
|
||||
'ignore_text': [], # List of text to ignore when calculating the comparison checksum
|
||||
'ignore_status_codes': None,
|
||||
'in_stock_only': True, # Only trigger change on going to instock from out-of-stock
|
||||
'include_filters': [],
|
||||
'last_checked': 0,
|
||||
'last_error': False,
|
||||
'last_notification_error': None,
|
||||
'last_viewed': 0, # history key value of the last viewed via the [diff] link
|
||||
'link_to_open': '', # Optional human-facing URL to open instead of 'url' (eg. watch an API/RSS endpoint, open the real page)
|
||||
'llm_backend_profile': True, # @note - now its just a bool but in the near future we can select a LLM profile or 'off'/false
|
||||
'llm_change_summary': '', # Prompt for AI Change Summary — replaces {{ diff }} in notifications
|
||||
'llm_change_summary_mode': 'replace', # 'replace' the inherited prompt, or 'append' to it
|
||||
'llm_evaluation_cache': {}, # {sha256(intent+diff): {important, summary}} - evaluated once, cached
|
||||
'llm_intent': '', # Plain-English description of what the user cares about (change filter)
|
||||
'llm_prefilter': None, # CSS selector derived at setup time (semantic only, e.g. "footer")
|
||||
'method': 'GET',
|
||||
'notification_alert_count': 0,
|
||||
'notification_body': None,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
|
||||
import time
|
||||
import re
|
||||
import apprise
|
||||
from apprise import NotifyFormat
|
||||
from loguru import logger
|
||||
from urllib.parse import urlparse
|
||||
from .apprise_plugin.assets import apprise_asset, APPRISE_AVATAR_URL
|
||||
from .email_helpers import as_monospaced_html_email
|
||||
from ..diff import HTML_REMOVED_STYLE, REMOVED_PLACEMARKER_OPEN, REMOVED_PLACEMARKER_CLOSED, ADDED_PLACEMARKER_OPEN, HTML_ADDED_STYLE, \
|
||||
ADDED_PLACEMARKER_CLOSED, CHANGED_INTO_PLACEMARKER_OPEN, CHANGED_INTO_PLACEMARKER_CLOSED, CHANGED_PLACEMARKER_OPEN, \
|
||||
@@ -61,7 +64,6 @@ def notification_format_align_with_apprise(n_format : str):
|
||||
:param n_format:
|
||||
:return:
|
||||
"""
|
||||
from apprise import NotifyFormat
|
||||
|
||||
if not n_format:
|
||||
return NotifyFormat.TEXT.value
|
||||
@@ -206,7 +208,6 @@ def replace_placemarkers_in_text(text, url, requested_output_format):
|
||||
|
||||
def apply_service_tweaks(url, n_body, n_title, requested_output_format):
|
||||
|
||||
from .apprise_plugin.assets import APPRISE_AVATAR_URL
|
||||
logger.debug(f"Applying markup in '{requested_output_format}' mode")
|
||||
|
||||
# Re 323 - Limit discord length to their 2000 char limit total or it wont send.
|
||||
@@ -304,9 +305,6 @@ def apply_service_tweaks(url, n_body, n_title, requested_output_format):
|
||||
|
||||
|
||||
def process_notification(n_object: NotificationContextData, datastore):
|
||||
import apprise
|
||||
from apprise import NotifyFormat
|
||||
from .apprise_plugin.assets import apprise_asset
|
||||
from changedetectionio.jinja2_custom import render as jinja_render
|
||||
from . import USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH, default_notification_format, valid_notification_formats
|
||||
# be sure its registered
|
||||
|
||||
@@ -241,7 +241,6 @@ class NotificationContextData(dict):
|
||||
'watch_tag': None,
|
||||
'watch_title': None,
|
||||
'watch_url': 'https://WATCH-PLACE-HOLDER/',
|
||||
'watch_open_url': 'https://WATCH-PLACE-HOLDER/', # watch['link_to_open'] when set, otherwise the same as watch_url
|
||||
'watch_uuid': 'XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX', # Converted to 'watch_uuid' in create_notification_parameters
|
||||
})
|
||||
|
||||
@@ -351,7 +350,6 @@ def set_basic_notification_vars(current_snapshot, prev_snapshot, watch, triggere
|
||||
'triggered_text': triggered_text,
|
||||
'uuid': watch.get('uuid') if watch else None,
|
||||
'watch_url': watch.get('url') if watch else None,
|
||||
'watch_open_url': (watch.open_link_override or watch.get('url')) if watch else None,
|
||||
'watch_uuid': watch.get('uuid') if watch else None,
|
||||
'watch_mime_type': watch.get('content-type')
|
||||
}
|
||||
@@ -513,7 +511,6 @@ Thanks - Your omniscient changedetection.io installation.
|
||||
if 'notification_urls' in n_object:
|
||||
n_object.update({
|
||||
'watch_url': watch['url'],
|
||||
'watch_open_url': watch.open_link_override or watch['url'],
|
||||
'uuid': watch_uuid,
|
||||
'screenshot': None
|
||||
})
|
||||
@@ -563,7 +560,6 @@ Thanks - Your omniscient changedetection.io installation.
|
||||
if 'notification_urls' in n_object:
|
||||
n_object.update({
|
||||
'watch_url': watch['url'],
|
||||
'watch_open_url': watch.open_link_override or watch['url'],
|
||||
'uuid': watch_uuid
|
||||
})
|
||||
self.notification_q.put(n_object)
|
||||
|
||||
@@ -22,16 +22,11 @@ class difference_detection_processor():
|
||||
preferred_proxy = None
|
||||
screenshot_format = SCREENSHOT_FORMAT_JPEG
|
||||
last_raw_content_checksum = None
|
||||
worker_id = None
|
||||
|
||||
def __init__(self, datastore, watch_uuid, worker_id=None):
|
||||
def __init__(self, datastore, watch_uuid):
|
||||
self.datastore = datastore
|
||||
self.watch_uuid = watch_uuid
|
||||
|
||||
# Which async worker is driving this check, passed down to the fetcher in call_browser()
|
||||
# so it can keep per-worker browser state apart, None when we're not called from a worker
|
||||
self.worker_id = worker_id
|
||||
|
||||
# Create a stable snapshot of the watch for processing
|
||||
# Why deepcopy?
|
||||
# 1. Prevents "dict changed during iteration" errors if watch is modified during processing
|
||||
@@ -206,8 +201,7 @@ class difference_detection_processor():
|
||||
# When browser_connection_url is None, it method should default to working out whats the best defaults (os env vars etc)
|
||||
self.fetcher = fetcher_obj(proxy_override=proxy_url,
|
||||
custom_browser_connection_url=custom_browser_connection_url,
|
||||
screenshot_format=self.screenshot_format,
|
||||
worker_id=self.worker_id
|
||||
screenshot_format=self.screenshot_format
|
||||
)
|
||||
|
||||
# Stamp the resolved backend name so downstream consumers (processors, plugins)
|
||||
@@ -267,7 +261,7 @@ class difference_detection_processor():
|
||||
await self.fetcher.run(
|
||||
current_include_filters=self.watch.get('include_filters'),
|
||||
empty_pages_are_a_change=empty_pages_are_a_change,
|
||||
fetch_favicon=self.watch.favicon_is_expired() and self.datastore.data['settings']['application'].get('ui', {}).get('favicons_enabled', True),
|
||||
fetch_favicon=self.watch.favicon_is_expired(),
|
||||
ignore_status_codes=ignore_status_codes,
|
||||
is_binary=is_binary,
|
||||
request_body=request_body,
|
||||
|
||||
@@ -430,8 +430,7 @@ def render(watch, datastore, request, url_for, render_template, flash, redirect)
|
||||
change_percentage=change_percentage,
|
||||
comparison_data=comparison_data, # Full history for charts/visualization
|
||||
comparison_method=method_display,
|
||||
current_diff_label=watch.label,
|
||||
current_diff_url=watch.open_link,
|
||||
current_diff_url=watch['url'],
|
||||
from_version=from_version,
|
||||
percentage_different=change_percentage,
|
||||
threshold=pixel_difference_threshold_sensitivity,
|
||||
|
||||
@@ -106,6 +106,5 @@ def render(watch, datastore, request, url_for, render_template, flash, redirect)
|
||||
uuid=watch.get('uuid'),
|
||||
versions=versions,
|
||||
timestamp=timestamp,
|
||||
current_diff_label=watch.label,
|
||||
current_diff_url=watch.open_link
|
||||
current_diff_url=watch['url']
|
||||
)
|
||||
|
||||
@@ -191,8 +191,7 @@ def render(watch, datastore, request, url_for, render_template, flash, redirect,
|
||||
'restock_diff/difference.html',
|
||||
uuid=uuid,
|
||||
watch=watch,
|
||||
current_diff_label=watch.label,
|
||||
current_diff_url=watch.open_link,
|
||||
current_diff_url=watch['url'],
|
||||
extra_title=f" - {watch.label} - {gettext('Price history')}",
|
||||
last_error=watch['last_error'],
|
||||
screenshot=watch.get_screenshot(),
|
||||
|
||||
@@ -364,8 +364,12 @@ def get_itemprop_availability(html_content) -> Restock:
|
||||
if availability_result:
|
||||
value['availability'] = availability_result[0].value
|
||||
|
||||
if value.get('availability'):
|
||||
value['availability'] = re.sub(r'(?i)^(https|http)://schema.org/', '',
|
||||
value.get('availability').strip(' "\'').lower()) if value.get('availability') else None
|
||||
|
||||
# Second, go dig OpenGraph which is something that jsonpath_ng cant do because of the tuples and double-dots (:)
|
||||
if not value.get('price') or not value.get('availability') or not value.get('currency'):
|
||||
if not value.get('price') or value.get('availability'):
|
||||
logger.debug("Alternatively digging through OpenGraph properties for restock/price info..")
|
||||
jsonpath_expr = parse('$..properties')
|
||||
|
||||
@@ -376,14 +380,6 @@ def get_itemprop_availability(html_content) -> Restock:
|
||||
value['availability'] = _search_prop_by_value([match.value], "product:availability")
|
||||
if not value.get('currency'):
|
||||
value['currency'] = _search_prop_by_value([match.value], "price:currency")
|
||||
|
||||
# Normalise after both sources have been tried, otherwise an availability that came from
|
||||
# OpenGraph stays raw. The OpenGraph vocabulary spells it "in stock" while the in-stock
|
||||
# matcher looks for "instock", so the spaces have to go too.
|
||||
if value.get('availability'):
|
||||
value['availability'] = re.sub(r'(?i)^(https|http)://schema.org/', '',
|
||||
value.get('availability').strip(' "\'').lower())
|
||||
value['availability'] = re.sub(r'\s+', '', value['availability'])
|
||||
logger.trace(f"Processed with Extruct in {time.time()-now:.3f}s")
|
||||
|
||||
return value
|
||||
@@ -506,11 +502,8 @@ class perform_site_check(difference_detection_processor):
|
||||
# Try plugin override - plugins can decide if they support this fetcher
|
||||
if fetcher_name:
|
||||
logger.debug(f"Calling extra plugins for getting item price/availability (fetcher: {fetcher_name})")
|
||||
from changedetectionio.llm.evaluator import llm_enabled_for_watch, resolve_intent
|
||||
# AI off for this watch (or for its group) means no intent is handed to the
|
||||
# LLM restock plugin, so it doesn't spend tokens here either — #4204.
|
||||
_llm_on, _ = llm_enabled_for_watch(watch, self.datastore)
|
||||
_llm_intent, _ = resolve_intent(watch, self.datastore) if _llm_on else ('', '')
|
||||
from changedetectionio.llm.evaluator import resolve_intent
|
||||
_llm_intent, _ = resolve_intent(watch, self.datastore)
|
||||
plugin_availability = get_itemprop_availability_from_plugin(self.fetcher.content, fetcher_name, self.fetcher, watch.link, llm_intent=_llm_intent or None)
|
||||
|
||||
if plugin_availability:
|
||||
|
||||
@@ -86,10 +86,8 @@
|
||||
price_low: {{ _('Currently low')|tojson }},
|
||||
price_typical: {{ _('Currently typical')|tojson }},
|
||||
price_high: {{ _('Currently high')|tojson }},
|
||||
{# TRANSLATORS: {pct} is a percentage including the % sign, e.g. "80%" #}
|
||||
cheaper_than: {{ _('cheaper than {pct} of tracked prices')|tojson }},
|
||||
{# TRANSLATORS: {pct} is a percentage including the % sign, e.g. "80%" #}
|
||||
pricier_than: {{ _('more expensive than {pct} of tracked prices')|tojson }},
|
||||
cheaper_than: {{ _('cheaper than %s% of tracked prices')|tojson }},
|
||||
pricier_than: {{ _('more expensive than %s% of tracked prices')|tojson }},
|
||||
typical_note: {{ _('around the usual price')|tojson }},
|
||||
avg_label: {{ _('avg')|tojson }}
|
||||
};
|
||||
|
||||
@@ -216,8 +216,7 @@ def render(watch, datastore, request, url_for, render_template, flash, redirect,
|
||||
#initial_scroll_line_number=100,
|
||||
bottom_horizontal_offscreen_contents=offscreen_content,
|
||||
content=content,
|
||||
current_diff_label=watch.label,
|
||||
current_diff_url=watch.open_link,
|
||||
current_diff_url=watch['url'],
|
||||
diff_cell_grid=diff_cell_grid,
|
||||
diff_prefs=diff_prefs,
|
||||
extra_classes=' '.join(filter(None, ['difference-page', 'llm-configured' if llm_configured else ''])),
|
||||
|
||||
@@ -9,12 +9,6 @@
|
||||
# exit when any command fails
|
||||
set -e
|
||||
|
||||
# Failing fast is deliberate here (the first failure is usually the real problem, and it keeps
|
||||
# the run short) - but this script runs 8 independent pytest groups, so make it obvious that the
|
||||
# groups after the failure were SKIPPED rather than passed. Otherwise one failing test reads as
|
||||
# "the whole basic suite is broken".
|
||||
trap 'rc=$?; echo "::error::run_basic_tests.sh aborted at line $LINENO (exit $rc) - the test groups after this point were SKIPPED, not run"' ERR
|
||||
|
||||
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
|
||||
rm tests/logs/* -f
|
||||
|
||||
|
||||
@@ -78,9 +78,7 @@ $(document).ready(function () {
|
||||
// bootstrap it, this will trigger everything else
|
||||
$('#browsersteps-img').bind('load', function () {
|
||||
$('body').addClass('full-width');
|
||||
console.log(`Loaded background ${this.naturalWidth}px` );
|
||||
// For the UI width of the whole edit area
|
||||
document.documentElement.style.setProperty('--browser-steps-max-width', `${this.naturalWidth+200}px` );
|
||||
console.log("Loaded background...");
|
||||
|
||||
document.getElementById("browsersteps-selector-canvas");
|
||||
c = document.getElementById("browsersteps-selector-canvas");
|
||||
@@ -163,7 +161,7 @@ $(document).ready(function () {
|
||||
// Find the best one
|
||||
if (possible_elements.length) {
|
||||
possible_elements.forEach(function (item, index) {
|
||||
if (["a", "input", "select", "textarea", "button"].includes(item['tagName'])) {
|
||||
if (["a", "input", "textarea", "button"].includes(item['tagName'])) {
|
||||
current_selected_i = item;
|
||||
}
|
||||
});
|
||||
@@ -286,7 +284,7 @@ $(document).ready(function () {
|
||||
$('#browser-steps-ui .loader .spinner').show();
|
||||
// Request a new session
|
||||
$.ajax({
|
||||
type: "POST",
|
||||
type: "GET",
|
||||
url: browser_steps_start_url,
|
||||
statusCode: {
|
||||
400: function () {
|
||||
|
||||
@@ -1,205 +1,74 @@
|
||||
// Previous / Next step both version selects one position back or forward and
|
||||
// rebuild the href from the current diff options; the arrow keys do the same
|
||||
// thing. Recomputed at press time rather than cached, because changing either
|
||||
// select submits the form and re-renders them.
|
||||
function diffStepHref(direction) {
|
||||
var $from = $('#diff-from-version option:selected')[direction]();
|
||||
var $to = $('#diff-to-version option:selected')[direction]();
|
||||
if (!$from.length || !$to.length) {
|
||||
return null;
|
||||
}
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
params.set('from_version', $from.val());
|
||||
params.set('to_version', $to.val());
|
||||
return '?' + params.toString();
|
||||
}
|
||||
|
||||
function setupDiffNavigation() {
|
||||
var BUTTONS = {prev: '#btn-previous', next: '#btn-next'};
|
||||
var $fromSelect = $('#diff-from-version');
|
||||
var $toSelect = $('#diff-to-version');
|
||||
var $fromSelected = $fromSelect.find('option:selected');
|
||||
var $toSelected = $toSelect.find('option:selected');
|
||||
|
||||
if ($('#diff-from-version option:selected').length && $('#diff-to-version option:selected').length) {
|
||||
$.each(BUTTONS, function (direction, selector) {
|
||||
var href = diffStepHref(direction);
|
||||
// Nothing to step to that way: drop the button rather than leave a
|
||||
// dead one on the bar.
|
||||
if (href) {
|
||||
$(selector).attr('href', href);
|
||||
} else {
|
||||
$(selector).remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
if ($fromSelected.length && $toSelected.length) {
|
||||
// Find the previous pair (move both back one position)
|
||||
var $prevFrom = $fromSelected.prev();
|
||||
var $prevTo = $toSelected.prev();
|
||||
|
||||
$(window).on('keydown', function (event) {
|
||||
// Not while someone is typing or working a select.
|
||||
if (/^(INPUT|TEXTAREA|SELECT)$/.test(event.target.tagName)) {
|
||||
return;
|
||||
}
|
||||
var direction = {ArrowLeft: 'prev', ArrowRight: 'next'}[event.key];
|
||||
var href = direction && diffStepHref(direction) && $(BUTTONS[direction]).attr('href');
|
||||
if (href) {
|
||||
event.preventDefault();
|
||||
window.location.href = href;
|
||||
}
|
||||
});
|
||||
}
|
||||
// Find the next pair (move both forward one position)
|
||||
var $nextFrom = $fromSelected.next();
|
||||
var $nextTo = $toSelected.next();
|
||||
|
||||
// The seven diff options collapse behind the 'Filters' button so the sticky bar
|
||||
// stays one toolbar row. Wired up here rather than purely in CSS: until this
|
||||
// runs the fieldset is inline and the toggle hidden, so with scripting off the
|
||||
// options are still reachable and still submit with the form.
|
||||
function setupDiffFilters() {
|
||||
var $header = $('#diff-header');
|
||||
var $toggle = $('#diff-filters-toggle');
|
||||
var $panel = $('#diff-style');
|
||||
if (!$header.length || !$toggle.length || !$panel.length) {
|
||||
return;
|
||||
}
|
||||
var header = $header[0], toggle = $toggle[0], panel = $panel[0];
|
||||
// Build URL with current diff preferences
|
||||
var currentParams = new URLSearchParams(window.location.search);
|
||||
|
||||
$header.addClass('diff-filters-js');
|
||||
|
||||
function isOpen() {
|
||||
return $header.hasClass('diff-filters-open');
|
||||
}
|
||||
|
||||
var EDGE_GAP = 8; // keep the panel clear of the viewport edges
|
||||
var BUTTON_GAP = 4; // between the toggle and the panel
|
||||
|
||||
// position: fixed, because #diff-header's overflow: auto would clip an absolute
|
||||
// panel - which means parking it by hand, and that scrolling will never bring a
|
||||
// row hanging off the bottom back into reach. So it has to fit at placement time
|
||||
// or not at all: cap it to the room there is and let it scroll (see diff.scss).
|
||||
function place() {
|
||||
var button = toggle.getBoundingClientRect();
|
||||
// A fixed element is positioned against the layout viewport, but iOS
|
||||
// shrinks the *visual* one behind its toolbars, so budget with whichever
|
||||
// is smaller.
|
||||
var viewportHeight = document.documentElement.clientHeight;
|
||||
if (window.visualViewport) {
|
||||
viewportHeight = Math.min(viewportHeight, window.visualViewport.height);
|
||||
}
|
||||
|
||||
// Measure unconstrained: a cap left over from the previous placement would
|
||||
// otherwise read back as the panel's natural height.
|
||||
panel.style.maxHeight = '';
|
||||
|
||||
// Pick the roomier side rather than assume one: below used to be roomier by
|
||||
// construction, but diff.scss's short-viewport breakpoint drops the bar's
|
||||
// 50svh cap and at 280x300 above wins 147.6px to 104.2px.
|
||||
var roomBelow = viewportHeight - button.bottom - BUTTON_GAP - EDGE_GAP;
|
||||
var roomAbove = button.top - BUTTON_GAP - EDGE_GAP;
|
||||
var above = roomAbove > roomBelow;
|
||||
var room = above ? roomAbove : roomBelow;
|
||||
|
||||
if (panel.offsetHeight > room) {
|
||||
// max-height caps the content box, and the panel must stay content-box
|
||||
// (border-box would fold the padding into min-width and narrow it by
|
||||
// 24px) - so subtract its own padding and borders.
|
||||
var style = window.getComputedStyle(panel);
|
||||
var trim = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom) +
|
||||
parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth);
|
||||
panel.style.maxHeight = Math.max(room - trim, 0) + 'px';
|
||||
}
|
||||
|
||||
// offsetHeight after the cap: parking above measures from the panel's bottom.
|
||||
var top = above ? button.top - BUTTON_GAP - panel.offsetHeight
|
||||
: button.bottom + BUTTON_GAP;
|
||||
panel.style.top = top + 'px';
|
||||
|
||||
var available = document.documentElement.clientWidth - panel.offsetWidth - EDGE_GAP;
|
||||
var left = Math.max(EDGE_GAP, Math.min(button.left, available));
|
||||
|
||||
// The panel paints the page's own backdrop (see diff.scss), aligned by
|
||||
// geometry because iOS Safari ignores background-attachment: fixed. Negative
|
||||
// because the copy's top-left corner belongs at the viewport's.
|
||||
$panel.css({left: left + 'px', backgroundPosition: (-left) + 'px ' + (-top) + 'px'});
|
||||
}
|
||||
|
||||
function open() {
|
||||
$header.addClass('diff-filters-open');
|
||||
$toggle.attr('aria-expanded', 'true');
|
||||
place();
|
||||
}
|
||||
|
||||
function close(restoreFocus) {
|
||||
if (!isOpen()) {
|
||||
return;
|
||||
}
|
||||
$header.removeClass('diff-filters-open');
|
||||
$toggle.attr('aria-expanded', 'false');
|
||||
if (restoreFocus) {
|
||||
$toggle.trigger('focus');
|
||||
}
|
||||
}
|
||||
|
||||
$toggle.on('click', function (event) {
|
||||
event.preventDefault();
|
||||
if (isOpen()) {
|
||||
close(false);
|
||||
// Previous button: only show if both can move back
|
||||
if ($prevFrom.length && $prevTo.length) {
|
||||
currentParams.set('from_version', $prevFrom.val());
|
||||
currentParams.set('to_version', $prevTo.val());
|
||||
$('#btn-previous').attr('href', '?' + currentParams.toString());
|
||||
} else {
|
||||
open();
|
||||
$('#btn-previous').remove();
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on('click', function (event) {
|
||||
if (isOpen() && !panel.contains(event.target) && !toggle.contains(event.target)) {
|
||||
close(false);
|
||||
// Next button: only show if both can move forward
|
||||
if ($nextFrom.length && $nextTo.length) {
|
||||
currentParams.set('from_version', $nextFrom.val());
|
||||
currentParams.set('to_version', $nextTo.val());
|
||||
$('#btn-next').attr('href', '?' + currentParams.toString());
|
||||
} else {
|
||||
$('#btn-next').remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$(document).on('keydown', function (event) {
|
||||
if (event.key === 'Escape') {
|
||||
close(true);
|
||||
}
|
||||
});
|
||||
|
||||
// A tab switch hides #settings and the panel with it, so drop the open state
|
||||
// rather than leave aria-expanded lying.
|
||||
$(window).on('hashchange', function () {
|
||||
close(false);
|
||||
});
|
||||
|
||||
$(window).on('resize', function () {
|
||||
if (isOpen()) {
|
||||
place();
|
||||
}
|
||||
});
|
||||
|
||||
// The button can drift out from under the fixed panel two ways: #settings
|
||||
// scrolling inside a sticky bar (16px at 390x390, 30px at 320x480), or the whole
|
||||
// bar scrolling away below diff.scss's max-height: 500px breakpoint. Opposite
|
||||
// causes, one follow/close rule; each closing test is inert in the other mode.
|
||||
function followButton() {
|
||||
if (!isOpen()) {
|
||||
// Keyboard navigation
|
||||
window.addEventListener('keydown', function (event) {
|
||||
// Don't trigger if user is typing in an input field
|
||||
if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA' || event.target.tagName === 'SELECT') {
|
||||
return;
|
||||
}
|
||||
var bar = header.getBoundingClientRect();
|
||||
var button = toggle.getBoundingClientRect();
|
||||
var viewportHeight = document.documentElement.clientHeight;
|
||||
if (button.bottom <= bar.top || button.top >= bar.bottom ||
|
||||
button.bottom <= 0 || button.top >= viewportHeight) {
|
||||
close(false);
|
||||
} else {
|
||||
place();
|
||||
}
|
||||
}
|
||||
|
||||
// Native, not jQuery: .on() cannot pass passive or capture. Capture is required
|
||||
// because scroll does not bubble and the element that scrolls is #settings, a
|
||||
// descendant; the window registration covers the static mode's document scroll.
|
||||
header.addEventListener('scroll', followButton, {passive: true, capture: true});
|
||||
window.addEventListener('scroll', followButton, {passive: true});
|
||||
// On iOS the visual viewport shrinks on its own - a toolbar or the keyboard -
|
||||
// without resizing the layout viewport, so no window resize fires and the cap
|
||||
// place() budgeted stays wrong. Reproduced in Chromium via CDP pinch-zoom.
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener('resize', function () {
|
||||
if (isOpen()) {
|
||||
place();
|
||||
var $fromSelected = $fromSelect.find('option:selected');
|
||||
var $toSelected = $toSelect.find('option:selected');
|
||||
|
||||
if ($fromSelected.length && $toSelected.length) {
|
||||
if (event.key === 'ArrowLeft') {
|
||||
var $prevFrom = $fromSelected.prev();
|
||||
var $prevTo = $toSelected.prev();
|
||||
if ($prevFrom.length && $prevTo.length) {
|
||||
var prevHref = $('#btn-previous').attr('href');
|
||||
if (prevHref) {
|
||||
event.preventDefault();
|
||||
window.location.href = prevHref;
|
||||
}
|
||||
}
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
var $nextFrom = $fromSelected.next();
|
||||
var $nextTo = $toSelected.next();
|
||||
if ($nextFrom.length && $nextTo.length) {
|
||||
var nextHref = $('#btn-next').attr('href');
|
||||
if (nextHref) {
|
||||
event.preventDefault();
|
||||
window.location.href = nextHref;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}, false);
|
||||
}
|
||||
|
||||
$(document).ready(function () {
|
||||
@@ -218,70 +87,10 @@ $(document).ready(function () {
|
||||
setupDiffNavigation();
|
||||
}
|
||||
|
||||
setupDiffFilters();
|
||||
|
||||
// Load it when the #screenshot tab is in use, so we dont give a slow experience when waiting for the text diff to load
|
||||
$(window).on('hashchange', function () {
|
||||
window.addEventListener('hashchange', function (e) {
|
||||
toggle(location.hash);
|
||||
realignPane();
|
||||
});
|
||||
|
||||
// Where the last alignment we performed left the page, so a later one can tell
|
||||
// "still where we put them" from "the reader has moved since", and the
|
||||
// scroll-margin-top it was computed against.
|
||||
var alignedY = null;
|
||||
var alignedMargin = null;
|
||||
|
||||
function targetPane() {
|
||||
var pane = location.hash.length > 1 &&
|
||||
document.getElementById(location.hash.slice(1));
|
||||
return pane && pane.classList.contains('tab-pane-inner') ? pane : null;
|
||||
}
|
||||
|
||||
// The fragment jump runs before toggle() hides #settings, which collapses the
|
||||
// ~8000px document; the engine then clamps its held offset rather than re-running
|
||||
// the jump, and on iOS that clamp is never 0. So re-run it once the layout has
|
||||
// settled. scrollIntoView, not scrollTo(0, 0): .tab-pane-inner already declares
|
||||
// the sticky stack's offset as scroll-margin-top. setTimeout(0) rather than rAF,
|
||||
// because that margin resolves against --diff-header-height, which
|
||||
// diff-render.js's ResizeObserver updates after frame callbacks have run.
|
||||
function realignPane() {
|
||||
var pane = targetPane();
|
||||
if (!pane) {
|
||||
return;
|
||||
}
|
||||
setTimeout(function () {
|
||||
alignedMargin = parseFloat(getComputedStyle(pane).scrollMarginTop);
|
||||
pane.scrollIntoView({block: 'start', inline: 'nearest'});
|
||||
alignedY = window.scrollY;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// The load path is stale for a nearer reason: tabs.js rewrites an empty hash and
|
||||
// the browser jumps before diff-render.js has measured the bar, so
|
||||
// scroll-margin-top is still on its --diff-header-height: 0 fallback. Traced at
|
||||
// 844x390: landed at scrollY 231 against a 16px margin that then became 202px.
|
||||
// Below the short-viewport breakpoint the bar is in flow, so that puts every
|
||||
// control off the top of the page. Re-run when the bar resizes - comparing the
|
||||
// margin, so it only fires on a real change - and only while the reader is still
|
||||
// where we left them, since rotating the phone resizes the bar too.
|
||||
var bar = document.getElementById('diff-header');
|
||||
if (bar && typeof ResizeObserver !== 'undefined') {
|
||||
new ResizeObserver(function () {
|
||||
var pane = targetPane();
|
||||
if (!pane) {
|
||||
return;
|
||||
}
|
||||
if (alignedY !== null && Math.abs(window.scrollY - alignedY) > 2) {
|
||||
return;
|
||||
}
|
||||
var margin = parseFloat(getComputedStyle(pane).scrollMarginTop);
|
||||
if (alignedMargin !== null && Math.abs(margin - alignedMargin) < 0.5) {
|
||||
return;
|
||||
}
|
||||
realignPane();
|
||||
}).observe(bar);
|
||||
}
|
||||
}, false);
|
||||
|
||||
toggle(location.hash);
|
||||
|
||||
@@ -299,14 +108,23 @@ $(document).ready(function () {
|
||||
}
|
||||
}
|
||||
|
||||
// We could also add the 'touchend' event for touch devices, but since most
|
||||
// iOS/Android browsers already show a dialog when you select text (often with a
|
||||
// Share option) we'll skip that. mouseup goes on the page rather than the
|
||||
// article, because they might 'mouse up' outside it.
|
||||
$('#difference').on('mousedown', clean);
|
||||
$('.difference-page').on('mouseup', dragTextHandler);
|
||||
const article = $('#difference')[0];
|
||||
|
||||
$('#highlightSnippetActions a').on('click', function (e) {
|
||||
// We could also add the 'touchend' event for touch devices, but since
|
||||
// most iOS/Android browsers already show a dialog when you select
|
||||
// text (often with a Share option) we'll skip that
|
||||
if (article) {
|
||||
article.addEventListener('mousedown', clean, false);
|
||||
}
|
||||
|
||||
// Because they might 'mouse up' outside the article but on the page
|
||||
const d_page = $(".difference-page")[0]
|
||||
if (d_page ) {
|
||||
d_page.addEventListener('mouseup', dragTextHandler, false);
|
||||
}
|
||||
|
||||
|
||||
$('#highlightSnippetActions a').bind('click', function (e) {
|
||||
if (!window.getSelection().toString().trim().length) {
|
||||
alert('Oops no text selected!');
|
||||
return;
|
||||
@@ -337,11 +155,12 @@ $(document).ready(function () {
|
||||
$('#bottom-horizontal-offscreen').hide();
|
||||
}
|
||||
|
||||
$(window).on('keydown', function (e) {
|
||||
// Listen for Escape key press
|
||||
window.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') {
|
||||
clean();
|
||||
}
|
||||
});
|
||||
}, false);
|
||||
|
||||
function dragTextHandler(event) {
|
||||
console.log('mouseupped');
|
||||
|
||||
@@ -11,59 +11,6 @@ $(document).ready(function () {
|
||||
var visualizerResolutionCells = $cells.length;
|
||||
var cellHeight;
|
||||
|
||||
var header = document.getElementById('diff-header');
|
||||
// The app top menu, sticky above #diff-header on the diff page.
|
||||
var appHeader = document.querySelector('.app-main > .header');
|
||||
|
||||
// Controls can wrap or disappear when switching tabs, and the top menu
|
||||
// rewraps on narrow viewports. Keep each sticky layer, the minimap and the
|
||||
// anchor links below the actual measured heights instead of fixed offsets.
|
||||
// Order matters: #diff-header's max-height is calc(50dvh - the app header),
|
||||
// so store that first and measure the diff header against the new cap. The
|
||||
// other way round reads it under the previous cap and one pass is not enough
|
||||
// to settle.
|
||||
function updateHeaderHeight() {
|
||||
if (appHeader) {
|
||||
document.body.style.setProperty('--app-header-height', appHeader.offsetHeight + 'px');
|
||||
}
|
||||
if (header) {
|
||||
document.body.style.setProperty('--diff-header-height', header.offsetHeight + 'px');
|
||||
}
|
||||
}
|
||||
if (header || appHeader) {
|
||||
// Measure once regardless of observer support, so the offsets are never
|
||||
// left at their 0 fallback.
|
||||
updateHeaderHeight();
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
var headerObserver = new ResizeObserver(updateHeaderHeight);
|
||||
if (header) headerObserver.observe(header);
|
||||
if (appHeader) headerObserver.observe(appHeader);
|
||||
} else {
|
||||
// Without it, catch the two things that actually resize the bars:
|
||||
// the window rewrapping them, and the tab switch that shows or hides
|
||||
// #settings. diff-overview.js toggles that from its own hashchange
|
||||
// handler, so defer past it rather than depend on listener order.
|
||||
$(window).on('resize.diffheader', updateHeaderHeight.debounce(100));
|
||||
$(window).on('hashchange.diffheader', function () {
|
||||
setTimeout(updateHeaderHeight, 0);
|
||||
});
|
||||
// Opening a link straight at #screenshot / #extract hides #settings
|
||||
// from diff-overview.js's ready handler, with no hashchange to
|
||||
// follow. Re-measure once the whole ready pass has run.
|
||||
setTimeout(updateHeaderHeight, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Centre of the region left visible below the sticky stack, not of the whole
|
||||
// viewport - every sticky layer above the diff has to be counted or jumps
|
||||
// land half its height too high.
|
||||
function viewportCenterOffset() {
|
||||
var appHeaderHeight = appHeader ? appHeader.offsetHeight : 0;
|
||||
var headerHeight = header ? header.offsetHeight : 0;
|
||||
var visualizerHeight = $visualizer.is(':visible') ? $visualizer.outerHeight() : 0;
|
||||
return (appHeaderHeight + headerHeight + visualizerHeight + $(window).height()) / 2;
|
||||
}
|
||||
|
||||
if ($difference.length && visualizerResolutionCells > 0) {
|
||||
var docHeight = $difference[0].scrollHeight;
|
||||
cellHeight = docHeight / visualizerResolutionCells;
|
||||
@@ -74,10 +21,11 @@ $(document).ready(function () {
|
||||
$(this).on('click', function() {
|
||||
var cellIndex = $(this).data('cellIndex');
|
||||
var targetPositionInDifference = cellIndex * cellHeight;
|
||||
var viewportHeight = $(window).height();
|
||||
|
||||
// Scroll so target is at viewport center (where eyes expect it)
|
||||
window.scrollTo({
|
||||
top: $difference.offset().top + targetPositionInDifference - viewportCenterOffset(),
|
||||
top: $difference.offset().top + targetPositionInDifference - (viewportHeight / 2),
|
||||
behavior: "smooth"
|
||||
});
|
||||
});
|
||||
@@ -89,7 +37,8 @@ $(document).ready(function () {
|
||||
|
||||
// Find the next change after current scroll position
|
||||
var currentScrollPos = $(window).scrollTop();
|
||||
var currentCenter = currentScrollPos + viewportCenterOffset();
|
||||
var viewportHeight = $(window).height();
|
||||
var currentCenter = currentScrollPos + (viewportHeight / 2);
|
||||
|
||||
// Add small buffer (50px) to jump past changes already near center
|
||||
var searchFromPosition = currentCenter + 50;
|
||||
@@ -110,7 +59,7 @@ $(document).ready(function () {
|
||||
|
||||
// Scroll to position the element at viewport center
|
||||
var elementTop = $(nextElement).offset().top;
|
||||
var targetScrollPos = elementTop - viewportCenterOffset();
|
||||
var targetScrollPos = elementTop - (viewportHeight / 2);
|
||||
|
||||
window.scrollTo({
|
||||
top: targetScrollPos,
|
||||
@@ -124,7 +73,7 @@ $(document).ready(function () {
|
||||
|
||||
var scrollTop = $(window).scrollTop();
|
||||
var viewportHeight = $(window).height();
|
||||
var viewportCenter = scrollTop + viewportCenterOffset();
|
||||
var viewportCenter = scrollTop + (viewportHeight / 2);
|
||||
var differenceTop = $difference.offset().top;
|
||||
var differenceHeight = $difference[0].scrollHeight;
|
||||
var positionInDifference = viewportCenter - differenceTop;
|
||||
@@ -180,9 +129,10 @@ $(document).ready(function () {
|
||||
var estimatedTop = (charPosition / totalChars) * totalHeight;
|
||||
|
||||
// Scroll to position with line at viewport center
|
||||
var viewportHeight = $(window).height();
|
||||
setTimeout(function() {
|
||||
window.scrollTo({
|
||||
top: $difference.offset().top + estimatedTop - viewportCenterOffset(),
|
||||
top: $difference.offset().top + estimatedTop - (viewportHeight / 2),
|
||||
behavior: "smooth"
|
||||
});
|
||||
}, 100); // Small delay to ensure page is fully loaded
|
||||
@@ -199,3 +149,4 @@ $(document).ready(function () {
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
|
||||
@@ -179,25 +179,6 @@ function toggleOpacity(checkboxSelector, fieldSelector, inverted) {
|
||||
checkbox.addEventListener('change', updateOpacity);
|
||||
}
|
||||
|
||||
// Radio-group counterpart of toggleOpacity: fields are full opacity only while the named
|
||||
// radio group sits on activeValue, otherwise greyed out. Used by the tag AI/LLM tab, where a
|
||||
// ternary (On / Off / Leave it to each watch) decides whether the prompts below apply.
|
||||
function toggleOpacityByRadioValue(radioName, activeValue, fieldSelector) {
|
||||
const radios = document.querySelectorAll(`input[type="radio"][name="${radioName}"]`);
|
||||
const fields = document.querySelectorAll(fieldSelector);
|
||||
|
||||
function updateOpacity() {
|
||||
const active = Array.from(radios).some(radio => radio.checked && radio.value === activeValue);
|
||||
fields.forEach(field => {
|
||||
field.style.opacity = active ? 1 : 0.6;
|
||||
});
|
||||
}
|
||||
|
||||
// Initial setup
|
||||
updateOpacity();
|
||||
radios.forEach(radio => radio.addEventListener('change', updateOpacity));
|
||||
}
|
||||
|
||||
function toggleVisibility(checkboxSelector, fieldSelector, inverted) {
|
||||
const checkbox = document.querySelector(checkboxSelector);
|
||||
const fields = document.querySelectorAll(fieldSelector);
|
||||
|
||||
@@ -73,7 +73,7 @@ $(function () {
|
||||
|
||||
// Request start, needs CSRF?
|
||||
$.ajax({
|
||||
type: "POST",
|
||||
type: "GET",
|
||||
url: recheck_proxy_start_url,
|
||||
}).done(function (data) {
|
||||
$.each(data, function (proxy_key, state) {
|
||||
|
||||
@@ -23,11 +23,8 @@
|
||||
no_data: 'No price data available to graph yet.', load_error: 'Could not load price history.',
|
||||
changes: 'Changes', avg_price: 'Average price',
|
||||
price_low: 'Currently low', price_typical: 'Currently typical', price_high: 'Currently high',
|
||||
// {pct} is substituted with an already-formatted percentage, e.g. "80%". The percent sign is
|
||||
// NOT part of the msgid on purpose: a bare "%" followed by a letter (" of") parses as a
|
||||
// python-format conversion ("% o"), which makes pybabel reject any translation of it.
|
||||
cheaper_than: 'cheaper than {pct} of tracked prices',
|
||||
pricier_than: 'more expensive than {pct} of tracked prices',
|
||||
cheaper_than: 'cheaper than %s% of tracked prices',
|
||||
pricier_than: 'more expensive than %s% of tracked prices',
|
||||
typical_note: 'around the usual price', avg_label: 'avg' };
|
||||
const OVERLAY_MIN_POINTS = 5; // need enough history for low/typical/high to be meaningful
|
||||
|
||||
@@ -263,9 +260,9 @@
|
||||
$pill.append($('<span class="rg-status-label"></span>').text(i18n['price_' + summary.status] || ''));
|
||||
let sub;
|
||||
if (summary.status === 'low') {
|
||||
sub = (i18n.cheaper_than || '').replace('{pct}', summary.cheaper_than_pct + '%');
|
||||
sub = (i18n.cheaper_than || '').replace('%s', summary.cheaper_than_pct);
|
||||
} else if (summary.status === 'high') {
|
||||
sub = (i18n.pricier_than || '').replace('{pct}', summary.pricier_than_pct + '%');
|
||||
sub = (i18n.pricier_than || '').replace('%s', summary.pricier_than_pct);
|
||||
} else {
|
||||
sub = i18n.typical_note || '';
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
// The watch title in the top menu is a horizontal scroll container (see
|
||||
// parts/_top_menu.scss), so a long one can be dragged or swiped to read its end.
|
||||
// The CSS ships a fixed right-hand fade for the no-JS case; once this is
|
||||
// running, the fade has to follow the scroll instead, or the ending the reader
|
||||
// just dragged over is the part that is dimmed.
|
||||
//
|
||||
// Global rather than part of diff-overview.js: the restock difference page and
|
||||
// the image-SSIM preview page render the same line and neither loads that file.
|
||||
$(function () {
|
||||
var $title = $('.current-diff-url');
|
||||
if (!$title.length) return;
|
||||
var el = $title[0];
|
||||
|
||||
function updateFades() {
|
||||
// The 1px slack absorbs the sub-pixel gap a fractional layout leaves
|
||||
// between scrollWidth and clientWidth, which would otherwise hold
|
||||
// fade-right on for a line that is not actually cut off.
|
||||
$title
|
||||
.toggleClass('fade-left', el.scrollLeft > 1)
|
||||
.toggleClass('fade-right', el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
|
||||
}
|
||||
|
||||
// The class and the first state are set in the same task, so the no-JS mask
|
||||
// is never seen to blink off before its replacement arrives.
|
||||
$title.addClass('js-fades').on('scroll', updateFades);
|
||||
// Resizing changes what fits without scrolling the line, so the state can
|
||||
// become wrong without a scroll event: rotating a phone is the common case.
|
||||
$(window).on('resize', updateFades);
|
||||
updateFades();
|
||||
});
|
||||
@@ -7,6 +7,7 @@
|
||||
// The Search button is rendered in the left rail and the mobile drawer.
|
||||
const openSearchButtons = document.querySelectorAll('.js-open-search-modal');
|
||||
const closeSearchButton = document.getElementById('close-search-modal');
|
||||
const searchForm = document.getElementById('search-form');
|
||||
const searchInput = document.getElementById('search-modal-input');
|
||||
|
||||
if (!searchModal || openSearchButtons.length === 0) {
|
||||
@@ -62,15 +63,6 @@
|
||||
|
||||
// Close modal when clicking the backdrop
|
||||
searchModal.addEventListener('click', function(e) {
|
||||
// Only real pointer clicks can land on the backdrop. Keyboard-synthesised clicks
|
||||
// report detail 0 and coordinates of 0,0, which the geometry test below reads as
|
||||
// "outside the dialog" - and implicit form submission (Enter in the input) fires
|
||||
// exactly such a click at the Search button. That closed the modal and blanked
|
||||
// the input mid-dispatch, so the submit that followed hit an empty `required`
|
||||
// field and was rejected: Enter appeared to just dismiss the form.
|
||||
if (e.detail === 0) {
|
||||
return;
|
||||
}
|
||||
const rect = searchModal.getBoundingClientRect();
|
||||
const isInDialog = (
|
||||
rect.top <= e.clientY &&
|
||||
@@ -101,8 +93,43 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Submission is left to the browser: the form carries a server-rendered action
|
||||
// (correct under a reverse-proxy sub-path) and Enter in the input triggers implicit
|
||||
// submission via the footer's submit button, which also runs `required` validation.
|
||||
// Handle Enter key in search input
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (searchForm) {
|
||||
// Trigger form submission programmatically
|
||||
searchForm.dispatchEvent(new Event('submit'));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle form submission
|
||||
if (searchForm) {
|
||||
searchForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Get form data
|
||||
const formData = new FormData(searchForm);
|
||||
const searchQuery = formData.get('q');
|
||||
const tags = formData.get('tags');
|
||||
|
||||
// Build URL
|
||||
const params = new URLSearchParams();
|
||||
if (searchQuery) {
|
||||
params.append('q', searchQuery);
|
||||
}
|
||||
if (tags) {
|
||||
params.append('tags', tags);
|
||||
}
|
||||
|
||||
// Navigate to search results (always redirect to watchlist home)
|
||||
// Use base_path if available (for sub-path deployments like /enlighten-richerx)
|
||||
const basePath = typeof base_path !== 'undefined' ? base_path : '';
|
||||
window.location.href = basePath + '/?' + params.toString();
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
// Left-rail expand/collapse state.
|
||||
// Adds `actionsidebar-expanded` to <body> whenever the rail is showing its
|
||||
// labels. In 'pinned-expanded' mode (body.actionside-bar-on) the class is already
|
||||
// present from page load; in 'expandable' mode (body.actionsidebar-minimal) the rail
|
||||
// only expands on hover/focus, so we toggle the class to match. 'minimal' mode is
|
||||
// also the collapsed rail but carries `actionsidebar-no-expand` and never rolls out.
|
||||
// Adds `action-side-bar-expanded` to <body> whenever the rail is showing its
|
||||
// labels. In pinned mode (body.actionside-bar-on) the class is already present
|
||||
// from page load; in collapsed mode (body.actionsidebar-minimal) the rail only
|
||||
// expands on hover/focus, so we toggle the class to match.
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (!document.body.classList.contains('actionsidebar-minimal') ||
|
||||
document.body.classList.contains('actionsidebar-no-expand')) {
|
||||
if (!document.body.classList.contains('actionsidebar-minimal')) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -18,8 +16,8 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const expand = () => document.body.classList.add('actionsidebar-expanded');
|
||||
const collapse = () => document.body.classList.remove('actionsidebar-expanded');
|
||||
const expand = () => document.body.classList.add('action-side-bar-expanded');
|
||||
const collapse = () => document.body.classList.remove('action-side-bar-expanded');
|
||||
|
||||
inner.addEventListener('mouseenter', expand);
|
||||
inner.addEventListener('mouseleave', collapse);
|
||||
|
||||
@@ -115,23 +115,17 @@ window.initVisualSelector = function (opts) {
|
||||
$('#selector-current-xpath, #clear-selector').hide();
|
||||
})
|
||||
.on('load', () => {
|
||||
console.log(`Loaded background ${$selectorBackgroundElem[0].naturalWidth}px`);
|
||||
// For the UI width of the whole edit area
|
||||
document.documentElement.style.setProperty('--visualselector-max-width', `${$selectorBackgroundElem[0].naturalWidth}px` );
|
||||
console.log("Loaded background...");
|
||||
c = $selectorCanvasElem[0];
|
||||
xctx = c.getContext("2d");
|
||||
ctx = c.getContext("2d");
|
||||
// Drop any handlers left over from a previous load BEFORE (re)building the
|
||||
// selector - applyElementData() runs synchronously for inline xpathData and
|
||||
// binds the element handlers itself, so unbinding afterwards would silently
|
||||
// kill hover/click selection on the add-watch snapshot path.
|
||||
$selectorCanvasElem.off("mousemove mousedown mouseleave");
|
||||
if (source.xpathData) {
|
||||
// Inline data (add-watch snapshot) - no extra round trip needed
|
||||
applyElementData(source.xpathData);
|
||||
} else {
|
||||
fetchData();
|
||||
}
|
||||
$selectorCanvasElem.off("mousemove mousedown");
|
||||
});
|
||||
|
||||
// data: URIs must be used verbatim; real URLs get a cache-buster
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,48 +1,3 @@
|
||||
@use "settings" as *;
|
||||
|
||||
// Paint the page's own background (page colour + the fixed 130deg gradient at
|
||||
// 0.91, pre-composited with color-mix) so a sticky bar stays opaque to content
|
||||
// scrolling under it while looking identical to the page behind it.
|
||||
//
|
||||
// styles.scss paints that backdrop as a real fixed layer: body::after is a
|
||||
// `position: fixed` box at the viewport's top-left corner, viewport-wide and
|
||||
// `height: 100vh` tall. Anything that has to disappear into it must show the
|
||||
// slice of that box it covers. background-attachment: fixed asks the engine for
|
||||
// exactly that, and iOS Safari does not honour it - it anchors the layer to the
|
||||
// document instead, so the bar sweeps the whole ramp (cyan to purple over a
|
||||
// long page) while the gutters beside it hold still. Chromium does honour it,
|
||||
// which is why only a device caught this.
|
||||
//
|
||||
// So derive the alignment from geometry and ask for no feature at all. Each bar
|
||||
// keeps a constant viewport offset - .header at the top, #diff-header directly
|
||||
// below it - and both are flush with the viewport's right edge at every width:
|
||||
// the action rail insets them from the *left* on a wide screen and widens again
|
||||
// on hover, but nothing moves their right edge. Anchor a viewport-sized copy of
|
||||
// the gradient to that edge and push it up by the bar's own offset, and the bar
|
||||
// paints body::after's slice by construction, on any engine.
|
||||
//
|
||||
// $y-offset: the bar's distance from the top of the viewport.
|
||||
@mixin page-surface-gradient($y-offset: 0px) {
|
||||
background: var(--color-background-gradient-second); // fallback, no color-mix
|
||||
// Longhands from here down, and no second `background:` shorthand after them -
|
||||
// one would silently reset size/position/origin back to their initial values.
|
||||
background-image: linear-gradient(130deg,
|
||||
color-mix(in srgb, var(--color-background-gradient-first) 91%, var(--color-background-page)),
|
||||
color-mix(in srgb, var(--color-background-gradient-second) 91%, var(--color-background-page)) 41.07%,
|
||||
color-mix(in srgb, var(--color-background-gradient-third) 91%, var(--color-background-page)) 84.05%);
|
||||
// 100vh to match body::after's own height: 100vh - same unit, so the two
|
||||
// resolve to the same number on iOS, where it means the large (toolbar
|
||||
// collapsed) viewport and therefore holds still as the toolbar moves.
|
||||
// 100vw for the width: the bars are as wide as the viewport only on a narrow
|
||||
// screen, so 100% would compress the ramp into the rail's leftovers on a
|
||||
// desktop one.
|
||||
background-size: 100vw 100vh;
|
||||
background-repeat: no-repeat;
|
||||
// border-box, or the bar's own padding would offset the copy by that much.
|
||||
background-origin: border-box;
|
||||
background-position: right 0 top calc(-1 * #{$y-offset});
|
||||
}
|
||||
|
||||
#diff-form {
|
||||
|
||||
background: rgba(0, 0, 0, .05);
|
||||
@@ -85,451 +40,8 @@
|
||||
}
|
||||
|
||||
body.difference-page {
|
||||
// Keep the top menu (watch URL, EDIT, theme/GitHub icons) on screen while the
|
||||
// diff scrolls, stacked above #diff-header. Scoped to the diff page on
|
||||
// purpose - upstream's @todo in parts/_top_menu.scss wants this globally, but
|
||||
// that is a separate change.
|
||||
//
|
||||
// Sticky makes .header a stacking context, so its mobile drawer / overlay
|
||||
// (z-index 10000 / 9999) are now capped by this value. 30 clears #diff-header
|
||||
// below and still leaves toasts (10000, fixed) painting over the bar.
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
@include page-surface-gradient(0px);
|
||||
}
|
||||
|
||||
// The top line now carries the watch's name, so give it back the room the two
|
||||
// stacked left insets were taking. Its text starts $common-gap twice in: the
|
||||
// menu row's own padding, which every item on the row needs to clear the edge,
|
||||
// plus the link's own left margin, which on the row's first item only repeats
|
||||
// that padding. Drop the repeat and keep the row's padding: 8.8px back, and
|
||||
// the line still sits on the same left edge as the diff below it rather than
|
||||
// hard against the viewport. The right side of both is untouched - the link's
|
||||
// right margin still separates it from the menu icons.
|
||||
.current-diff-url {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
// Wherever the hamburger is (max-width: $desktop-wide-breakpoint, set in
|
||||
// parts/_hamburger_menu.scss), the menu row is at its tightest and every item
|
||||
// on it competes with the watch name for the same line. The heart is the one
|
||||
// item on that row that is neither navigation nor status, so it is what gives
|
||||
// way - on this page only, and only under that width. Desktop diff pages and
|
||||
// every other page keep it.
|
||||
//
|
||||
// CSS rather than a {% if %} in base.html: the heart's own reason to exist is
|
||||
// that it is always there, and a template branch would take it off the page
|
||||
// entirely rather than fold it away with the rest of the row.
|
||||
@media only screen and (max-width: $desktop-wide-breakpoint) {
|
||||
#heart-us {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// .app-main's 0.55rem gap sits between the two sticky bars, so #diff-header
|
||||
// starts 8.8px below the top menu and then slides up to meet it over the
|
||||
// first 8.8px of scroll - a visible twitch right as scrolling begins. The gap
|
||||
// can't be preserved once stuck (it is outside both bars, so the diff would
|
||||
// scroll through it), so close it here and let them sit flush at every scroll
|
||||
// position. Only the .header/section.content pair is affected; .app-main has
|
||||
// no other children.
|
||||
.app-main {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.tab-pane-inner {
|
||||
scroll-margin-top: calc(var(--app-header-height, 0px) + var(--diff-header-height, 0px) + 1rem);
|
||||
}
|
||||
|
||||
// The 2px activity strip is fixed on body, so it lives in the root stacking
|
||||
// context and would paint over the mobile drawer now that the drawer is capped
|
||||
// inside .header's context above. Drop it below .header rather than raising
|
||||
// .header past the action rail (60), which would hide the rail's hover flyout.
|
||||
// Still above #diff-header (20) and the minimap (10); on this page it now also
|
||||
// passes under the rail's flyout, which is 2px of imperceptible overlap.
|
||||
#pure-menu-horizontal-spinner {
|
||||
z-index: 29;
|
||||
}
|
||||
|
||||
// The thin blue line under the active tab. Nothing at that boundary has a
|
||||
// border - the active tab and #diff-ui are both var(--color-background) - so
|
||||
// it can only be the page gradient showing through a device-pixel row where
|
||||
// neither white paints. Whether such a row exists comes down to one number,
|
||||
// the bar's bottom edge minus the card's top edge, and measured across 14
|
||||
// viewports at three device pixel ratios that number is 0.813, 0.203 or
|
||||
// exactly 0.000 and never a whole pixel. At 0.000 the outcome belongs
|
||||
// entirely to the engine's sub-pixel rounding - which is why a DPR 3 phone
|
||||
// can show a line a DPR 1 desktop does not, and why 390x844 on the Screenshot
|
||||
// tab, the exact case reported, measures 0.000.
|
||||
//
|
||||
// So take the margin out of the engine's hands instead of hoping two white
|
||||
// edges land on the same device row. A pixel of overlap covers the gradient
|
||||
// on any grid; measured the same way afterwards, the worst case over those 42
|
||||
// readings becomes exactly 1.000px.
|
||||
//
|
||||
// The other candidate was a white skirt under the active tab
|
||||
// (box-shadow: 0 1px 0) which would have touched nothing else, and it does not
|
||||
// work: the shadow hangs below the bar's last child and #diff-header's
|
||||
// overflow: auto - the thing enforcing its height cap - clips it. Measured
|
||||
// with the card held 3px away so the skirt and the card are separable, it
|
||||
// painted 0 device rows of the 3 it needs at 844x390 and 926x428, and 0 to 2
|
||||
// in the sticky modes. Recorded here so it is not proposed again.
|
||||
//
|
||||
// What the overlap costs, stated rather than discovered later: where the bar
|
||||
// is sticky it is invisible, and a device-pixel diff of the seam band at
|
||||
// 390x844 and DPR 3 says so exactly - zero differing pixels, because the bar
|
||||
// is opaque and paints over the card. Below the short-viewport breakpoint the
|
||||
// bar has no background of its own, so the card's white also reaches the
|
||||
// bottom pixel of the *inactive* tabs and the 5px gaps between them: 4374
|
||||
// device pixels at 844x390, gradient turning white. The tab row ends up
|
||||
// sitting flush on the card rather than floating a pixel above it, which is
|
||||
// how tabs normally meet their panel and is the same continuity the active
|
||||
// tab was asked for.
|
||||
//
|
||||
// Scoped to the diff page deliberately. diff.css is NOT diff-page-only - it
|
||||
// is also served to the preview page (blueprint/ui/preview.py) and the
|
||||
// extract page (processors/extract.py), and both have a #diff-ui of their own
|
||||
// with no bar above it to absorb the pixel. Only difference.py sets the
|
||||
// difference-page class.
|
||||
#diff-ui {
|
||||
margin-top: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
#diff-header {
|
||||
position: sticky;
|
||||
// Sits directly under the now-sticky top menu. The 0 fallback (JS off) just
|
||||
// slides this under the bar - the same degradation the minimap already takes.
|
||||
top: var(--app-header-height, 0px);
|
||||
z-index: 20;
|
||||
// section.content insets its children by $common-gap so they clear the left
|
||||
// rail, while the top menu above stays flush against it. A bar that paints
|
||||
// the page's own backdrop therefore leaves an 8.8px strip of real page
|
||||
// background down each side, and on a device those strips read as mismatched
|
||||
// borders. Cancel the inset rather than try to blend across it: with no
|
||||
// gutter there is nothing left to keep aligned, and the flush right edge is
|
||||
// what page-surface-gradient anchors its copy of the page gradient to. The
|
||||
// controls do not move - the bar grows outwards by exactly the margin it
|
||||
// takes back.
|
||||
//
|
||||
// The width has to come from align-self, not width: 100%. .content-main
|
||||
// centres its children, so an item is fit-content unless it stretches, and a
|
||||
// percentage width resolves against the parent without the negative margins -
|
||||
// it would leave the bar short by 17.6px.
|
||||
align-self: stretch;
|
||||
margin-left: -$common-gap;
|
||||
margin-right: -$common-gap;
|
||||
box-sizing: border-box;
|
||||
// Center children at their intrinsic width, the same way section.content does
|
||||
// for the non-sticky siblings it still lays out (align-items: center).
|
||||
// Two rows: the toolbar and the tabs.
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
// No bottom padding: the tab row is the bar's last child, and the active tab
|
||||
// and #diff-ui below it are both var(--color-background). 4px of gradient
|
||||
// between them reads as a seam across an otherwise continuous white surface -
|
||||
// the same tabs on the extract page, which are not in a bar, measure 0. The
|
||||
// top padding stays; nothing sits above the toolbar.
|
||||
padding: 0.25rem 1rem 0;
|
||||
// Keep the diff readable even with wrapped controls, zoom, or a short viewport.
|
||||
// The budget is for the whole sticky stack, so the top menu above comes out of
|
||||
// it - otherwise the two bars together take 64% of a 390px-tall landscape phone.
|
||||
//
|
||||
// svh, not dvh. dvh tracks the dynamic viewport, which on iOS Safari grows and
|
||||
// shrinks as the browser's own toolbar collapses and expands while you scroll:
|
||||
// the cap would then breathe mid-scroll, changing the header's height and
|
||||
// shifting everything below it, and each transition also fires resize and
|
||||
// re-runs the sticky measurement behind it. svh is defined as the
|
||||
// toolbar-expanded minimum and holds still for the life of the page, so the
|
||||
// budget is a constant. It is the smaller of the two, which is the safe side to
|
||||
// be wrong on for a cap. The plain vh line stays as the fallback for engines
|
||||
// without the new units.
|
||||
max-height: calc(50vh - var(--app-header-height, 0px));
|
||||
max-height: calc(50svh - var(--app-header-height, 0px));
|
||||
overflow: auto;
|
||||
|
||||
// Same offset as `top` above: the bar's constant distance from the top of the
|
||||
// viewport. With JS off the var is absent and the copy is misaligned by the
|
||||
// top menu's height - the same tier of degradation `top` itself already takes.
|
||||
@include page-surface-gradient(var(--app-header-height, 0px));
|
||||
|
||||
> * {
|
||||
// A centered flex item is sized to fit-content, which has a min-content
|
||||
// floor (the version <select> is as wide as its longest option). Without
|
||||
// this cap a narrow viewport would centre an over-wide child and spill it
|
||||
// out of both sides of the header.
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
// base.html's wrap detector decides body.wrapped-tabs by flipping the tab <ul>
|
||||
// to flex-wrap and seeing whether any tab lands on a second row - so what it
|
||||
// measures against is the width of .tabs. _tabs.scss says in upstream's own
|
||||
// words that .tabs has to take the full width of the centred column "so the
|
||||
// wrap-detector JS has the real available space to measure against", and the
|
||||
// two properties that would do it are commented out directly underneath. As
|
||||
// shipped, .tabs is fit-content inside any centred column, so the detector
|
||||
// measures a container that moves with the layout state it is judging: the
|
||||
// active tab's label is bold, and selecting the widest one ("Current
|
||||
// screenshot") grows .tabs by ~9px. In the band where that crosses the
|
||||
// available width the verdict flips per tab - measured here at 350, 355 and
|
||||
// 358px, where Text and Extract Data report one row and Current screenshot
|
||||
// reports three. iOS puts the same band at 390px because its fonts are wider,
|
||||
// which is how a phone showed one row in a screenshot and a vertical stack in
|
||||
// a video of the same page.
|
||||
//
|
||||
// Give the detector a fixed target on this page only. Un-commenting the lines
|
||||
// in _tabs.scss would fix it everywhere and change every page upstream owns;
|
||||
// this PR does not have the standing to do that.
|
||||
.tabs {
|
||||
align-self: stretch;
|
||||
// Never give up height to keep the controls whole - see #settings below.
|
||||
flex: none;
|
||||
|
||||
// .tabs no longer shrink-wraps, so the grid would sit left-aligned in it.
|
||||
ul {
|
||||
justify-content: center;
|
||||
|
||||
// Stretching the container fixes one side of the detector's comparison;
|
||||
// this fixes the other. `.active a { font-weight: bold }` in _tabs.scss
|
||||
// makes the row's width depend on which label is currently bold: at 355px
|
||||
// the Text and Extract states measure 332.9 and 330.5 against a 339.0
|
||||
// container and stay on one row, while Current screenshot - the widest
|
||||
// label - measures over it and wraps to three. Neutralising just that rule
|
||||
// collapses all three states to 330.5 and the flipping stops, which is what
|
||||
// identifies the weight as the variable rather than the container.
|
||||
//
|
||||
// So drop the bold here rather than reserve room for it. Reserving the bold
|
||||
// width on every tab (a hidden bold copy behind each label) also stabilises
|
||||
// it, but it makes the row ~14px wider in every state and that pushes 360px
|
||||
// - a common Android width that fits on one row today - into the stacked
|
||||
// layout. The active tab does not need the third signal: _tabs.scss already
|
||||
// gives it its own background-color and its own text colour.
|
||||
li.active a,
|
||||
li :target a {
|
||||
font-weight: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The bar's height budget (max-height above) is smaller than its contents on a
|
||||
// landscape phone: iOS gives the two datetime <select>s enough intrinsic width
|
||||
// that From and To each take their own row, and From + To + Filters + tabs is
|
||||
// then taller than 50svh minus the top menu. With overflow: auto on the bar
|
||||
// itself the row that leaves the visible box is the last one - the tabs, which
|
||||
// are the page's navigation and have no scrollbar to advertise themselves. Measured in Chromium with no iOS emulation at 736x414, 667x375,
|
||||
// 780x360 and 844x330: the tab row sits outside #diff-header's own rect.
|
||||
//
|
||||
// So spend the budget on the row that can afford it. #settings is the only
|
||||
// child allowed to shrink, and it is what scrolls; the tab row never does and
|
||||
// is therefore always inside the bar at any viewport height. The shrink needs
|
||||
// min-height: 0 - a flex item's automatic minimum size is its content, which
|
||||
// is what would otherwise push the tabs back out.
|
||||
#settings {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex: 0 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
// Same reason the Filters popover carries it: a nested scroller inside a
|
||||
// sticky bar that chains its overscroll to the diff behind is worse than no
|
||||
// scroller at all on a touch screen.
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
// One toolbar row: From/To chips and their selects, the version arrows, and
|
||||
// the Filters toggle.
|
||||
#diff-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem 0.9rem;
|
||||
padding: 0.2rem 0.7rem;
|
||||
margin-bottom: 0;
|
||||
|
||||
fieldset {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
// The From/To labels shrink to chips rather than hiding: display:none takes
|
||||
// the <label> out of the accessibility tree and the selects lose their
|
||||
// accessible name entirely, and two identically formatted datetime selects
|
||||
// side by side need the red/green cue sighted users already know. The
|
||||
// shipped width: 4rem is what makes them full-size plates. "From" is wider
|
||||
// than "To", which left-ragged the selects once they stack, so the floor is
|
||||
// wide enough for the longer word - it costs width only, never height.
|
||||
.from-to-label {
|
||||
width: auto;
|
||||
min-width: 2.4rem;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 3px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
#keyboard-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
|
||||
// The arrows speak for themselves at this size; the links keep the full
|
||||
// wording as aria-label and title (see diff.html).
|
||||
strong,
|
||||
.keyboard-nav-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Seven set-once options are a popover, not a permanent row of the bar. The
|
||||
// swap happens only once diff-overview.js has wired the toggle up, so with
|
||||
// scripting off the fieldset stays inline and submits as it does today.
|
||||
#diff-filters-toggle {
|
||||
display: none;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.3em 0.8em;
|
||||
}
|
||||
|
||||
&.diff-filters-js {
|
||||
#diff-filters-toggle {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
#diff-style {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.diff-filters-js.diff-filters-open {
|
||||
#diff-style {
|
||||
// fixed, not absolute: #diff-header carries overflow: auto to enforce the
|
||||
// 50svh cap above, which clips an absolutely positioned descendant at the
|
||||
// bar's bottom edge. The bar is sticky with no transform/filter/contain,
|
||||
// so it is not a containing block for fixed and the panel escapes the
|
||||
// clip. diff-overview.js parks it under the toggle.
|
||||
display: block;
|
||||
position: fixed;
|
||||
z-index: 25;
|
||||
min-width: 14rem;
|
||||
// diff-overview.js caps max-height to the room below the button that the
|
||||
// viewport actually has - on a landscape phone that is less than the
|
||||
// seven rows need. The rows past the cap have to stay reachable, and the
|
||||
// scroll must not chain out to the diff behind once they run out.
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, .28);
|
||||
text-align: left;
|
||||
|
||||
// The bar repaints the page's own backdrop; so must anything that has to
|
||||
// be opaque over the scrolling diff. This one is not pinned to an edge of
|
||||
// the viewport - diff-overview.js decides where it goes - so the same
|
||||
// file overwrites background-position from the coordinates it just set.
|
||||
// The declaration here is what a placement-less first paint falls back to.
|
||||
@include page-surface-gradient;
|
||||
}
|
||||
|
||||
// Out-specifies #diff-form #diff-style > span's inline-block above.
|
||||
#diff-form #diff-style > span {
|
||||
display: block;
|
||||
padding: 0.2em 0;
|
||||
}
|
||||
}
|
||||
|
||||
.diff-fieldset {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
|
||||
> span {
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
gap: 0.25rem;
|
||||
// Size to content so From/To stay side-by-side on desktop (as they were
|
||||
// before the header existed); shrink and wrap only when the row can't fit.
|
||||
flex: 0 1 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
// A <select> keeps the intrinsic min-content width of its longest option
|
||||
// (min-width: 0 does not lower it), so label + select cannot fit a phone
|
||||
// viewport side by side. Let the label drop above it instead of forcing
|
||||
// the whole header wider than the screen.
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
select {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
label {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The one narrow-viewport breakpoint for the whole bar - title, fit and
|
||||
// gutters all hang off it rather than letting magic numbers multiply.
|
||||
//
|
||||
// Every selector below carries an ID on purpose. The rules it has to beat are
|
||||
// written above as `#diff-header .diff-fieldset > span` and friends, and a
|
||||
// single ID outranks any number of classes, so a class-only override loses
|
||||
// silently and the chain goes on shrink-wrapping. Please don't tidy the IDs
|
||||
// away.
|
||||
@media (max-width: 700px) {
|
||||
// Once the From/To chip takes inline width the <select> clips its own
|
||||
// displayed value, and two snapshots from the same day differ only by the
|
||||
// meridiem - so they read identically while closed. Shrinking the font alone
|
||||
// cannot fix that: #settings, #diff-form, .diff-fieldset and the span are all
|
||||
// sized to max-content, which *is* the select's text, so the container
|
||||
// shrinks by exactly as much as the text does and the headroom stays pinned
|
||||
// at zero at every font size. Pin the chain to the row first; only then does
|
||||
// the smaller font buy anything.
|
||||
#diff-header > #settings {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#diff-header #diff-form {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
// 16px of bar gutter plus 11.2px of form gutter a side is 54px of a 390px
|
||||
// row spent on nothing.
|
||||
padding-left: 0.2rem;
|
||||
padding-right: 0.2rem;
|
||||
}
|
||||
|
||||
#diff-header {
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
#diff-header #diff-form > .diff-fieldset {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
#diff-header .diff-fieldset > span {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
// With the chain pinned the select holds its width, so this is what actually
|
||||
// buys the room. The worst realistic English value - a two-digit day, the
|
||||
// longest month and a two-digit hour - still clips at 0.8rem.
|
||||
#diff-header .diff-fieldset select {
|
||||
font-size: 0.75rem;
|
||||
padding-left: 0.3em;
|
||||
padding-right: 0.3em;
|
||||
section.content {
|
||||
padding-top: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -658,7 +170,7 @@ body.difference-page {
|
||||
border-radius: 3px;
|
||||
overflow-x: hidden;
|
||||
position: sticky;
|
||||
top: calc(var(--app-header-height, 0px) + var(--diff-header-height, 0px));
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
@@ -734,82 +246,3 @@ body.difference-page {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Placed last on purpose. Several of these rules out-specify nothing - they
|
||||
// tie with the rule they are overriding, and a tie is decided by source
|
||||
// order. The minimap's sticky offset is written further down this file as
|
||||
// `#diff-ui #cell-diff-jump-visualiser`, so a block that sits above it loses
|
||||
// silently. Keep this one at the bottom.
|
||||
//
|
||||
// A phone held sideways is the one place the sticky design cannot pay for
|
||||
// itself. 50svh of a 390px-tall viewport is 195px, the top menu takes 55 of it,
|
||||
// and what is left has to hold From, To, Filters and the tab row - three of
|
||||
// those on their own lines, because iOS gives the two datetime selects enough
|
||||
// width that From and To cannot share one. #settings shrinking and scrolling
|
||||
// (see above) keeps the tabs inside the bar, but a bar you have to scroll
|
||||
// *inside* to reach half its controls is still a bar in the way, and no amount
|
||||
// of trimming decoration finds 195px.
|
||||
//
|
||||
// So stop compensating for the budget here and give up the budget instead.
|
||||
// Below 500px of viewport height both bars are ordinary static blocks: no
|
||||
// height cap, no inner scroller, title and every control present at once, and
|
||||
// the page scrolls as one document. The trade is the sticky guarantee itself -
|
||||
// the controls and the tab row scroll away with the diff, so switching tabs
|
||||
// means scrolling back up - which is the right side to be wrong on when the
|
||||
// alternative is controls you cannot see. Portrait is untouched.
|
||||
//
|
||||
// Keyed on viewport height rather than orientation: a short desktop window has
|
||||
// the identical problem for the identical reason.
|
||||
@media (max-height: 500px) {
|
||||
body.difference-page .header {
|
||||
// Not a new state: parts/_top_menu.scss ships .header with its sticky
|
||||
// commented out, so this is the position every other page's top menu is
|
||||
// already in. z-index goes inert along with it, which hands the mobile
|
||||
// drawer's 10000 back to the root stacking context.
|
||||
position: static;
|
||||
// Nothing scrolls underneath a static bar, so it neither needs its
|
||||
// pre-composited copy of the page gradient nor may keep it: the copy is
|
||||
// aligned by pushing it up by the bar's own *constant* viewport offset, and
|
||||
// a bar that scrolls has no constant offset. It would slide out of phase
|
||||
// with body::after's fixed layer and read as a moving seam. The shorthand,
|
||||
// not background-image - it resets size, origin and position too.
|
||||
background: none;
|
||||
}
|
||||
|
||||
#diff-header {
|
||||
position: static;
|
||||
// The cap and the scroller are what the sticky design pays for its
|
||||
// guarantee that the diff keeps half the viewport. There is no longer a
|
||||
// guarantee to pay for.
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
background: none;
|
||||
|
||||
#settings {
|
||||
// Same: it only shrank and scrolled to keep the tab row inside the cap.
|
||||
min-height: auto;
|
||||
overflow-y: visible;
|
||||
}
|
||||
}
|
||||
|
||||
// .tab-pane-inner's scroll-margin-top is deliberately NOT overridden here,
|
||||
// and dropping it to a bare 1rem is a trap worth naming. It reads as an
|
||||
// allowance for pinned chrome, so with nothing pinned it looks like dead
|
||||
// weight - but upstream's tabs.js rewrites an empty hash to the first tab's
|
||||
// (tabs.js:15) and the browser then jumps to that pane on load, before any
|
||||
// of this is a choice. At 1rem that jump lands with the whole static bar
|
||||
// already scrolled off the top: measured -175.8px at 844x390, a landscape
|
||||
// page that opens with no controls on it at all. The existing formula is the
|
||||
// right number in both modes for two different reasons - in the sticky one it
|
||||
// clears chrome that is pinned over the pane, in the static one it keeps
|
||||
// chrome that sits above the pane on screen. Leave it alone.
|
||||
|
||||
// The minimap is sticky inside the diff card, offset to sit directly below
|
||||
// the sticky stack. With that stack gone its offset would pin it a top menu
|
||||
// plus a whole toolbar down the viewport - past halfway on a 390px-tall one,
|
||||
// a strip floating in mid-screen beneath nothing. It is still worth pinning;
|
||||
// pin it to the viewport, which is now free.
|
||||
#diff-ui #cell-diff-jump-visualiser {
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,27 +32,21 @@ $action-sidebar-content-slot: 1100px;
|
||||
// Hidden on mobile: the mobile drawer (hamburger) carries these items
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// MINIMAL MODE: the rail's footprint in the flex row is frozen at the
|
||||
// collapsed width (+ the inner block's horizontal padding, which is
|
||||
// content-box). The inner block is taken out of flow (absolute, below) so
|
||||
// its hover expansion rolls out ON TOP of the page content instead of
|
||||
// widening this flex item and reflowing `.app-main`.
|
||||
body.actionsidebar-minimal & {
|
||||
flex: 0 0 auto;
|
||||
width: calc(#{$action-sidebar-width-collapsed} + #{$common-gap * 2});
|
||||
overflow: visible; // let the expanded inner block escape the frame
|
||||
body.actionside-bar-on {
|
||||
.action-sidebar {
|
||||
/* width: $action-sidebar-width-expanded; // reserve enough to host the expanded inner block*/
|
||||
}
|
||||
}
|
||||
|
||||
// The actual interactive item block — wraps only the items, not the whole height.
|
||||
//
|
||||
// Body-level classes drive the rail's display state (emitted by
|
||||
// flask_app.get_sidebar_mode_class() from the `sidebar_mode` setting):
|
||||
// body.actionside-bar-on → always expanded (icons + labels visible all the time)
|
||||
// body.actionsidebar-minimal → icon-only collapsed rail; expands on hover/focus
|
||||
// body.actionsidebar-no-expand → alongside the above: stays collapsed, no hover-out
|
||||
// Both the hover-to-expand width animation and the label fade-in are gated on the
|
||||
// Two body-level modes drive the rail's display state:
|
||||
// body.actionside-bar-on → always expanded (icons + labels visible all the time)
|
||||
// body.actionsidebar-minimal → icon-only collapsed rail; expands on hover/focus
|
||||
// `actionside-bar-on` is the default applied in templates/base.html. Both the
|
||||
// hover-to-expand width animation and the label fade-in are gated on the
|
||||
// minimal class so that the always-expanded mode (or no mode) doesn't trigger
|
||||
// a needless layout jump when the user mouses across the rail.
|
||||
.action-sidebar-inner {
|
||||
@@ -70,40 +64,12 @@ $action-sidebar-content-slot: 1100px;
|
||||
padding-left: $common-gap;
|
||||
padding-right: $common-gap;
|
||||
|
||||
// MINIMAL MODE: lift the block out of the flex flow so growing it can't
|
||||
// resize the rail (and therefore can't shift the content column). The rail
|
||||
// is `position: sticky`, i.e. a positioned element, so top/left/bottom here
|
||||
// anchor to it; top+bottom keep the column full-height, which the footer
|
||||
// list's `margin-top: auto` still needs.
|
||||
//
|
||||
// Once it floats above the page it needs its own backdrop, or the content
|
||||
// it rolls over would read straight through the rail's translucent 5% white.
|
||||
// It can't just be a flat colour: the page background is a fixed, viewport-
|
||||
// sized gradient (`body:after`), so the rail repaints that same gradient with
|
||||
// `background-attachment: fixed` — which makes the viewport the positioning
|
||||
// area — and lays the rail's own 5% white on top. Result: opaque, but
|
||||
// pixel-identical to the translucent rail it replaces, at rest and rolled out.
|
||||
body.actionsidebar-minimal & {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
background-color: var(--color-background-page); // fallback under the gradient
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.05)),
|
||||
var(--page-background-gradient);
|
||||
background-attachment: fixed, fixed;
|
||||
}
|
||||
|
||||
// Hover-to-expand only fires in minimal mode, and not for 'minimal' (no-expand).
|
||||
body.actionsidebar-minimal:not(.actionsidebar-no-expand) &:hover,
|
||||
body.actionsidebar-minimal:not(.actionsidebar-no-expand) &:focus-within {
|
||||
// Hover-to-expand only fires in minimal mode.
|
||||
body.actionsidebar-minimal &:hover,
|
||||
body.actionsidebar-minimal &:focus-within {
|
||||
width: $action-sidebar-width-expanded;
|
||||
// Enter is smoother.
|
||||
transition: width 0.22s cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||
// Lift it off the page while it's rolled out over the content.
|
||||
box-shadow: var(--color-sidebar-shadow);
|
||||
}
|
||||
|
||||
// Always-expanded mode: no hover gate; the rail just sits open.
|
||||
@@ -262,7 +228,7 @@ ul.action-sidebar-list {
|
||||
letter-spacing: 0.06em;
|
||||
font-weight: 700;
|
||||
pointer-events: none;
|
||||
opacity: 0.9;
|
||||
opacity: 0;
|
||||
transition: opacity 0.05s ease-out;
|
||||
|
||||
// Unread-count variant: a small accent bubble pinned to the right of the row
|
||||
@@ -281,17 +247,11 @@ ul.action-sidebar-list {
|
||||
}
|
||||
}
|
||||
|
||||
// Count badges (unread, queue) are always visible in minimal mode so users
|
||||
// can see at a glance how many items need attention without expanding the rail.
|
||||
body.actionsidebar-minimal .action-badge--count {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
// When the inner block expands in MINIMAL mode, reveal every label/badge
|
||||
// together with a smooth enter. Always-expanded mode handles labels via the
|
||||
// `body.actionside-bar-on` block below — no hover gate needed there.
|
||||
body.actionsidebar-minimal:not(.actionsidebar-no-expand) .action-sidebar-inner:hover,
|
||||
body.actionsidebar-minimal:not(.actionsidebar-no-expand) .action-sidebar-inner:focus-within {
|
||||
body.actionsidebar-minimal .action-sidebar-inner:hover,
|
||||
body.actionsidebar-minimal .action-sidebar-inner:focus-within {
|
||||
.action-sidebar-item {
|
||||
.action-label {
|
||||
opacity: 1;
|
||||
@@ -481,7 +441,7 @@ body.actionside-bar-on {
|
||||
}
|
||||
}
|
||||
|
||||
&.actionsidebar-expanded {
|
||||
&.action-side-bar-expanded {
|
||||
#checking-now-stats-sidebar{
|
||||
display: block;
|
||||
}
|
||||
@@ -489,29 +449,15 @@ body.actionside-bar-on {
|
||||
#logo-expanded {
|
||||
display: inline-block;
|
||||
}
|
||||
#logo-short {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
&:not(.actionsidebar-expanded) {
|
||||
.action-label {
|
||||
display: none;
|
||||
}
|
||||
.action-badge--count {
|
||||
position: relative;
|
||||
left: -8px;
|
||||
top: -8px;
|
||||
}
|
||||
.action-sidebar-item.is-disabled {
|
||||
.action-badge {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
.action-side-bar-expanded {
|
||||
#cdio-logo {
|
||||
#logo-short {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,58 +158,14 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
color: #fff;
|
||||
|
||||
@media (max-width: 900px) {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
span.label {
|
||||
padding-bottom: 0.5rem;
|
||||
display: inline-block !important;
|
||||
}
|
||||
label {
|
||||
margin: 0 !important;; /* override */
|
||||
}
|
||||
li {
|
||||
list-style: none;
|
||||
font-size: .9rem;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
|
||||
input[type="radio"] {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
> label {
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
li + li { /* gap between */
|
||||
margin-top: .75rem;
|
||||
}
|
||||
|
||||
// Browser picker: fetcher descriptions are long (they carry the driver URL), so this is
|
||||
// a radio list whose labels wrap inside the narrow pane instead of a <select> that would
|
||||
// overflow it. The system-default entry is listed even when it can't render a preview.
|
||||
#quick-watch-fetch-backend {
|
||||
|
||||
li.unusable {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
|
||||
label {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.pure-form-message-inline {
|
||||
display: block;
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.8;
|
||||
.add-watch-option-group {
|
||||
label {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
width: 100%;
|
||||
overflow-y: scroll;
|
||||
position: relative;
|
||||
height: 80vh;
|
||||
|
||||
> img {
|
||||
position: absolute;
|
||||
|
||||
@@ -3,13 +3,9 @@
|
||||
// Clean outline button — surface background, hairline border, muted label that
|
||||
// darkens on hover. The app's neutral/secondary action button (toolbars, list
|
||||
// actions), a calmer alternative to the solid pure-button. Pairs with .seg.
|
||||
|
||||
.cdio-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
vertical-align: middle;
|
||||
box-sizing: border-box;
|
||||
|
||||
gap: 6px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
@@ -95,26 +91,3 @@
|
||||
&:hover { color: #d68a00; border-color: #d68a00; }
|
||||
}
|
||||
}
|
||||
|
||||
// State-mutating controls have to POST, and only a <button> can submit - these strip
|
||||
// the browser's button chrome so they render exactly like the <a> they replaced.
|
||||
.bare-btn {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
// Matches `a { color: var(--color-link) }` - these replace anchors, and the row
|
||||
// icons stroke with currentColor, so `inherit` would pick up .watch-controls red.
|
||||
color: var(--color-link);
|
||||
cursor: pointer;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-link);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&--link {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,9 +95,7 @@
|
||||
overflow-y: auto;
|
||||
padding-top: 60px;
|
||||
|
||||
.action-label {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
#cdio-logo {
|
||||
color: var(--color-text);
|
||||
|
||||
@@ -124,7 +122,7 @@
|
||||
li {
|
||||
border-bottom: 1px solid var(--color-border-table-cell);
|
||||
|
||||
>*, >form>button {
|
||||
>* {
|
||||
display: block;
|
||||
padding: 1rem 1.5rem;
|
||||
color: var(--color-text);
|
||||
@@ -136,14 +134,6 @@
|
||||
background: var(--color-background-menu-link-hover);
|
||||
}
|
||||
}
|
||||
|
||||
// Buttons shrink-wrap and centre their label; anchors don't. No global
|
||||
// border-box reset here, so this must not be folded into the rule above.
|
||||
>form>button {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
&#menu-pause, &#menu-mute {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#language-selector-flag {
|
||||
display: inline-block;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
vertical-align: middle;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
@@ -18,20 +18,10 @@
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0;
|
||||
|
||||
// The csrf mini-form around each option is layout-transparent, so the buttons
|
||||
// stay the flex items.
|
||||
> form {
|
||||
display: contents;
|
||||
}
|
||||
}
|
||||
|
||||
.language-option {
|
||||
display: flex;
|
||||
background: none;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.25rem;
|
||||
|
||||
@@ -15,13 +15,6 @@
|
||||
|
||||
.pure-menu-item {
|
||||
height: initial;
|
||||
|
||||
// Mini POST forms (pause/mute/log out need a csrf_token) are layout-transparent,
|
||||
// so the button inside sits where the plain <a> used to.
|
||||
> form {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
svg {
|
||||
height: 1.2rem;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
}
|
||||
|
||||
&.toast-top-center {
|
||||
top: 120px;
|
||||
top: 100px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
@@ -25,9 +25,6 @@
|
||||
rgba(200, 200, 200, 0.02),
|
||||
rgba(200, 200, 200, 0.6));
|
||||
}
|
||||
.bare-btn {
|
||||
color: var(--color-text-menu-heading);
|
||||
}
|
||||
}
|
||||
|
||||
ul#top-right-menu {
|
||||
@@ -50,18 +47,9 @@ ul#top-right-menu {
|
||||
}
|
||||
}
|
||||
|
||||
$current-diff-url-fade: 2.5em;
|
||||
|
||||
// -webkit- is still required for the mask on Safari, so every fade state has to
|
||||
// set the pair.
|
||||
@mixin current-diff-url-mask($image) {
|
||||
-webkit-mask-image: $image;
|
||||
mask-image: $image;
|
||||
}
|
||||
|
||||
// The current diff URL sits between the logo and the right-hand menu in the
|
||||
// horizontal nav. It can be arbitrarily long, so it must never push the
|
||||
// surrounding items around: it absorbs the available middle space and scrolls
|
||||
// surrounding items around: it absorbs the available middle space and trims
|
||||
// its own text, fading out toward the cut edge instead of a hard clip.
|
||||
.current-diff-url {
|
||||
// Fill the space between the logo and the right-hand menu so the URL block
|
||||
@@ -70,54 +58,19 @@ $current-diff-url-fade: 2.5em;
|
||||
// min-width:0 is required for a flex item to shrink below its content width,
|
||||
// otherwise the long URL forces the row to grow and shoves the menu off-screen.
|
||||
min-width: 0;
|
||||
// A scroll container rather than a clip, so the trimmed end can be reached by
|
||||
// dragging or swiping the line instead of only by hovering it. Browsers tell a
|
||||
// pan apart from a tap, so the anchor still navigates on a tap.
|
||||
//
|
||||
// overflow-y stays hidden: the content is one non-wrapping line, and an axis
|
||||
// that can scroll by a sub-pixel rounding error lets a diagonal swipe jog the
|
||||
// text vertically. The scrollbar is hidden in both engines - this is a nav
|
||||
// line a few characters tall, and a bar across it would cost more than it
|
||||
// says; the fades below are the affordance instead.
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
// Anchor the text to the start of the href so the beginning of the URL is
|
||||
// always visible; only the far (right) end gets trimmed when it overflows.
|
||||
text-align: left;
|
||||
margin: 0 $common-gap;
|
||||
|
||||
// No-JS default: without a scroll handler the line can only ever be cut at its
|
||||
// right edge, because nothing moves it. On short URLs the text ends before this
|
||||
// region, so the fade falls over empty space and stays invisible.
|
||||
@include current-diff-url-mask(linear-gradient(to right, #000 calc(100% - #{$current-diff-url-fade}), transparent));
|
||||
|
||||
// static/js/scrollable-title.js sets .js-fades once it is maintaining the
|
||||
// state, and from there the fade belongs on whichever edge is actually cut
|
||||
// off - a fixed right-hand fade would dim the ending the reader just dragged
|
||||
// over to read. No state class means nothing is cut off (a short title, or a
|
||||
// long one scrolled to neither end, which cannot happen on one axis), so no
|
||||
// mask at all. The mask tracks the element's own box, not its scrolled
|
||||
// content, so each fade stays pinned to the edge it names.
|
||||
&.js-fades {
|
||||
@include current-diff-url-mask(none);
|
||||
|
||||
&.fade-right {
|
||||
@include current-diff-url-mask(linear-gradient(to right, #000 calc(100% - #{$current-diff-url-fade}), transparent));
|
||||
}
|
||||
|
||||
&.fade-left {
|
||||
@include current-diff-url-mask(linear-gradient(to right, transparent, #000 #{$current-diff-url-fade}));
|
||||
}
|
||||
|
||||
&.fade-left.fade-right {
|
||||
@include current-diff-url-mask(linear-gradient(to right, transparent, #000 #{$current-diff-url-fade}, #000 calc(100% - #{$current-diff-url-fade}), transparent));
|
||||
}
|
||||
}
|
||||
// Fade the text toward the trimmed (right) edge. On short URLs the text ends
|
||||
// before this region, so the fade falls over empty space and stays invisible;
|
||||
// only an overflowing URL actually shows the fade-out.
|
||||
$fade-width: 2.5em;
|
||||
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - #{$fade-width}), transparent);
|
||||
mask-image: linear-gradient(to right, #000 calc(100% - #{$fade-width}), transparent);
|
||||
|
||||
span {
|
||||
overflow: visible;
|
||||
@@ -175,8 +128,6 @@ $current-diff-url-fade: 2.5em;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
@@ -193,11 +144,9 @@ $current-diff-url-fade: 2.5em;
|
||||
|
||||
// Paused = steady amber dot (no pulse).
|
||||
&.paused .live-dot {
|
||||
background: #e83d3d;
|
||||
box-shadow: 0 0 0 3px rgb(232 61 61 / 0.3);
|
||||
background: #e8a33d;
|
||||
box-shadow: 0 0 0 3px rgba(232, 163, 61, 0.3);
|
||||
animation: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
// Icon-led pills (e.g. the mute toggle) keep the feather icon compact.
|
||||
|
||||
@@ -27,12 +27,6 @@
|
||||
--color-background-gradient-first: #5ad8f7;
|
||||
--color-background-gradient-second: #2f50af;
|
||||
--color-background-gradient-third: #9150bf;
|
||||
// The page's atmosphere gradient, shared so anything that needs to sit ON the
|
||||
// page background (e.g. the left rail rolling out over content) can repaint
|
||||
// an identical copy instead of approximating it with a flat colour. The
|
||||
// referenced gradient stops are re-declared per theme below, so this single
|
||||
// definition follows light/dark automatically.
|
||||
--page-background-gradient: linear-gradient(130deg, var(--color-background-gradient-first), var(--color-background-gradient-second) 41.07%, var(--color-background-gradient-third) 84.05%);
|
||||
--color-background: var(--color-white);
|
||||
--color-text: var(--color-grey-200);
|
||||
--color-link: #1b98f8;
|
||||
@@ -76,7 +70,7 @@
|
||||
--color-background-messages-notice: rgba(255, 255, 255, .5);
|
||||
--color-border-notification: #ccc;
|
||||
|
||||
--color-background-checkbox-operations: rgba(255, 255, 255, 0.7);
|
||||
--color-background-checkbox-operations: rgba(0, 0, 0, 0.05);
|
||||
--color-warning: #ff3300;
|
||||
--color-border-warning: var(--color-warning);
|
||||
--color-text-legend: var(--color-white);
|
||||
@@ -124,6 +118,7 @@
|
||||
--color-sidebar-shadow: 6px 0 28px rgba(0, 0, 0, 0.18);
|
||||
--color-sidebar-item-hover-bg: rgba(0, 0, 0, 0.06);
|
||||
--color-sidebar-item-active-bg: rgba(0, 0, 0, 0.10);
|
||||
|
||||
--common-round-border: 8px;
|
||||
}
|
||||
|
||||
@@ -141,7 +136,7 @@ html[data-darkmode="true"] {
|
||||
--color-background-table-thead: var(--color-grey-200);
|
||||
--color-table-background: var(--color-grey-300);
|
||||
--color-table-stripe: var(--color-grey-325);
|
||||
--color-background-checkbox-operations: rgba(50, 50, 50, 0.7);
|
||||
|
||||
--watchlist-row-selected: #1e3a5f; // darker blue highlight for selected rows in dark mode
|
||||
--watchlist-row-hover: #2a2a2a; // subtle grey highlight on row hover (dark mode)
|
||||
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
body:has(#browser-steps:target) .edit-form {
|
||||
max-width: min(var(--browser-steps-max-width, 1280px), 95vw);
|
||||
width: 95%;
|
||||
}
|
||||
|
||||
body:has(#visualselector:target) .edit-form {
|
||||
width: 95%;
|
||||
max-width: min(var(--visualselector-max-width, 1280px), 95vw);
|
||||
}
|
||||
|
||||
#selector-wrapper {
|
||||
height: 100%;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user