Compare commits

...

36 Commits

Author SHA1 Message Date
dgtlmoon fc1963f31e Fix RSS watch titles
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-09-10 14:11:06 +02:00
dgtlmoon 78ec18bbad For now notificaitons stay the same 2025-09-10 14:03:42 +02:00
dgtlmoon 410dae3e33 Set watch_title correctly 2025-09-10 13:53:21 +02:00
dgtlmoon 6bc94ff52f add missing files, tweaks to fields 2025-09-10 12:46:01 +02:00
dgtlmoon 00f96ec860 more tweaks 2025-09-10 12:40:34 +02:00
dgtlmoon 85e9cf7b0b field tweaks 2025-09-10 12:19:41 +02:00
dgtlmoon 26420503af WIP 2025-09-10 12:14:28 +02:00
dgtlmoon 9737dd0942 tweak names
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-09-09 19:12:48 +02:00
dgtlmoon 15f14e668e group like operators 2025-09-09 19:09:05 +02:00
dgtlmoon 96c564b514 tweaks 2025-09-09 19:06:51 +02:00
dgtlmoon 88fec4df48 Adding ternary field 2025-09-09 18:56:40 +02:00
dgtlmoon 99a3a23e01 On by default 2025-09-09 18:20:01 +02:00
dgtlmoon 9be52cb601 Fix log message 2025-09-09 18:14:12 +02:00
dgtlmoon a973826e49 Lets use use_page_title_in_list to ON by default 2025-09-09 18:13:31 +02:00
dgtlmoon 256f7179eb Use watch placeholder title 2025-09-09 18:13:05 +02:00
dgtlmoon 359168f40a Improve logic 2025-09-09 18:04:43 +02:00
dgtlmoon a7c21c566c Always extract page <title> Re #3402 2025-09-08 18:04:02 +02:00
dependabot[bot] 7576bec66a Build - Bump actions/setup-python from 5 to 6 in the all group (#3408)
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64/v8 (main) (push) Has been cancelled
2025-09-08 12:12:04 +02:00
Nils Bergmann 7672190923 Restock - Add 'nicht mehr lieferbar' to stock status checks (#3410) 2025-09-08 12:11:37 +02:00
dgtlmoon 0ade4307b0 0.50.12
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-09-07 16:15:16 +02:00
dgtlmoon 8c03b65dc6 Fix - Filters in tags/groups were being added to watches on each check - #3406 fix list update (#3407) 2025-09-07 15:33:18 +02:00
Jeff Hedlund 8a07459e43 UI - Added "unread" view filter (#3393)
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-09-06 11:47:57 +02:00
Giuseppe Rota cd8e115118 Enable "last_viewed" field in the watch API. (#3403) 2025-09-06 11:47:16 +02:00
dgtlmoon 4ff7b20fcf Update docker-compose.yml - Include mac port info warning
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2025-08-29 13:15:59 +02:00
dgtlmoon 8120f00148 0.50.11
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64/v8 (main) (push) Has been cancelled
2025-08-28 22:11:36 +02:00
dependabot[bot] 127abf49f1 Bump cryptography from 43.0.1 to 44.0.1 (#3399) 2025-08-28 21:20:15 +02:00
dgtlmoon db81c3c5e2 Cryptography library - pinning version 2025-08-28 20:41:59 +02:00
dgtlmoon 9952af7a52 UI - Improving "real-time updates offline" message 2025-08-28 20:35:20 +02:00
dgtlmoon 790577c1b6 Build - Adding new cryptography library, solving apprise plugin issues (#3398) #3397 2025-08-28 20:29:21 +02:00
dgtlmoon bab362fb7d Update api-spec.yaml
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64/v8 (main) (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-08-28 14:38:19 +02:00
dgtlmoon a177d02406 API - API endpoint call validation against OpenAPI specification YML also (#3386) 2025-08-28 14:36:28 +02:00
dgtlmoon 8b8f280565 API Docs - Improve descriptions
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2025-08-25 13:28:04 +02:00
dgtlmoon e752875504 API Doc rebuild
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-08-24 16:47:07 +02:00
dgtlmoon 0a4562fc09 Bump API Docs slightly 2025-08-24 16:46:20 +02:00
dgtlmoon c84ac2eab1 Update settings.html text
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-08-24 00:55:44 +02:00
dgtlmoon 3ae07ac633 API - Use OpenAPI docs (#3384) 2025-08-24 00:48:17 +02:00
59 changed files with 2948 additions and 5730 deletions
+1 -1
View File
@@ -41,7 +41,7 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: Set up Python 3.11
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: 3.11
+2 -2
View File
@@ -9,7 +9,7 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Install pypa/build
@@ -39,7 +39,7 @@ jobs:
name: python-package-distributions
path: dist/
- name: Set up Python 3.11
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Test that the basic pip built package runs without error
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: Set up Python 3.11
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: 3.11
+4
View File
@@ -15,6 +15,10 @@ jobs:
ruff check . --select E9,F63,F7,F82
# Complete check with errors treated as warnings
ruff check . --exit-zero
- name: Validate OpenAPI spec
run: |
pip install openapi-spec-validator
python3 -c "from openapi_spec_validator import validate_spec; import yaml; validate_spec(yaml.safe_load(open('docs/api-spec.yaml')))"
test-application-3-10:
needs: lint-code
@@ -24,7 +24,7 @@ jobs:
# Mainly just for link/flake8
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
+9 -1
View File
@@ -5,7 +5,6 @@ ARG PYTHON_VERSION=3.11
FROM python:${PYTHON_VERSION}-slim-bookworm AS builder
# See `cryptography` pin comment in requirements.txt
ARG CRYPTOGRAPHY_DONT_BUILD_RUST=1
RUN apt-get update && apt-get install -y --no-install-recommends \
g++ \
@@ -17,6 +16,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libxslt-dev \
make \
patch \
pkg-config \
zlib1g-dev
RUN mkdir /install
@@ -26,6 +26,14 @@ COPY requirements.txt /requirements.txt
# Use cache mounts and multiple wheel sources for faster ARM builds
ENV PIP_CACHE_DIR=/tmp/pip-cache
# Help Rust find OpenSSL for cryptography package compilation on ARM
ENV PKG_CONFIG_PATH="/usr/lib/pkgconfig:/usr/lib/arm-linux-gnueabihf/pkgconfig:/usr/lib/aarch64-linux-gnu/pkgconfig"
ENV PKG_CONFIG_ALLOW_SYSTEM_CFLAGS=1
ENV OPENSSL_DIR="/usr"
ENV OPENSSL_LIB_DIR="/usr/lib/arm-linux-gnueabihf"
ENV OPENSSL_INCLUDE_DIR="/usr/include/openssl"
# Additional environment variables for cryptography Rust build
ENV CRYPTOGRAPHY_DONT_BUILD_RUST=1
RUN --mount=type=cache,target=/tmp/pip-cache \
pip install \
--extra-index-url https://www.piwheels.org/simple \
+2 -1
View File
@@ -1,7 +1,7 @@
recursive-include changedetectionio/api *
recursive-include changedetectionio/blueprint *
recursive-include changedetectionio/content_fetchers *
recursive-include changedetectionio/conditions *
recursive-include changedetectionio/content_fetchers *
recursive-include changedetectionio/model *
recursive-include changedetectionio/notification *
recursive-include changedetectionio/processors *
@@ -9,6 +9,7 @@ recursive-include changedetectionio/realtime *
recursive-include changedetectionio/static *
recursive-include changedetectionio/templates *
recursive-include changedetectionio/tests *
recursive-include changedetectionio/widgets *
prune changedetectionio/static/package-lock.json
prune changedetectionio/static/styles/node_modules
prune changedetectionio/static/styles/package-lock.json
+4 -1
View File
@@ -280,7 +280,10 @@ Excel import is recommended - that way you can better organise tags/groups of we
## API Support
Supports managing the website watch list [via our API](https://changedetection.io/docs/api_v1/index.html)
Full REST API for programmatic management of watches, tags, notifications and more.
- **[Interactive API Documentation](https://changedetection.io/docs/api_v1/index.html)** - Complete API reference with live testing
- **[OpenAPI Specification](docs/api-spec.yaml)** - Generate SDKs for any programming language
## Support us
+1 -1
View File
@@ -2,7 +2,7 @@
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
__version__ = '0.50.10'
__version__ = '0.50.12'
from changedetectionio.strtobool import strtobool
from json.decoder import JSONDecodeError
+3 -11
View File
@@ -3,7 +3,7 @@ from changedetectionio.strtobool import strtobool
from flask_restful import abort, Resource
from flask import request
import validators
from . import auth
from . import auth, validate_openapi_request
class Import(Resource):
@@ -12,17 +12,9 @@ class Import(Resource):
self.datastore = kwargs['datastore']
@auth.check_token
@validate_openapi_request('importWatches')
def post(self):
"""
@api {post} /api/v1/import Import a list of watched URLs
@apiDescription Accepts a line-feed separated list of URLs to import, additionally with ?tag_uuids=(tag id), ?tag=(name), ?proxy={key}, ?dedupe=true (default true) one URL per line.
@apiExample {curl} Example usage:
curl http://localhost:5000/api/v1/import --data-binary @list-of-sites.txt -H"x-api-key:8a111a21bc2f8f1dd9b9353bbd46049a"
@apiName Import
@apiGroup Import
@apiSuccess (200) {List} OK List of watch UUIDs added
@apiSuccess (500) {String} ERR Some other error
"""
"""Import a list of watched URLs."""
extras = {}
+10 -47
View File
@@ -1,9 +1,7 @@
from flask_expects_json import expects_json
from flask_restful import Resource
from . import auth
from flask_restful import abort, Resource
from flask_restful import Resource, abort
from flask import request
from . import auth
from . import auth, validate_openapi_request
from . import schema_create_notification_urls, schema_delete_notification_urls
class Notifications(Resource):
@@ -12,19 +10,9 @@ class Notifications(Resource):
self.datastore = kwargs['datastore']
@auth.check_token
@validate_openapi_request('getNotifications')
def get(self):
"""
@api {get} /api/v1/notifications Return Notification URL List
@apiDescription Return the Notification URL List from the configuration
@apiExample {curl} Example usage:
curl http://localhost:5000/api/v1/notifications -H"x-api-key:813031b16330fe25e3780cf0325daa45"
HTTP/1.0 200
{
'notification_urls': ["notification-urls-list"]
}
@apiName Get
@apiGroup Notifications
"""
"""Return Notification URL List."""
notification_urls = self.datastore.data.get('settings', {}).get('application', {}).get('notification_urls', [])
@@ -33,18 +21,10 @@ class Notifications(Resource):
}, 200
@auth.check_token
@validate_openapi_request('addNotifications')
@expects_json(schema_create_notification_urls)
def post(self):
"""
@api {post} /api/v1/notifications Create Notification URLs
@apiDescription Add one or more notification URLs from the configuration
@apiExample {curl} Example usage:
curl http://localhost:5000/api/v1/notifications/batch -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"notification_urls": ["url1", "url2"]}'
@apiName CreateBatch
@apiGroup Notifications
@apiSuccess (201) {Object[]} notification_urls List of added notification URLs
@apiError (400) {String} Invalid input
"""
"""Create Notification URLs."""
json_data = request.get_json()
notification_urls = json_data.get("notification_urls", [])
@@ -69,18 +49,10 @@ class Notifications(Resource):
return {'notification_urls': added_urls}, 201
@auth.check_token
@validate_openapi_request('replaceNotifications')
@expects_json(schema_create_notification_urls)
def put(self):
"""
@api {put} /api/v1/notifications Replace Notification URLs
@apiDescription Replace all notification URLs with the provided list (can be empty)
@apiExample {curl} Example usage:
curl -X PUT http://localhost:5000/api/v1/notifications -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"notification_urls": ["url1", "url2"]}'
@apiName Replace
@apiGroup Notifications
@apiSuccess (200) {Object[]} notification_urls List of current notification URLs
@apiError (400) {String} Invalid input
"""
"""Replace Notification URLs."""
json_data = request.get_json()
notification_urls = json_data.get("notification_urls", [])
@@ -100,19 +72,10 @@ class Notifications(Resource):
return {'notification_urls': clean_urls}, 200
@auth.check_token
@validate_openapi_request('deleteNotifications')
@expects_json(schema_delete_notification_urls)
def delete(self):
"""
@api {delete} /api/v1/notifications Delete Notification URLs
@apiDescription Deletes one or more notification URLs from the configuration
@apiExample {curl} Example usage:
curl http://localhost:5000/api/v1/notifications -X DELETE -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"notification_urls": ["url1", "url2"]}'
@apiParam {String[]} notification_urls The notification URLs to delete.
@apiName Delete
@apiGroup Notifications
@apiSuccess (204) {String} OK Deleted
@apiError (400) {String} No matching notification URLs found.
"""
"""Delete Notification URLs."""
json_data = request.get_json()
urls_to_delete = json_data.get("notification_urls", [])
+3 -15
View File
@@ -1,6 +1,6 @@
from flask_restful import Resource, abort
from flask import request
from . import auth
from . import auth, validate_openapi_request
class Search(Resource):
def __init__(self, **kwargs):
@@ -8,21 +8,9 @@ class Search(Resource):
self.datastore = kwargs['datastore']
@auth.check_token
@validate_openapi_request('searchWatches')
def get(self):
"""
@api {get} /api/v1/search Search for watches
@apiDescription Search watches by URL or title text
@apiExample {curl} Example usage:
curl "http://localhost:5000/api/v1/search?q=https://example.com/page1" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
curl "http://localhost:5000/api/v1/search?q=https://example.com/page1?tag=Favourites" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
curl "http://localhost:5000/api/v1/search?q=https://example.com?partial=true" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
@apiName Search
@apiGroup Search
@apiQuery {String} q Search query to match against watch URLs and titles
@apiQuery {String} [tag] Optional name of tag to limit results (name not UUID)
@apiQuery {String} [partial] Allow partial matching of URL query
@apiSuccess (200) {Object} JSON Object containing matched watches
"""
"""Search for watches by URL or title text."""
query = request.args.get('q', '').strip()
tag_limit = request.args.get('tag', '').strip()
from changedetectionio.strtobool import strtobool
+3 -17
View File
@@ -1,5 +1,5 @@
from flask_restful import Resource
from . import auth
from . import auth, validate_openapi_request
class SystemInfo(Resource):
@@ -9,23 +9,9 @@ class SystemInfo(Resource):
self.update_q = kwargs['update_q']
@auth.check_token
@validate_openapi_request('getSystemInfo')
def get(self):
"""
@api {get} /api/v1/systeminfo Return system info
@apiDescription Return some info about the current system state
@apiExample {curl} Example usage:
curl http://localhost:5000/api/v1/systeminfo -H"x-api-key:813031b16330fe25e3780cf0325daa45"
HTTP/1.0 200
{
'queue_size': 10 ,
'overdue_watches': ["watch-uuid-list"],
'uptime': 38344.55,
'watch_count': 800,
'version': "0.40.1"
}
@apiName Get Info
@apiGroup System Information
"""
"""Return system info."""
import time
overdue_watches = []
+11 -71
View File
@@ -7,7 +7,7 @@ from flask import request
from . import auth
# Import schemas from __init__.py
from . import schema_tag, schema_create_tag, schema_update_tag
from . import schema_tag, schema_create_tag, schema_update_tag, validate_openapi_request
class Tag(Resource):
@@ -19,22 +19,9 @@ class Tag(Resource):
# Get information about a single tag
# curl http://localhost:5000/api/v1/tag/<string:uuid>
@auth.check_token
@validate_openapi_request('getTag')
def get(self, uuid):
"""
@api {get} /api/v1/tag/:uuid Single tag / group - Get data, toggle notification muting, recheck all.
@apiDescription Retrieve tag information, set notification_muted status, recheck all in tag.
@apiExampleRequest
curl http://localhost:5000/api/v1/tag/cc0cfffa-f449-477b-83ea-0caafd1dc091 -H"x-api-key:813031b16330fe25e3780cf0325daa45"
curl "http://localhost:5000/api/v1/tag/cc0cfffa-f449-477b-83ea-0caafd1dc091?muted=muted" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
curl "http://localhost:5000/api/v1/tag/cc0cfffa-f449-477b-83ea-0caafd1dc091?recheck=true" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
@apiName Tag
@apiGroup Group / Tag
@apiParam {uuid} uuid Tag unique ID.
@apiQuery {String} [muted] =`muted` or =`unmuted` , Sets the MUTE NOTIFICATIONS state
@apiQuery {String} [recheck] = True, Queue all watches with this tag for recheck
@apiSuccess (200) {String} OK When muted operation OR full JSON object of the tag
@apiSuccess (200) {JSON} TagJSON JSON Full JSON object of the tag
"""
"""Get data for a single tag/group, toggle notification muting, or recheck all."""
from copy import deepcopy
tag = deepcopy(self.datastore.data['settings']['application']['tags'].get(uuid))
if not tag:
@@ -64,18 +51,9 @@ class Tag(Resource):
return tag
@auth.check_token
@validate_openapi_request('deleteTag')
def delete(self, uuid):
"""
@api {delete} /api/v1/tag/:uuid Delete a tag / group and remove it from all watches
@apiExampleRequest {curl} Example usage:
curl http://localhost:5000/api/v1/tag/cc0cfffa-f449-477b-83ea-0caafd1dc091 -X DELETE -H"x-api-key:813031b16330fe25e3780cf0325daa45"
@apiExampleResponse {string}
OK
@apiParam {uuid} uuid Tag unique ID.
@apiName DeleteTag
@apiGroup Group / Tag
@apiSuccess (200) {String} OK Was deleted
"""
"""Delete a tag/group and remove it from all watches."""
if not self.datastore.data['settings']['application']['tags'].get(uuid):
abort(400, message='No tag exists with the UUID of {}'.format(uuid))
@@ -90,21 +68,10 @@ class Tag(Resource):
return 'OK', 204
@auth.check_token
@validate_openapi_request('updateTag')
@expects_json(schema_update_tag)
def put(self, uuid):
"""
@api {put} /api/v1/tag/:uuid Update tag information
@apiExampleRequest {curl} Request:
curl http://localhost:5000/api/v1/tag/cc0cfffa-f449-477b-83ea-0caafd1dc091 -X PUT -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"title": "New Tag Title"}'
@apiExampleResponse {json} Response:
"OK"
@apiDescription Updates an existing tag using JSON
@apiParam {uuid} uuid Tag unique ID.
@apiName UpdateTag
@apiGroup Group / Tag
@apiSuccess (200) {String} OK Was updated
@apiSuccess (500) {String} ERR Some other error
"""
"""Update tag information."""
tag = self.datastore.data['settings']['application']['tags'].get(uuid)
if not tag:
abort(404, message='No tag exists with the UUID of {}'.format(uuid))
@@ -116,17 +83,10 @@ class Tag(Resource):
@auth.check_token
@validate_openapi_request('createTag')
# Only cares for {'title': 'xxxx'}
def post(self):
"""
@api {post} /api/v1/watch Create a single tag / group
@apiExample {curl} Example usage:
curl http://localhost:5000/api/v1/watch -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"name": "Work related"}'
@apiName Create
@apiGroup Group / Tag
@apiSuccess (200) {String} OK Was created
@apiSuccess (500) {String} ERR Some other error
"""
"""Create a single tag/group."""
json_data = request.get_json()
title = json_data.get("title",'').strip()
@@ -144,29 +104,9 @@ class Tags(Resource):
self.datastore = kwargs['datastore']
@auth.check_token
@validate_openapi_request('listTags')
def get(self):
"""
@api {get} /api/v1/tags List tags / groups
@apiDescription Return list of available tags / groups
@apiExampleRequest {curl} Request:
curl http://localhost:5000/api/v1/tags -H"x-api-key:813031b16330fe25e3780cf0325daa45"
@apiExampleResponse {json} Response:
{
"cc0cfffa-f449-477b-83ea-0caafd1dc091": {
"title": "Tech News",
"notification_muted": false,
"date_created": 1677103794
},
"e6f5fd5c-dbfe-468b-b8f3-f9d6ff5ad69b": {
"title": "Shopping",
"notification_muted": true,
"date_created": 1676662819
}
}
@apiName ListTags
@apiGroup Group / Tag Management
@apiSuccess (200) {JSON} Short list of tags keyed by tag/group UUID
"""
"""List tags/groups."""
result = {}
for uuid, tag in self.datastore.data['settings']['application']['tags'].items():
result[uuid] = {
+17 -124
View File
@@ -11,7 +11,7 @@ from . import auth
import copy
# Import schemas from __init__.py
from . import schema, schema_create_watch, schema_update_watch
from . import schema, schema_create_watch, schema_update_watch, validate_openapi_request
class Watch(Resource):
@@ -25,24 +25,9 @@ class Watch(Resource):
# @todo - version2 - ?muted and ?paused should be able to be called together, return the watch struct not "OK"
# ?recheck=true
@auth.check_token
@validate_openapi_request('getWatch')
def get(self, uuid):
"""
@api {get} /api/v1/watch/:uuid Single watch - get data, recheck, pause, mute.
@apiDescription Retrieve watch information and set muted/paused status, returns the FULL Watch JSON which can be used for any other PUT (update etc)
@apiExample {curl} Example usage:
curl http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091 -H"x-api-key:813031b16330fe25e3780cf0325daa45"
curl "http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091?muted=unmuted" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
curl "http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091?paused=unpaused" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
@apiName Watch
@apiGroup Watch
@apiGroupDocOrder 0
@apiParam {uuid} uuid Watch unique ID.
@apiQuery {Boolean} [recheck] Recheck this watch `recheck=1`
@apiQuery {String} [paused] =`paused` or =`unpaused` , Sets the PAUSED state
@apiQuery {String} [muted] =`muted` or =`unmuted` , Sets the MUTE NOTIFICATIONS state
@apiSuccess (200) {String} OK When paused/muted/recheck operation OR full JSON object of the watch
@apiSuccess (200) {JSON} WatchJSON JSON Full JSON object of the watch
"""
"""Get information about a single watch, recheck, pause, or mute."""
from copy import deepcopy
watch = deepcopy(self.datastore.data['watching'].get(uuid))
if not watch:
@@ -73,16 +58,9 @@ class Watch(Resource):
return watch
@auth.check_token
@validate_openapi_request('deleteWatch')
def delete(self, uuid):
"""
@api {delete} /api/v1/watch/:uuid Delete a watch and related history
@apiExample {curl} Example usage:
curl http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091 -X DELETE -H"x-api-key:813031b16330fe25e3780cf0325daa45"
@apiParam {uuid} uuid Watch unique ID.
@apiName Delete
@apiGroup Watch
@apiSuccess (200) {String} OK Was deleted
"""
"""Delete a watch and related history."""
if not self.datastore.data['watching'].get(uuid):
abort(400, message='No watch exists with the UUID of {}'.format(uuid))
@@ -90,21 +68,10 @@ class Watch(Resource):
return 'OK', 204
@auth.check_token
@validate_openapi_request('updateWatch')
@expects_json(schema_update_watch)
def put(self, uuid):
"""
@api {put} /api/v1/watch/:uuid Update watch information
@apiExampleRequest {curl} Example usage:
curl http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091 -X PUT -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"url": "https://my-nice.com" , "tag": "new list"}'
@apiExampleResponse {string} Example usage:
OK
@apiDescription Updates an existing watch using JSON, accepts the same structure as returned in <a href="#watch_GET">get single watch information</a>
@apiParam {uuid} uuid Watch unique ID.
@apiName Update a watch
@apiGroup Watch
@apiSuccess (200) {String} OK Was updated
@apiSuccess (500) {String} ERR Some other error
"""
"""Update watch information."""
watch = self.datastore.data['watching'].get(uuid)
if not watch:
abort(404, message='No watch exists with the UUID of {}'.format(uuid))
@@ -127,24 +94,9 @@ class WatchHistory(Resource):
# Get a list of available history for a watch by UUID
# curl http://localhost:5000/api/v1/watch/<string:uuid>/history
@auth.check_token
@validate_openapi_request('getWatchHistory')
def get(self, uuid):
"""
@api {get} /api/v1/watch/<string:uuid>/history Get a list of all historical snapshots available for a watch
@apiDescription Requires `uuid`, returns list
@apiGroupDocOrder 1
@apiExampleRequest {curl} Request:
curl http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/history -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json"
@apiExampleResponse {json} Response:
{
"1676649279": "/tmp/data/6a4b7d5c-fee4-4616-9f43-4ac97046b595/cb7e9be8258368262246910e6a2a4c30.txt",
"1677092785": "/tmp/data/6a4b7d5c-fee4-4616-9f43-4ac97046b595/e20db368d6fc633e34f559ff67bb4044.txt",
"1677103794": "/tmp/data/6a4b7d5c-fee4-4616-9f43-4ac97046b595/02efdd37dacdae96554a8cc85dc9c945.txt"
}
@apiName Get list of available stored snapshots for watch
@apiGroup Watch History
@apiSuccess (200) {JSON} List of keyed (by change date) paths to snapshot, use the key to <a href="#snapshots_GET">fetch a single snapshot</a>.
@apiSuccess (404) {String} ERR Not found
"""
"""Get a list of all historical snapshots available for a watch."""
watch = self.datastore.data['watching'].get(uuid)
if not watch:
abort(404, message='No watch exists with the UUID of {}'.format(uuid))
@@ -157,21 +109,9 @@ class WatchSingleHistory(Resource):
self.datastore = kwargs['datastore']
@auth.check_token
@validate_openapi_request('getWatchSnapshot')
def get(self, uuid, timestamp):
"""
@api {get} /api/v1/watch/<string:uuid>/history/<int:timestamp> Get single snapshot from watch
@apiDescription Requires watch `uuid` and `timestamp`. `timestamp` of "`latest`" for latest available snapshot, or <a href="#watch_history_GET">use the list returned here</a>
@apiExampleRequest {curl} Example usage:
curl http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/history/1677092977 -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json"
@apiExampleResponse {string} Closes matching snapshot text
Big bad fox flew over the moon at 2025-01-01 etc etc
@apiName Get single snapshot content
@apiGroup Snapshots
@apiGroupDocOrder 2
@apiParam {String} [html] Optional Set to =1 to return the last HTML (only stores last 2 snapshots, use `latest` as timestamp)
@apiSuccess (200) {String} OK
@apiSuccess (404) {String} ERR Not found
"""
"""Get single snapshot from watch."""
watch = self.datastore.data['watching'].get(uuid)
if not watch:
abort(404, message=f"No watch exists with the UUID of {uuid}")
@@ -203,20 +143,9 @@ class WatchFavicon(Resource):
self.datastore = kwargs['datastore']
@auth.check_token
@validate_openapi_request('getWatchFavicon')
def get(self, uuid):
"""
@api {get} /api/v1/watch/<string:uuid>/favicon Get favicon for a watch.
@apiDescription Requires watch `uuid`, ,The favicon is the favicon which is available in the page watch overview list.
@apiExampleRequest {curl} Example usage:
curl http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/favicon -H"x-api-key:813031b16330fe25e3780cf0325daa45"
@apiExampleResponse {binary data}
JPEG...
@apiName Get latest Favicon
@apiGroup Favicon
@apiGroupDocOrder 3
@apiSuccess (200) {binary} Data ( Binary data of the favicon )
@apiSuccess (404) {String} ERR Not found
"""
"""Get favicon for a watch."""
watch = self.datastore.data['watching'].get(uuid)
if not watch:
abort(404, message=f"No watch exists with the UUID of {uuid}")
@@ -249,18 +178,10 @@ class CreateWatch(Resource):
self.update_q = kwargs['update_q']
@auth.check_token
@validate_openapi_request('createWatch')
@expects_json(schema_create_watch)
def post(self):
"""
@api {post} /api/v1/watch Create a single watch
@apiDescription Requires atleast `url` set, can accept the same structure as <a href="#watch_GET">get single watch information</a> to create.
@apiExample {curl} Example usage:
curl http://localhost:5000/api/v1/watch -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"url": "https://my-nice.com" , "tag": "nice list"}'
@apiName Create
@apiGroup Watch
@apiSuccess (200) {String} OK Was created
@apiSuccess (500) {String} ERR Some other error
"""
"""Create a single watch."""
json_data = request.get_json()
url = json_data['url'].strip()
@@ -293,37 +214,9 @@ class CreateWatch(Resource):
return "Invalid or unsupported URL", 400
@auth.check_token
@validate_openapi_request('listWatches')
def get(self):
"""
@api {get} /api/v1/watch List watches
@apiDescription Return concise list of available watches and some very basic info
@apiExampleRequest {curl} Request:
curl http://localhost:5000/api/v1/watch -H"x-api-key:813031b16330fe25e3780cf0325daa45"
@apiExampleResponse {json} Response:
{
"6a4b7d5c-fee4-4616-9f43-4ac97046b595": {
"last_changed": 1677103794,
"last_checked": 1677103794,
"last_error": false,
"title": "",
"url": "http://www.quotationspage.com/random.php"
},
"e6f5fd5c-dbfe-468b-b8f3-f9d6ff5ad69b": {
"last_changed": 0,
"last_checked": 1676662819,
"last_error": false,
"title": "QuickLook",
"url": "https://github.com/QL-Win/QuickLook/tags"
}
}
@apiParam {String} [recheck_all] Optional Set to =1 to force recheck of all watches
@apiParam {String} [tag] Optional name of tag to limit results
@apiName ListWatches
@apiGroup Watch Management
@apiGroupDocOrder 4
@apiSuccess (200) {String} OK JSON dict
"""
"""List watches."""
list = {}
tag_limit = request.args.get('tag', '').lower()
+38
View File
@@ -1,4 +1,9 @@
import copy
import yaml
import functools
from flask import request, abort
from openapi_core import OpenAPI
from openapi_core.contrib.flask import FlaskOpenAPIRequest
from . import api_schema
from ..model import watch_base
@@ -8,6 +13,7 @@ schema = api_schema.build_watch_json_schema(watch_base_config)
schema_create_watch = copy.deepcopy(schema)
schema_create_watch['required'] = ['url']
del schema_create_watch['properties']['last_viewed']
schema_update_watch = copy.deepcopy(schema)
schema_update_watch['additionalProperties'] = False
@@ -25,6 +31,38 @@ schema_create_notification_urls['required'] = ['notification_urls']
schema_delete_notification_urls = copy.deepcopy(schema_notification_urls)
schema_delete_notification_urls['required'] = ['notification_urls']
# Load OpenAPI spec for validation
_openapi_spec = None
def get_openapi_spec():
global _openapi_spec
if _openapi_spec is None:
import os
spec_path = os.path.join(os.path.dirname(__file__), '../../docs/api-spec.yaml')
with open(spec_path, 'r') as f:
spec_dict = yaml.safe_load(f)
_openapi_spec = OpenAPI.from_dict(spec_dict)
return _openapi_spec
def validate_openapi_request(operation_id):
"""Decorator to validate incoming requests against OpenAPI spec."""
def decorator(f):
@functools.wraps(f)
def wrapper(*args, **kwargs):
try:
spec = get_openapi_spec()
openapi_request = FlaskOpenAPIRequest(request)
result = spec.unmarshal_request(openapi_request)
if result.errors:
abort(400, message=f"OpenAPI validation failed: {result.errors}")
return f(*args, **kwargs)
except Exception as e:
# If OpenAPI validation fails, log but don't break existing functionality
print(f"OpenAPI validation warning for {operation_id}: {e}")
return f(*args, **kwargs)
return wrapper
return decorator
# Import all API resources
from .Watch import Watch, WatchHistory, WatchSingleHistory, CreateWatch, WatchFavicon
from .Tags import Tags, Tag
+7
View File
@@ -78,6 +78,13 @@ def build_watch_json_schema(d):
]:
schema['properties'][v]['anyOf'].append({'type': 'string', "maxLength": 5000})
for v in ['last_viewed']:
schema['properties'][v] = {
"type": "integer",
"description": "Unix timestamp in seconds of the last time the watch was viewed.",
"minimum": 0
}
# None or Boolean
schema['properties']['track_ldjson_price_data']['anyOf'].append({'type': 'boolean'})
+8 -9
View File
@@ -310,15 +310,6 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore):
continue
if process_changedetection_results:
# Extract title if needed
if datastore.data['settings']['application'].get('extract_title_as_title') or watch['extract_title_as_title']:
if not watch['title'] or not len(watch['title']):
try:
update_obj['title'] = html_tools.extract_element(find='title', html_content=update_handler.fetcher.content)
logger.info(f"UUID: {uuid} Extract <title> updated title to '{update_obj['title']}")
except Exception as e:
logger.warning(f"UUID: {uuid} Extract <title> as watch title was enabled, but couldn't find a <title>.")
try:
datastore.update_watch(uuid=uuid, update_obj=update_obj)
@@ -357,6 +348,14 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore):
# Always record attempt count
count = watch.get('check_count', 0) + 1
# Always record page title (used in notifications, and can change even when the content is the same)
try:
page_title = html_tools.extract_title(data=update_handler.fetcher.content)
logger.debug(f"UUID: {uuid} Page <title> is '{page_title}'")
datastore.update_watch(uuid=uuid, update_obj={'page_title': page_title})
except Exception as e:
logger.warning(f"UUID: {uuid} Exception when extracting <title> - {str(e)}")
# Record server header
try:
server_header = update_handler.fetcher.headers.get('server', '').strip().lower()[:255]
+7 -4
View File
@@ -108,10 +108,13 @@ def construct_blueprint(datastore: ChangeDetectionStore):
fe.link(link=diff_link)
# @todo watch should be a getter - watch.get('title') (internally if URL else..)
# Same logic as watch-overview.html
if datastore.data['settings']['application']['ui'].get('use_page_title_in_list') or watch.get('use_page_title_in_list'):
watch_label = watch.label
else:
watch_label = watch.get('url')
watch_title = watch.get('title') if watch.get('title') else watch.get('url')
fe.title(title=watch_title)
fe.title(title=watch_label)
try:
html_diff = diff.render_diff(previous_version_file_contents=watch.get_history_snapshot(dates[-2]),
@@ -127,7 +130,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
# @todo User could decide if <link> goes to the diff page, or to the watch link
rss_template = "<html><body>\n<h4><a href=\"{{watch_url}}\">{{watch_title}}</a></h4>\n<p>{{html_diff}}</p>\n</body></html>\n"
content = jinja_render(template_str=rss_template, watch_title=watch_title, html_diff=html_diff, watch_url=watch.link)
content = jinja_render(template_str=rss_template, watch_title=watch_label, html_diff=html_diff, watch_url=watch.link)
# Out of range chars could also break feedgen
if scan_invalid_chars_in_rss(content):
@@ -1,7 +1,7 @@
{% extends 'base.html' %}
{% block content %}
{% from '_helpers.html' import render_field, render_checkbox_field, render_button, render_time_schedule_form %}
{% from '_helpers.html' import render_field, render_checkbox_field, render_button, render_time_schedule_form, render_ternary_field %}
{% from '_common_fields.html' import render_common_settings_form %}
<script>
const notification_base_url="{{url_for('ui.ui_notification.ajax_callback_send_notification_test', mode="global-settings")}}";
@@ -75,18 +75,10 @@
<div class="pure-control-group">
{{ render_checkbox_field(form.application.form.rss_hide_muted_watches) }}
</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>
</div>
<div class="pure-control-group">
{{ render_field(form.application.form.rss_content_format) }}
<span class="pure-form-message-inline">Love RSS? Does your reader support HTML? Set it here</span>
</div>
<div class="pure-control-group">
{{ render_checkbox_field(form.application.form.extract_title_as_title) }}
<span class="pure-form-message-inline">Note: This will automatically apply to all existing watches.</span>
</div>
<div class="pure-control-group">
{{ render_checkbox_field(form.application.form.empty_pages_are_a_change) }}
<span class="pure-form-message-inline">When a request returns no content, or the HTML does not contain any text, is this considered a change?</span>
@@ -203,7 +195,7 @@ nav
<div class="tab-pane-inner" id="api">
<h4>API Access</h4>
<p>Drive your changedetection.io via API, More about <a href="https://github.com/dgtlmoon/changedetection.io/wiki/API-Reference">API access here</a></p>
<p>Drive your changedetection.io via API, More about <a href="https://changedetection.io/docs/api_v1/index.html">API access and examples here</a>.</p>
<div class="pure-control-group">
{{ render_checkbox_field(form.application.form.api_access_token_enabled) }}
@@ -260,6 +252,13 @@ nav
{{ 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>
</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_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>
</div>
</div>
<div class="tab-pane-inner" id="proxies">
@@ -324,8 +323,8 @@ nav
<div id="actions">
<div class="pure-control-group">
{{ render_button(form.save_button) }}
<a href="{{url_for('watchlist.index')}}" class="pure-button button-small button-cancel">Back</a>
<a href="{{url_for('ui.clear_all_history')}}" class="pure-button button-small button-error">Clear Snapshot History</a>
<a href="{{url_for('watchlist.index')}}" class="pure-button button-cancel">Back</a>
<a href="{{url_for('ui.clear_all_history')}}" class="pure-button button-error">Clear Snapshot History</a>
</div>
</div>
</form>
@@ -1,6 +1,6 @@
{% extends 'base.html' %}
{% block content %}
{% from '_helpers.html' import render_field, render_checkbox_field, render_button %}
{% from '_helpers.html' import render_field, render_checkbox_field, render_button, render_ternary_field %}
{% from '_common_fields.html' import render_common_settings_form %}
<script>
const notification_base_url="{{url_for('ui.ui_notification.ajax_callback_send_notification_test', mode="group-settings")}}";
@@ -64,7 +64,7 @@
<div class="tab-pane-inner" id="notifications">
<fieldset>
<div class="pure-control-group inline-radio">
{{ render_checkbox_field(form.notification_muted) }}
{{ render_ternary_field(form.notification_muted, BooleanField=True) }}
</div>
{% if 1 %}
<div class="pure-control-group inline-radio">
@@ -1,6 +1,6 @@
{% extends 'base.html' %}
{% block content %}
{% from '_helpers.html' import render_field, render_checkbox_field, render_button, render_time_schedule_form, playwright_warning, only_playwright_type_watches_warning, render_conditions_fieldlist_of_formfields_as_table %}
{% from '_helpers.html' import render_field, render_checkbox_field, render_button, render_time_schedule_form, playwright_warning, only_playwright_type_watches_warning, render_conditions_fieldlist_of_formfields_as_table, render_ternary_field %}
{% from '_common_fields.html' import render_common_settings_form %}
<script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script>
<script src="{{url_for('static_content', group='js', filename='vis.js')}}" defer></script>
@@ -72,15 +72,16 @@
<div class="pure-form-message">Some sites use JavaScript to create the content, for this you should <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Fetching-pages-with-WebDriver">use the Chrome/WebDriver Fetcher</a></div>
<div class="pure-form-message">Variables are supported in the URL (<a href="https://github.com/dgtlmoon/changedetection.io/wiki/Handling-variables-in-the-watched-URL">help and examples here</a>).</div>
</div>
<div class="pure-control-group">
{{ render_field(form.tags) }}
<span class="pure-form-message-inline">Organisational tag/group name used in the main listing page</span>
</div>
<div class="pure-control-group inline-radio">
{{ render_field(form.processor) }}
</div>
<div class="pure-control-group">
{{ render_field(form.title, class="m-d") }}
</div>
<div class="pure-control-group">
{{ render_field(form.tags) }}
<span class="pure-form-message-inline">Organisational tag/group name used in the main listing page</span>
{{ 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 time-between-check border-fieldset">
@@ -101,15 +102,16 @@
</div>
<br>
</div>
<div class="pure-control-group">
{{ render_checkbox_field(form.extract_title_as_title) }}
</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_ternary_field(form.use_page_title_in_list) }}
</div>
</fieldset>
</div>
@@ -262,7 +264,7 @@ Math: {{ 1 + 1 }}") }}
<div class="tab-pane-inner" id="notifications">
<fieldset>
<div class="pure-control-group inline-radio">
{{ render_checkbox_field(form.notification_muted) }}
{{ render_ternary_field(form.notification_muted, BooleanField=true) }}
</div>
{% if watch_needs_selenium_or_playwright %}
<div class="pure-control-group inline-radio">
@@ -469,11 +471,11 @@ Math: {{ 1 + 1 }}") }}
<div class="pure-control-group">
{{ render_button(form.save_button) }}
<a href="{{url_for('ui.form_delete', uuid=uuid)}}"
class="pure-button button-small button-error ">Delete</a>
class="pure-button button-error ">Delete</a>
{% if watch.history_n %}<a href="{{url_for('ui.clear_watch_history', uuid=uuid)}}"
class="pure-button button-small button-error ">Clear History</a>{% endif %}
class="pure-button button-error">Clear History</a>{% endif %}
<a href="{{url_for('ui.form_clone', uuid=uuid)}}"
class="pure-button button-small ">Clone &amp; Edit</a>
class="pure-button">Clone &amp; Edit</a>
</div>
</div>
</form>
@@ -44,12 +44,16 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
# Sort by last_changed and add the uuid which is usually the key..
sorted_watches = []
with_errors = request.args.get('with_errors') == "1"
unread_only = request.args.get('unread') == "1"
errored_count = 0
search_q = request.args.get('q').strip().lower() if request.args.get('q') else False
for uuid, watch in datastore.data['watching'].items():
if with_errors and not watch.get('last_error'):
continue
if unread_only and (watch.viewed or watch.last_changed == 0) :
continue
if active_tag_uuid and not active_tag_uuid in watch['tags']:
continue
if watch.get('last_error'):
@@ -118,7 +118,8 @@ document.addEventListener('DOMContentLoaded', function() {
{%- set checking_now = is_checking_now(watch) -%}
{%- set history_n = watch.history_n -%}
{%- set favicon = watch.get_favicon_filename() -%}
{# Mirror in changedetectionio/static/js/realtime.js for the frontend #}
{%- set system_use_url_watchlist = datastore.data['settings']['application']['ui'].get('use_page_title_in_list') -%}
{# Class settings mirrored in changedetectionio/static/js/realtime.js for the frontend #}
{%- set row_classes = [
loop.cycle('pure-table-odd', 'pure-table-even'),
'processor-' ~ watch['processor'],
@@ -133,7 +134,8 @@ document.addEventListener('DOMContentLoaded', function() {
'checking-now' if checking_now else '',
'notification_muted' if watch.notification_muted else '',
'single-history' if history_n == 1 else '',
'multiple-history' if history_n >= 2 else '',
'multiple-history' if history_n >= 2 else '',
'use-html-title' if system_use_url_watchlist else 'no-html-title',
] -%}
<tr id="{{ watch.uuid }}" data-watch-uuid="{{ watch.uuid }}" class="{{ row_classes | reject('equalto', '') | join(' ') }}">
<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>
@@ -155,7 +157,12 @@ document.addEventListener('DOMContentLoaded', function() {
{% endif %}
<div>
<span class="watch-title">
{{watch.title if watch.title is not none and watch.title|length > 0 else watch.url}}&nbsp;<a class="external" target="_blank" rel="noopener" href="{{ watch.link.replace('source:','') }}">&nbsp;</a>
{% if system_use_url_watchlist or watch.get('use_page_title_in_list') %}
{{watch.label}}
{% else %}
{{watch.url}}
{% endif %}
<a class="external" target="_blank" rel="noopener" href="{{ watch.link.replace('source:','') }}">&nbsp;</a>
</span>
<div class="error-text" style="display:none;">{{ watch.compile_error_texts(has_proxies=datastore.proxy_list) }}</div>
{%- if watch['processor'] == 'text_json_diff' -%}
@@ -245,6 +252,9 @@ document.addEventListener('DOMContentLoaded', function() {
<a href="{{url_for('ui.mark_all_viewed', tag=active_tag_uuid) }}" class="pure-button button-tag " id="mark-all-viewed">Mark all viewed in '{{active_tag.title}}'</a>
</li>
{%- endif -%}
<li id="post-list-unread" class="{%- if has_unviewed -%}has-unviewed{%- endif -%}" style="display: none;" >
<a href="{{url_for('watchlist.index', unread=1, tag=request.args.get('tag')) }}" class="pure-button button-tag">Unread</a>
</li>
<li>
<a href="{{ url_for('ui.form_watch_checknow', tag=active_tag_uuid, with_errors=request.args.get('with_errors',0)) }}" class="pure-button button-tag" id="recheck-all">Recheck
all {% if active_tag_uuid %} in '{{active_tag.title}}'{%endif%}</a>
@@ -47,6 +47,7 @@ async () => {
'nicht lieferbar',
'nicht verfügbar',
'nicht vorrätig',
'nicht mehr lieferbar',
'nicht zur verfügung',
'nie znaleziono produktów',
'niet beschikbaar',
+9 -7
View File
@@ -28,6 +28,8 @@ from wtforms.validators import ValidationError
from validators.url import url as url_validator
from changedetectionio.widgets import TernaryNoneBooleanField
# default
# each select <option data-enabled="enabled-0-0"
@@ -548,7 +550,6 @@ class commonSettingsForm(Form):
self.notification_title.extra_notification_tokens = kwargs.get('extra_notification_tokens', {})
self.notification_urls.extra_notification_tokens = kwargs.get('extra_notification_tokens', {})
extract_title_as_title = BooleanField('Extract <title> from document and use as watch title', default=False)
fetch_backend = RadioField(u'Fetch Method', choices=content_fetchers.available_fetchers(), validators=[ValidateContentFetcherIsReady()])
notification_body = TextAreaField('Notification Body', default='{{ watch_url }} had a change.', validators=[validators.Optional(), ValidateJinja2Template()])
notification_format = SelectField('Notification format', choices=valid_notification_formats.keys())
@@ -616,18 +617,18 @@ class processor_text_json_diff_form(commonSettingsForm):
text_should_not_be_present = StringListField('Block change-detection while text matches', [validators.Optional(), ValidateListRegex()])
webdriver_js_execute_code = TextAreaField('Execute JavaScript before change detection', render_kw={"rows": "5"}, validators=[validators.Optional()])
save_button = SubmitField('Save', render_kw={"class": "pure-button button-small pure-button-primary"})
save_button = SubmitField('Save', render_kw={"class": "pure-button pure-button-primary"})
proxy = RadioField('Proxy')
# filter_failure_notification_send @todo make ternary
filter_failure_notification_send = BooleanField(
'Send a notification when the filter can no longer be found on the page', default=False)
notification_muted = BooleanField('Notifications Muted / Off', default=False)
notification_muted = TernaryNoneBooleanField('Notifications', default=None, yes_text="Muted", no_text="On")
notification_screenshot = BooleanField('Attach screenshot to notification (where possible)', default=False)
conditions_match_logic = RadioField(u'Match', choices=[('ALL', 'Match all of the following'),('ANY', 'Match any of the following')], default='ALL')
conditions = FieldList(FormField(ConditionFormRow), min_entries=1) # Add rule logic here
use_page_title_in_list = TernaryNoneBooleanField('Use page <title> in list', default=None)
def extra_tab_content(self):
return None
@@ -755,6 +756,7 @@ class globalSettingsApplicationUIForm(Form):
open_diff_in_new_tab = BooleanField("Open 'History' page in a new tab", default=True, validators=[validators.Optional()])
socket_io_enabled = BooleanField('Realtime UI Updates Enabled', default=True, validators=[validators.Optional()])
favicons_enabled = BooleanField('Favicons Enabled', default=True, validators=[validators.Optional()])
use_page_title_in_list = BooleanField('Use page <title> in watch overview list') #BooleanField=True
# datastore.data['settings']['application']..
class globalSettingsApplicationForm(commonSettingsForm):
@@ -779,7 +781,7 @@ class globalSettingsApplicationForm(commonSettingsForm):
removepassword_button = SubmitField('Remove password', render_kw={"class": "pure-button pure-button-primary"})
render_anchor_tag_content = BooleanField('Render anchor tag content', default=False)
shared_diff_access = BooleanField('Allow access to view diff page when password is enabled', default=False, validators=[validators.Optional()])
shared_diff_access = BooleanField('Allow anonymous access to watch history page when password is enabled', default=False, validators=[validators.Optional()])
rss_hide_muted_watches = BooleanField('Hide muted watches from RSS feed', default=True,
validators=[validators.Optional()])
filter_failure_notification_threshold_attempts = IntegerField('Number of times the filter can be missing before sending a notification',
@@ -801,7 +803,7 @@ class globalSettingsForm(Form):
requests = FormField(globalSettingsRequestForm)
application = FormField(globalSettingsApplicationForm)
save_button = SubmitField('Save', render_kw={"class": "pure-button button-small pure-button-primary"})
save_button = SubmitField('Save', render_kw={"class": "pure-button pure-button-primary"})
class extractDataForm(Form):
+46
View File
@@ -1,6 +1,7 @@
from loguru import logger
from lxml import etree
from typing import List
import html
import json
import re
@@ -9,6 +10,11 @@ TEXT_FILTER_LIST_LINE_SUFFIX = "<br>"
TRANSLATE_WHITESPACE_TABLE = str.maketrans('', '', '\r\n\t ')
PERL_STYLE_REGEX = r'^/(.*?)/([a-z]*)?$'
TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.I | re.S)
META_CS = re.compile(r'<meta[^>]+charset=["\']?\s*([a-z0-9_\-:+.]+)', re.I)
META_CT = re.compile(r'<meta[^>]+http-equiv=["\']?content-type["\']?[^>]*content=["\'][^>]*charset=([a-z0-9_\-:+.]+)', re.I)
# 'price' , 'lowPrice', 'highPrice' are usually under here
# All of those may or may not appear on different websites - I didnt find a way todo case-insensitive searching here
LD_JSON_PRODUCT_OFFER_SELECTORS = ["json:$..offers", "json:$..Offers"]
@@ -510,3 +516,43 @@ def get_triggered_text(content, trigger_text):
i += 1
return triggered_text
def extract_title(data: bytes | str, sniff_bytes: int = 2048, scan_chars: int = 8192) -> str | None:
try:
# Only decode/process the prefix we need for title extraction
match data:
case bytes() if data.startswith((b"\xff\xfe", b"\xfe\xff")):
prefix = data[:scan_chars * 2].decode("utf-16", errors="replace")
case bytes() if data.startswith((b"\xff\xfe\x00\x00", b"\x00\x00\xfe\xff")):
prefix = data[:scan_chars * 4].decode("utf-32", errors="replace")
case bytes():
try:
prefix = data[:scan_chars].decode("utf-8")
except UnicodeDecodeError:
try:
head = data[:sniff_bytes].decode("ascii", errors="ignore")
if m := (META_CS.search(head) or META_CT.search(head)):
enc = m.group(1).lower()
else:
enc = "cp1252"
prefix = data[:scan_chars * 2].decode(enc, errors="replace")
except Exception as e:
logger.error(f"Title extraction encoding detection failed: {e}")
return None
case str():
prefix = data[:scan_chars] if len(data) > scan_chars else data
case _:
logger.error(f"Title extraction received unsupported data type: {type(data)}")
return None
# Search only in the prefix
if m := TITLE_RE.search(prefix):
title = html.unescape(" ".join(m.group(1).split())).strip()
# Some safe limit
return title[:2000]
return None
except Exception as e:
logger.error(f"Title extraction failed: {e}")
return None
+3 -2
View File
@@ -39,12 +39,12 @@ class model(dict):
'api_access_token_enabled': True,
'base_url' : None,
'empty_pages_are_a_change': False,
'extract_title_as_title': False,
'fetch_backend': getenv("DEFAULT_FETCH_BACKEND", "html_requests"),
'filter_failure_notification_threshold_attempts': _FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT,
'global_ignore_text': [], # List of text to ignore when calculating the comparison checksum
'global_subtractive_selectors': [],
'ignore_whitespace': True,
'ignore_status_codes': False, #@todo implement, as ternary.
'notification_body': default_notification_body,
'notification_format': default_notification_format,
'notification_title': default_notification_title,
@@ -57,10 +57,11 @@ class model(dict):
'rss_hide_muted_watches': True,
'schema_version' : 0,
'shared_diff_access': False,
'webdriver_delay': None , # Extra delay in seconds before extracting text
'tags': {}, #@todo use Tag.model initialisers
'timezone': None, # Default IANA timezone name
'webdriver_delay': None , # Extra delay in seconds before extracting text
'ui': {
'use_page_title_in_list': True,
'open_diff_in_new_tab': True,
'socket_io_enabled': True,
'favicons_enabled': True
+2 -2
View File
@@ -169,8 +169,8 @@ class model(watch_base):
@property
def label(self):
# Used for sorting
return self.get('title') if self.get('title') else self.get('url')
# Used for sorting, display, etc
return self.get('title') or self.get('page_title') or self.get('url')
@property
def last_changed(self):
+4 -2
View File
@@ -24,7 +24,6 @@ class watch_base(dict):
'content-type': None,
'date_created': None,
'extract_text': [], # Extract text by regex after filters
'extract_title_as_title': False,
'fetch_backend': 'system', # plaintext, playwright etc
'fetch_time': 0.0,
'filter_failure_notification_send': strtobool(os.getenv('FILTER_FAILURE_NOTIFICATION_SEND_DEFAULT', 'True')),
@@ -35,6 +34,7 @@ class watch_base(dict):
'has_ldjson_price_data': 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,
@@ -49,6 +49,7 @@ class watch_base(dict):
'notification_screenshot': False, # Include the latest screenshot if available and supported by the apprise URL
'notification_title': None,
'notification_urls': [], # List of URLs to add to the notification Queue (Usually AppRise)
'page_title': None, # <title> from the page
'paused': False,
'previous_md5': False,
'previous_md5_before_filters': False, # Used for skipping changedetection entirely
@@ -122,12 +123,13 @@ class watch_base(dict):
}
},
},
'title': None,
'title': None, # An arbitrary field that overrides 'page_title'
'track_ldjson_price_data': None,
'trim_text_whitespace': False,
'remove_duplicate_lines': False,
'trigger_text': [], # List of text or regex to wait for until a change is detected
'url': '',
'use_page_title_in_list': None, # None = use system settings
'uuid': str(uuid.uuid4()),
'webdriver_delay': None,
'webdriver_js_execute_code': None, # Run before change-detection
+1 -1
View File
@@ -149,7 +149,7 @@ def create_notification_parameters(n_object, datastore):
uuid = n_object['uuid'] if 'uuid' in n_object else ''
if uuid:
watch_title = datastore.data['watching'][uuid].get('title', '')
watch_title = datastore.data['watching'][uuid].label
tag_list = []
tags = datastore.get_all_tags_for_watch(uuid)
if tags:
@@ -251,8 +251,7 @@ class perform_site_check(difference_detection_processor):
update_obj["last_check_status"] = self.fetcher.get_last_status_code()
# 615 Extract text by regex
extract_text = watch.get('extract_text', [])
extract_text += self.datastore.get_tag_overrides_for_watch(uuid=watch.get('uuid'), attr='extract_text')
extract_text = list(dict.fromkeys(watch.get('extract_text', []) + self.datastore.get_tag_overrides_for_watch(uuid=watch.get('uuid'), attr='extract_text')))
if len(extract_text) > 0:
regex_matched_output = []
for s_re in extract_text:
@@ -311,8 +310,7 @@ class perform_site_check(difference_detection_processor):
############ Blocking rules, after checksum #################
blocked = False
trigger_text = watch.get('trigger_text', [])
trigger_text += self.datastore.get_tag_overrides_for_watch(uuid=watch.get('uuid'), attr='trigger_text')
trigger_text = list(dict.fromkeys(watch.get('trigger_text', []) + self.datastore.get_tag_overrides_for_watch(uuid=watch.get('uuid'), attr='trigger_text')))
if len(trigger_text):
# Assume blocked
blocked = True
@@ -326,8 +324,7 @@ class perform_site_check(difference_detection_processor):
if result:
blocked = False
text_should_not_be_present = watch.get('text_should_not_be_present', [])
text_should_not_be_present += self.datastore.get_tag_overrides_for_watch(uuid=watch.get('uuid'), attr='text_should_not_be_present')
text_should_not_be_present = list(dict.fromkeys(watch.get('text_should_not_be_present', []) + self.datastore.get_tag_overrides_for_watch(uuid=watch.get('uuid'), attr='text_should_not_be_present')))
if len(text_should_not_be_present):
# If anything matched, then we should block a change from happening
result = html_tools.strip_ignore_text(content=str(stripped_text_from_html),
+1
View File
@@ -153,6 +153,7 @@ $(document).ready(function () {
// Tabs at bottom of list
$('#post-list-mark-views').toggleClass("has-unviewed", general_stats.has_unviewed);
$('#post-list-unread').toggleClass("has-unviewed", general_stats.has_unviewed);
$('#post-list-with-errors').toggleClass("has-error", general_stats.count_errors !== 0)
$('#post-list-with-errors a').text(`With errors (${ general_stats.count_errors })`);
@@ -51,6 +51,7 @@ $(document).ready(function () {
$('#notification_body').val('');
$('#notification_format').val('System default');
$('#notification_urls').val('');
$('#notification_muted_none').prop('checked', true); // in the case of a ternary field
e.preventDefault();
});
$("#notification-token-toggle").click(function (e) {
@@ -24,6 +24,9 @@ body.checking-now {
#post-list-mark-views.has-unviewed {
display: inline-block !important;
}
#post-list-unread.has-unviewed {
display: inline-block !important;
}
}
@@ -0,0 +1,115 @@
// Ternary radio button group component
.ternary-radio-group {
display: flex;
gap: 0;
border: 1px solid var(--color-grey-750);
border-radius: 4px;
overflow: hidden;
width: fit-content;
background: var(--color-background);
.ternary-radio-option {
position: relative;
cursor: pointer;
margin: 0;
display: flex;
align-items: center;
input[type="radio"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.ternary-radio-label {
padding: 8px 16px;
background: var(--color-grey-900);
border: none;
border-right: 1px solid var(--color-grey-750);
font-size: 13px;
font-weight: 500;
color: var(--color-text);
transition: all 0.2s ease;
cursor: pointer;
display: block;
min-width: 60px;
text-align: center;
}
&:last-child .ternary-radio-label {
border-right: none;
}
input:checked + .ternary-radio-label {
background: var(--color-link);
color: var(--color-text-button);
font-weight: 600;
&.ternary-default {
background: var(--color-grey-600);
color: var(--color-text-button);
}
&:hover {
background: #1a7bc4;
&.ternary-default {
background: var(--color-grey-500);
}
}
}
&:hover .ternary-radio-label {
background: var(--color-grey-800);
}
}
@media (max-width: 480px) {
width: 100%;
.ternary-radio-label {
flex: 1;
min-width: auto;
}
}
}
// Standard radio button styling
input[type="radio"].pure-radio:checked + label,
input[type="radio"].pure-radio:checked {
background: var(--color-link);
color: var(--color-text-button);
}
html[data-darkmode="true"] {
.ternary-radio-group {
.ternary-radio-option {
.ternary-radio-label {
background: var(--color-grey-350);
}
&:hover .ternary-radio-label {
background: var(--color-grey-400);
}
input:checked + .ternary-radio-label {
background: var(--color-link);
color: var(--color-text-button);
&.ternary-default {
background: var(--color-grey-600);
}
&:hover {
background: #1a7bc4;
&.ternary-default {
background: var(--color-grey-500);
}
}
}
}
}
}
@@ -20,7 +20,7 @@
@use "parts/lister_extra";
@use "parts/socket";
@use "parts/visualselector";
@use "parts/widgets";
body {
color: var(--color-text);
@@ -1130,11 +1130,12 @@ ul {
}
#realtime-conn-error {
position: absolute;
position: fixed;
bottom: 0;
left: 30px;
left: 0;
background: var(--color-warning);
padding: 10px;
font-size: 0.8rem;
color: #fff;
opacity: 0.8;
}
File diff suppressed because one or more lines are too long
+11 -6
View File
@@ -262,11 +262,6 @@ class ChangeDetectionStore:
extras = deepcopy(self.data['watching'][uuid])
new_uuid = self.add_watch(url=url, extras=extras)
watch = self.data['watching'][new_uuid]
if self.data['settings']['application'].get('extract_title_as_title') or watch['extract_title_as_title']:
# Because it will be recalculated on the next fetch
self.data['watching'][new_uuid]['title'] = None
return new_uuid
def url_exists(self, url):
@@ -308,7 +303,6 @@ class ChangeDetectionStore:
'browser_steps',
'css_filter',
'extract_text',
'extract_title_as_title',
'headers',
'ignore_text',
'include_filters',
@@ -323,6 +317,7 @@ class ChangeDetectionStore:
'title',
'trigger_text',
'url',
'use_page_title_in_list',
'webdriver_js_execute_code',
]:
if res.get(k):
@@ -973,6 +968,16 @@ class ChangeDetectionStore:
f_d.write(zlib.compress(f_j.read()))
os.unlink(json_path)
def update_20(self):
for uuid, watch in self.data['watching'].items():
if self.data['watching'][uuid].get('extract_title_as_title'):
self.data['watching'][uuid]['use_page_title_in_list'] = self.data['watching'][uuid].get('extract_title_as_title')
del self.data['watching'][uuid]['extract_title_as_title']
if self.data['settings']['application'].get('extract_title_as_title'):
self.data['settings']['application']['ui']['use_page_title_in_list'] = self.data['settings']['application'].get('extract_title_as_title')
def add_notification_url(self, notification_url):
logger.debug(f">>> Adding new notification_url - '{notification_url}'")
@@ -70,7 +70,7 @@
</tr>
<tr>
<td><code>{{ '{{watch_title}}' }}</code></td>
<td>The title of the watch.</td>
<td>The page title of the watch, uses &lt;title&gt; if not set, falls back to URL</td>
</tr>
<tr>
<td><code>{{ '{{watch_tag}}' }}</code></td>
+17
View File
@@ -24,6 +24,23 @@
</div>
{% endmacro %}
{% macro render_ternary_field(field, BooleanField=false) %}
{% if BooleanField %}
{% set _ = field.__setattr__('boolean_mode', true) %}
{% endif %}
<div class="ternary-field {% if field.errors %} error {% endif %}">
<div class="ternary-field-label">{{ field.label }}</div>
<div class="ternary-field-widget">{{ field(**kwargs)|safe }}</div>
{% if field.errors %}
<ul class=errors>
{% for error in field.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endmacro %}
{% macro render_simple_field(field) %}
<span class="label {% if field.errors %}error{% endif %}">{{ field.label }}</span>
+1 -1
View File
@@ -236,7 +236,7 @@
<script src="{{url_for('static_content', group='js', filename='toggle-theme.js')}}" defer></script>
<div id="checking-now-fixed-tab" style="display: none;"><span class="spinner"></span><span>&nbsp;Checking now</span></div>
<div id="realtime-conn-error" style="display:none">Offline</div>
<div id="realtime-conn-error" style="display:none">Real-time updates offline</div>
</body>
</html>
+10 -3
View File
@@ -311,7 +311,7 @@ def test_api_watch_PUT_update(client, live_server, measure_memory_usage):
"value": "." # contains anything
}
],
"conditions_match_logic": "ALL"
"conditions_match_logic": "ALL",
}
),
headers={'content-type': 'application/json', 'x-api-key': api_key},
@@ -328,6 +328,7 @@ def test_api_watch_PUT_update(client, live_server, measure_memory_usage):
)
watch_uuid = list(res.json.keys())[0]
assert not res.json[watch_uuid].get('viewed'), 'A newly created watch can only be unviewed'
# Check in the edit page just to be sure
res = client.get(
@@ -341,7 +342,12 @@ def test_api_watch_PUT_update(client, live_server, measure_memory_usage):
res = client.put(
url_for("watch", uuid=watch_uuid),
headers={'x-api-key': api_key, 'content-type': 'application/json'},
data=json.dumps({"title": "new title", 'time_between_check': {'minutes': 552}, 'headers': {'cookie': 'all eaten'}}),
data=json.dumps({
"title": "new title",
'time_between_check': {'minutes': 552},
'headers': {'cookie': 'all eaten'},
'last_viewed': int(time.time())
}),
)
assert res.status_code == 200, "HTTP PUT update was sent OK"
@@ -351,6 +357,7 @@ def test_api_watch_PUT_update(client, live_server, measure_memory_usage):
headers={'x-api-key': api_key}
)
assert res.json.get('title') == 'new title'
assert res.json.get('viewed'), 'With the timestamp greater than "changed" a watch can be updated to viewed'
# Check in the edit page just to be sure
res = client.get(
@@ -383,7 +390,7 @@ def test_api_watch_PUT_update(client, live_server, measure_memory_usage):
def test_api_import(client, live_server, measure_memory_usage):
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
res = client.post(
+17 -11
View File
@@ -89,7 +89,7 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
assert b'CDATA' in res.data
assert expected_url.encode('utf-8') in res.data
#
# Following the 'diff' link, it should no longer display as 'unviewed' even after we recheck it a few times
res = client.get(url_for("ui.ui_views.diff_history_page", uuid=uuid))
assert b'selected=""' in res.data, "Confirm diff history page loaded"
@@ -104,26 +104,34 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
wait_for_all_checks(client)
# Do this a few times.. ensures we dont accidently set the status
for n in range(2):
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
# Do this a few times.. ensures we don't accidently set the status
for n in range(2):
res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
# Give the thread time to pick it up
wait_for_all_checks(client)
# It should report nothing found (no new 'unviewed' class)
res = client.get(url_for("watchlist.index"))
assert b'unviewed' not in res.data
assert b'class="has-unviewed' not in res.data
assert b'head title' not in res.data # Should not be present because this is off by default
assert b'head title' in res.data # Should be ON by default
assert b'test-endpoint' in res.data
set_original_response()
# Recheck it but only with a title change, content wasnt changed
set_original_response(extra_title=" and more")
# Enable auto pickup of <title> in settings
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
res = client.get(url_for("watchlist.index"))
assert b'head title and more' in res.data
# disable <title> pickup
res = client.post(
url_for("settings.settings_page"),
data={"application-extract_title_as_title": "1", "requests-time_between_check-minutes": 180,
data={"application-ui-use_page_title_in_list": "", "requests-time_between_check-minutes": 180,
'application-fetch_backend': "html_requests"},
follow_redirects=True
)
@@ -134,16 +142,14 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
res = client.get(url_for("watchlist.index"))
assert b'unviewed' in res.data
assert b'class="has-unviewed' in res.data
assert b'head title' not in res.data # should now be off
# It should have picked up the <title>
assert b'head title' in res.data
# Be sure the last_viewed is going to be greater than the last snapshot
time.sleep(1)
# hit the mark all viewed link
res = client.get(url_for("ui.mark_all_viewed"), follow_redirects=True)
time.sleep(0.2)
assert b'class="has-unviewed' not in res.data
assert b'unviewed' not in res.data
+3 -3
View File
@@ -6,9 +6,9 @@ from flask import url_for
import logging
import time
def set_original_response():
test_return_data = """<html>
<head><title>head title</title></head>
def set_original_response(extra_title=''):
test_return_data = f"""<html>
<head><title>head title{extra_title}</title></head>
<body>
Some initial text<br>
<p>Which is across multiple lines</p>
+3
View File
@@ -0,0 +1,3 @@
from .ternary_boolean import TernaryNoneBooleanWidget, TernaryNoneBooleanField
__all__ = ['TernaryNoneBooleanWidget', 'TernaryNoneBooleanField']
@@ -0,0 +1,104 @@
from wtforms import Field
from wtforms import widgets
from markupsafe import Markup
class TernaryNoneBooleanWidget:
"""
A widget that renders a horizontal radio button group with either two options (Yes/No)
or three options (Yes/No/Default), depending on the field's boolean_mode setting.
"""
def __call__(self, field, **kwargs):
html = ['<div class="ternary-radio-group pure-form">']
field_id = kwargs.pop('id', field.id)
boolean_mode = getattr(field, 'boolean_mode', False)
# Get custom text or use defaults
yes_text = getattr(field, 'yes_text', 'Yes')
no_text = getattr(field, 'no_text', 'No')
none_text = getattr(field, 'none_text', 'Main settings')
# True option
checked_true = ' checked' if field.data is True else ''
html.append(f'''
<label class="ternary-radio-option">
<input type="radio" name="{field.name}" value="true" id="{field_id}_true"{checked_true} class="pure-radio">
<span class="ternary-radio-label pure-button-primary">{yes_text}</span>
</label>
''')
# False option
checked_false = ' checked' if field.data is False else ''
html.append(f'''
<label class="ternary-radio-option">
<input type="radio" name="{field.name}" value="false" id="{field_id}_false"{checked_false} class="pure-radio">
<span class="ternary-radio-label">{no_text}</span>
</label>
''')
# None option (only show if not in boolean mode)
if not boolean_mode:
checked_none = ' checked' if field.data is None else ''
html.append(f'''
<label class="ternary-radio-option">
<input type="radio" name="{field.name}" value="none" id="{field_id}_none"{checked_none} class="pure-radio">
<span class="ternary-radio-label ternary-default">{none_text}</span>
</label>
''')
html.append('</div>')
return Markup(''.join(html))
class TernaryNoneBooleanField(Field):
"""
A field that can handle True, False, or None values, represented as a horizontal radio group.
When boolean_mode=True, it acts like a BooleanField (only Yes/No options).
When boolean_mode=False (default), it shows Yes/No/Default options.
Custom text can be provided for each option:
- yes_text: Text for True option (default: "Yes")
- no_text: Text for False option (default: "No")
- none_text: Text for None option (default: "Default")
"""
widget = TernaryNoneBooleanWidget()
def __init__(self, label=None, validators=None, false_values=None, boolean_mode=False,
yes_text="Yes", no_text="No", none_text="Main settings", **kwargs):
super(TernaryNoneBooleanField, self).__init__(label, validators, **kwargs)
self.boolean_mode = boolean_mode
self.yes_text = yes_text
self.no_text = no_text
self.none_text = none_text
if false_values is None:
self.false_values = {'false', ''}
else:
self.false_values = false_values
def process_formdata(self, valuelist):
if not valuelist or not valuelist[0]:
# In boolean mode, default to False instead of None
self.data = False if self.boolean_mode else None
elif valuelist[0].lower() == 'true':
self.data = True
elif valuelist[0].lower() == 'false':
self.data = False
elif valuelist[0].lower() == 'none':
# In boolean mode, treat 'none' as False
self.data = False if self.boolean_mode else None
else:
self.data = False if self.boolean_mode else None
def _value(self):
if self.data is True:
return 'true'
elif self.data is False:
return 'false'
else:
# In boolean mode, None should be treated as False
if self.boolean_mode:
return 'false'
else:
return 'none'
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../..'))
from changedetectionio.widgets import TernaryNoneBooleanField
from wtforms import Form
class TestForm(Form):
# Default text
default_field = TernaryNoneBooleanField('Default Field', default=None)
# Custom text with HTML icons
notification_field = TernaryNoneBooleanField(
'Notifications',
default=False,
yes_text='🔕 Muted',
no_text='🔔 Unmuted',
none_text='⚙️ System default'
)
# HTML with styling
styled_field = TernaryNoneBooleanField(
'Status',
default=None,
yes_text='<strong style="color: green;">✅ Active</strong>',
no_text='<strong style="color: red;">❌ Inactive</strong>',
none_text='<em style="color: gray;">🔧 Auto</em>'
)
# Boolean mode with custom text
boolean_field = TernaryNoneBooleanField(
'Boolean Field',
default=True,
boolean_mode=True,
yes_text="Enabled",
no_text="Disabled"
)
# FontAwesome example
fontawesome_field = TernaryNoneBooleanField(
'Notifications with FontAwesome',
default=None,
yes_text='<i class="fa fa-bell-slash"></i> Muted',
no_text='<i class="fa fa-bell"></i> Unmuted',
none_text='<i class="fa fa-cogs"></i> System default'
)
def test_custom_text():
"""Test custom text functionality"""
form = TestForm()
print("=== Testing TernaryNoneBooleanField Custom Text ===")
# Test default field
print("\n--- Default Field ---")
default_field = form.default_field
default_html = default_field.widget(default_field)
print(f"Contains 'Yes': {'Yes' in default_html}")
print(f"Contains 'No': {'No' in default_html}")
print(f"Contains 'Default': {'Default' in default_html}")
assert 'Yes' in default_html and 'No' in default_html and 'Default' in default_html
# Test custom text field
print("\n--- Custom Text Field with Emojis ---")
notification_field = form.notification_field
notification_html = notification_field.widget(notification_field)
print(f"Contains '🔕 Muted': {'🔕 Muted' in notification_html}")
print(f"Contains '🔔 Unmuted': {'🔔 Unmuted' in notification_html}")
print(f"Contains '⚙️ System default': {'⚙️ System default' in notification_html}")
print(f"Does NOT contain 'Yes': {'Yes' not in notification_html}")
print(f"Does NOT contain 'No': {'No' not in notification_html}")
assert '🔕 Muted' in notification_html and '🔔 Unmuted' in notification_html
assert 'Yes' not in notification_html and 'No' not in notification_html
# Test HTML styling
print("\n--- HTML Styled Field ---")
styled_field = form.styled_field
styled_html = styled_field.widget(styled_field)
print(f"Contains HTML tags: {'<strong' in styled_html}")
print(f"Contains color styling: {'color: green' in styled_html}")
print(f"Contains emojis: {'' in styled_html and '' in styled_html}")
assert '<strong' in styled_html and 'color: green' in styled_html
# Test boolean mode with custom text
print("\n--- Boolean Field with Custom Text ---")
boolean_field = form.boolean_field
boolean_html = boolean_field.widget(boolean_field)
print(f"Contains 'Enabled': {'Enabled' in boolean_html}")
print(f"Contains 'Disabled': {'Disabled' in boolean_html}")
print(f"Does NOT contain 'System default': {'System default' not in boolean_html}")
print(f"Does NOT contain 'Default': {'Default' not in boolean_html}")
assert 'Enabled' in boolean_html and 'Disabled' in boolean_html
assert 'System default' not in boolean_html and 'Default' not in boolean_html
# Test FontAwesome field
print("\n--- FontAwesome Icons Field ---")
fontawesome_field = form.fontawesome_field
fontawesome_html = fontawesome_field.widget(fontawesome_field)
print(f"Contains FontAwesome classes: {'fa fa-bell' in fontawesome_html}")
print(f"Contains multiple FA icons: {'fa fa-bell-slash' in fontawesome_html and 'fa fa-cogs' in fontawesome_html}")
assert 'fa fa-bell' in fontawesome_html
print("\n✅ All custom text tests passed!")
print("\n--- Example Usage ---")
print("TernaryNoneBooleanField('Status', yes_text='🟢 Online', no_text='🔴 Offline', none_text='🟡 Auto')")
print("TernaryNoneBooleanField('Notifications', yes_text='<i class=\"fa fa-bell-slash\"></i> Muted', ...)")
def test_data_processing():
"""Test that custom text doesn't affect data processing"""
print("\n=== Testing Data Processing ===")
form = TestForm()
field = form.notification_field
# Test form data processing
field.process_formdata(['true'])
assert field.data is True, "Custom text should not affect data processing"
print("✅ True processing works with custom text")
field.process_formdata(['false'])
assert field.data is False, "Custom text should not affect data processing"
print("✅ False processing works with custom text")
field.process_formdata(['none'])
assert field.data is None, "Custom text should not affect data processing"
print("✅ None processing works with custom text")
print("✅ All data processing tests passed!")
if __name__ == '__main__':
test_custom_text()
test_data_processing()
+1
View File
@@ -84,6 +84,7 @@ services:
# Comment out ports: when using behind a reverse proxy , enable networks: etc.
# Mac users! Use "127.0.0.1:5050:5000" (port 5050) so theres no conflict with Airplay etc. (https://github.com/dgtlmoon/changedetection.io/issues/3401)
ports:
- 127.0.0.1:5000:5000
restart: unless-stopped
+27 -3
View File
@@ -1,9 +1,33 @@
Directory of docs
To regenerate API docs
## Regenerating API Documentation
Run from this directory.
### Modern Interactive API Docs (Recommended)
To regenerate the modern API documentation, run from the `docs/` directory:
```bash
# Install dependencies (first time only)
npm install
# Generate the HTML documentation from OpenAPI spec using Redoc
npm run build-docs
```
### OpenAPI Specification
The OpenAPI specification (`docs/api-spec.yaml`) is the source of truth for API documentation. This industry-standard format enables:
- **Interactive documentation** - Test endpoints directly in the browser
- **SDK generation** - Auto-generate client libraries for any programming language
- **API validation** - Ensure code matches documentation
- **Integration tools** - Import into Postman, Insomnia, API gateways, etc.
**Important:** When adding or modifying API endpoints, you must update `docs/api-spec.yaml` to keep documentation in sync:
1. Edit `docs/api-spec.yaml` with new endpoints, parameters, or response schemas
2. Run `npm run build-docs` to regenerate the HTML documentation
3. Commit both the YAML spec and generated HTML files
`python3 python-apidoc/apidoc.py -i ../changedetectionio -o api_v1/index.html`
+1306
View File
File diff suppressed because it is too large Load Diff
+920 -2225
View File
File diff suppressed because one or more lines are too long
+12
View File
@@ -0,0 +1,12 @@
{
"name": "changedetection-api-docs",
"version": "1.0.0",
"description": "API documentation generation for changedetection.io",
"private": true,
"scripts": {
"build-docs": "redocly build-docs api-spec.yaml --output api_v1/index.html"
},
"devDependencies": {
"@redocly/cli": "^1.34.5"
}
}
-397
View File
@@ -1,397 +0,0 @@
#!/usr/bin/env python3
"""
Python API Documentation Generator
Parses @api comments from Python files and generates Bootstrap HTML docs
"""
import re
import os
import json
import argparse
from pathlib import Path
from dataclasses import dataclass, field
from typing import List, Dict, Any
from jinja2 import Template
@dataclass
class ApiEndpoint:
method: str = ""
url: str = ""
title: str = ""
name: str = ""
group: str = "General"
group_order: int = 999 # Default to high number (low priority)
group_doc_order: int = 999 # Default to high number (low priority) for sidebar ordering
description: str = ""
params: List[Dict[str, Any]] = field(default_factory=list)
query: List[Dict[str, Any]] = field(default_factory=list)
success: List[Dict[str, Any]] = field(default_factory=list)
error: List[Dict[str, Any]] = field(default_factory=list)
example: str = ""
example_request: str = ""
example_response: str = ""
def prettify_json(text: str) -> str:
"""Attempt to prettify JSON content in the text"""
if not text or not text.strip():
return text
# First, try to parse the entire text as JSON
stripped_text = text.strip()
try:
json_obj = json.loads(stripped_text)
return json.dumps(json_obj, indent=2, ensure_ascii=False)
except (json.JSONDecodeError, ValueError):
pass
# If that fails, try to find JSON blocks within the text
lines = text.split('\n')
prettified_lines = []
i = 0
while i < len(lines):
line = lines[i]
stripped_line = line.strip()
# Look for the start of a JSON object or array
if stripped_line.startswith('{') or stripped_line.startswith('['):
# Try to collect a complete JSON block
json_lines = [stripped_line]
brace_count = stripped_line.count('{') - stripped_line.count('}')
bracket_count = stripped_line.count('[') - stripped_line.count(']')
j = i + 1
while j < len(lines) and (brace_count > 0 or bracket_count > 0):
next_line = lines[j].strip()
json_lines.append(next_line)
brace_count += next_line.count('{') - next_line.count('}')
bracket_count += next_line.count('[') - next_line.count(']')
j += 1
# Try to parse and prettify the collected JSON block
json_block = '\n'.join(json_lines)
try:
json_obj = json.loads(json_block)
prettified = json.dumps(json_obj, indent=2, ensure_ascii=False)
prettified_lines.append(prettified)
i = j # Skip the lines we just processed
continue
except (json.JSONDecodeError, ValueError):
# If parsing failed, just add the original line
prettified_lines.append(line)
else:
prettified_lines.append(line)
i += 1
return '\n'.join(prettified_lines)
class ApiDocParser:
def __init__(self):
self.patterns = {
'api': re.compile(r'@api\s*\{(\w+)\}\s*([^\s]+)\s*(.*)'),
'apiName': re.compile(r'@apiName\s+(.*)'),
'apiGroup': re.compile(r'@apiGroup\s+(.*)'),
'apiGroupOrder': re.compile(r'@apiGroupOrder\s+(\d+)'),
'apiGroupDocOrder': re.compile(r'@apiGroupDocOrder\s+(\d+)'),
'apiDescription': re.compile(r'@apiDescription\s+(.*)'),
'apiParam': re.compile(r'@apiParam\s*\{([^}]+)\}\s*(\[?[\w.:]+\]?)\s*(.*)'),
'apiQuery': re.compile(r'@apiQuery\s*\{([^}]+)\}\s*(\[?[\w.:]+\]?)\s*(.*)'),
'apiSuccess': re.compile(r'@apiSuccess\s*\((\d+)\)\s*\{([^}]+)\}\s*(\w+)?\s*(.*)'),
'apiError': re.compile(r'@apiError\s*\((\d+)\)\s*\{([^}]+)\}\s*(.*)'),
'apiExample': re.compile(r'@apiExample\s*\{([^}]+)\}\s*(.*)'),
'apiExampleRequest': re.compile(r'@apiExampleRequest\s*\{([^}]+)\}\s*(.*)'),
'apiExampleResponse': re.compile(r'@apiExampleResponse\s*\{([^}]+)\}\s*(.*)'),
}
def parse_file(self, file_path: Path) -> List[ApiEndpoint]:
"""Parse a single Python file for @api comments"""
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
except Exception as e:
print(f"Error reading {file_path}: {e}")
return []
endpoints = []
current_endpoint = None
in_multiline_example = False
in_multiline_request = False
in_multiline_response = False
example_lines = []
request_lines = []
response_lines = []
for line in content.split('\n'):
line_stripped = line.strip()
# Handle multiline examples, requests, and responses
if in_multiline_example or in_multiline_request or in_multiline_response:
# Check if this line starts a new example type or exits multiline mode
should_exit_multiline = False
if line_stripped.startswith('@apiExampleRequest'):
# Finalize current multiline block and start request
should_exit_multiline = True
elif line_stripped.startswith('@apiExampleResponse'):
# Finalize current multiline block and start response
should_exit_multiline = True
elif line_stripped.startswith('@apiExample'):
# Finalize current multiline block and start example
should_exit_multiline = True
elif line_stripped.startswith('@api') and not any(x in line_stripped for x in ['@apiExample', '@apiExampleRequest', '@apiExampleResponse']):
# Exit multiline mode for any other @api directive
should_exit_multiline = True
if should_exit_multiline:
# Finalize any active multiline blocks
if in_multiline_example and current_endpoint and example_lines:
current_endpoint.example = '\n'.join(example_lines)
if in_multiline_request and current_endpoint and request_lines:
current_endpoint.example_request = '\n'.join(request_lines)
if in_multiline_response and current_endpoint and response_lines:
raw_response = '\n'.join(response_lines)
current_endpoint.example_response = prettify_json(raw_response)
# Reset all multiline states
in_multiline_example = False
in_multiline_request = False
in_multiline_response = False
example_lines = []
request_lines = []
response_lines = []
# If this is still an example directive, continue processing it
if not (line_stripped.startswith('@apiExample') or line_stripped.startswith('@apiExampleRequest') or line_stripped.startswith('@apiExampleResponse')):
# This is a different @api directive, let it be processed normally
pass
# If it's an example directive, it will be processed below
else:
# For multiline blocks, preserve the content more liberally
# Remove leading comment markers but preserve structure
clean_line = re.sub(r'^\s*[#*/]*\s?', '', line)
# Add the line if it has content or if it's an empty line (for formatting)
if clean_line or not line_stripped:
if in_multiline_example:
example_lines.append(clean_line)
elif in_multiline_request:
request_lines.append(clean_line)
elif in_multiline_response:
response_lines.append(clean_line)
continue
# Skip non-comment lines
if not any(marker in line_stripped for marker in ['@api', '#', '*', '//']):
continue
# Extract @api patterns
for pattern_name, pattern in self.patterns.items():
match = pattern.search(line_stripped)
if match:
if pattern_name == 'api':
# Start new endpoint
if current_endpoint:
endpoints.append(current_endpoint)
current_endpoint = ApiEndpoint()
current_endpoint.method = match.group(1).lower()
current_endpoint.url = match.group(2)
current_endpoint.title = match.group(3).strip()
elif current_endpoint:
if pattern_name == 'apiName':
current_endpoint.name = match.group(1)
elif pattern_name == 'apiGroup':
current_endpoint.group = match.group(1)
elif pattern_name == 'apiGroupOrder':
current_endpoint.group_order = int(match.group(1))
elif pattern_name == 'apiGroupDocOrder':
current_endpoint.group_doc_order = int(match.group(1))
elif pattern_name == 'apiDescription':
current_endpoint.description = match.group(1)
elif pattern_name == 'apiParam':
param_type = match.group(1)
param_name = match.group(2).strip('[]')
param_desc = match.group(3)
optional = '[' in match.group(2)
current_endpoint.params.append({
'type': param_type,
'name': param_name,
'description': param_desc,
'optional': optional
})
elif pattern_name == 'apiQuery':
param_type = match.group(1)
param_name = match.group(2).strip('[]')
param_desc = match.group(3)
optional = '[' in match.group(2)
current_endpoint.query.append({
'type': param_type,
'name': param_name,
'description': param_desc,
'optional': optional
})
elif pattern_name == 'apiSuccess':
status_code = match.group(1)
response_type = match.group(2)
response_name = match.group(3) or 'response'
response_desc = match.group(4)
current_endpoint.success.append({
'status': status_code,
'type': response_type,
'name': response_name,
'description': response_desc
})
elif pattern_name == 'apiError':
status_code = match.group(1)
error_type = match.group(2)
error_desc = match.group(3)
current_endpoint.error.append({
'status': status_code,
'type': error_type,
'description': error_desc
})
elif pattern_name == 'apiExample':
in_multiline_example = True
# Skip the "{curl} Example usage:" header line
example_lines = []
elif pattern_name == 'apiExampleRequest':
in_multiline_request = True
# Skip the "{curl} Request:" header line
request_lines = []
elif pattern_name == 'apiExampleResponse':
in_multiline_response = True
# Skip the "{json} Response:" header line
response_lines = []
break
# Don't forget the last endpoint
if current_endpoint:
if in_multiline_example and example_lines:
current_endpoint.example = '\n'.join(example_lines)
if in_multiline_request and request_lines:
current_endpoint.example_request = '\n'.join(request_lines)
if in_multiline_response and response_lines:
raw_response = '\n'.join(response_lines)
current_endpoint.example_response = prettify_json(raw_response)
endpoints.append(current_endpoint)
return endpoints
def parse_directory(self, directory: Path) -> List[ApiEndpoint]:
"""Parse all Python files in a directory"""
all_endpoints = []
for py_file in directory.rglob('*.py'):
endpoints = self.parse_file(py_file)
all_endpoints.extend(endpoints)
return all_endpoints
def generate_html(endpoints: List[ApiEndpoint], output_file: Path, template_file: Path):
"""Generate HTML documentation using Jinja2 template"""
# Group endpoints by group and collect group orders
grouped_endpoints = {}
group_orders = {}
group_doc_orders = {}
for endpoint in endpoints:
group = endpoint.group
if group not in grouped_endpoints:
grouped_endpoints[group] = []
group_orders[group] = endpoint.group_order
group_doc_orders[group] = endpoint.group_doc_order
grouped_endpoints[group].append(endpoint)
# Use the lowest order value for the group (in case of multiple definitions)
group_orders[group] = min(group_orders[group], endpoint.group_order)
group_doc_orders[group] = min(group_doc_orders[group], endpoint.group_doc_order)
# Sort groups by doc order for sidebar (0 = highest priority), then by content order, then alphabetically
sorted_groups = sorted(grouped_endpoints.items(), key=lambda x: (group_doc_orders[x[0]], group_orders[x[0]], x[0]))
# Convert back to ordered dict and sort endpoints within each group
grouped_endpoints = {}
for group, endpoints_list in sorted_groups:
endpoints_list.sort(key=lambda x: (x.name, x.url))
grouped_endpoints[group] = endpoints_list
# Load template
with open(template_file, 'r', encoding='utf-8') as f:
template_content = f.read()
# Load introduction content
introduction_file = template_file.parent / 'introduction.html'
introduction_content = ""
if introduction_file.exists():
with open(introduction_file, 'r', encoding='utf-8') as f:
introduction_content = f.read()
# Load sidebar header content
sidebar_header_file = template_file.parent / 'sidebar-header.html'
sidebar_header_content = "<h4>API Documentation</h4>" # Default fallback
if sidebar_header_file.exists():
with open(sidebar_header_file, 'r', encoding='utf-8') as f:
sidebar_header_content = f.read()
template = Template(template_content)
html_content = template.render(
grouped_endpoints=grouped_endpoints,
introduction_content=introduction_content,
sidebar_header_content=sidebar_header_content
)
with open(output_file, 'w', encoding='utf-8') as f:
f.write(html_content)
def main():
parser = argparse.ArgumentParser(description='Generate API documentation from Python source files')
parser.add_argument('-i', '--input', default='.',
help='Input directory to scan for Python files (default: current directory)')
parser.add_argument('-o', '--output', default='api_docs.html',
help='Output HTML file (default: api_docs.html)')
parser.add_argument('-t', '--template', default='template.html',
help='Template HTML file (default: template.html)')
args = parser.parse_args()
input_path = Path(args.input)
output_path = Path(args.output)
template_path = Path(args.template)
# Make template path relative to script location if not absolute
if not template_path.is_absolute():
template_path = Path(__file__).parent / template_path
if not input_path.exists():
print(f"Error: Input directory '{input_path}' does not exist")
return 1
if not template_path.exists():
print(f"Error: Template file '{template_path}' does not exist")
return 1
print(f"Scanning {input_path} for @api comments...")
doc_parser = ApiDocParser()
endpoints = doc_parser.parse_directory(input_path)
if not endpoints:
print("No API endpoints found!")
return 1
print(f"Found {len(endpoints)} API endpoints")
# Create output directory if needed
output_path.parent.mkdir(parents=True, exist_ok=True)
print(f"Generating HTML documentation to {output_path}...")
generate_html(endpoints, output_path, template_path)
print("Documentation generated successfully!")
print(f"Open {output_path.resolve()} in your browser to view the docs")
return 0
if __name__ == '__main__':
exit(main())
File diff suppressed because it is too large Load Diff
-27
View File
@@ -1,27 +0,0 @@
<div class="introduction-content">
<h3>ChangeDetection.io, Web page monitoring and notifications API</h3>
<p>REST API for managing Page watches, Group tags, and Notifications.</p>
<p>changedetection.io can be driven by its built in simple API, in the examples below you will also find <code>curl</code> command line examples to help you.</p>
<p>
<h5>Where to find my API key?</h5>
The API key can be easily found under the <strong>SETTINGS</strong> then <strong>API</strong> tab of changedetection.io dashboard.<br>
Simply click the API key to automatically copy it to your clipboard.<br><br>
<img src="where-to-get-api-key.jpeg" alt="Where to find the API key" title="Where to find the API key" style="max-width: 80%"/>
</p>
<p>
<h5>Connection URL</h5>
The API can be found at <code>/api/v1/</code>, so for example if you run changedetection.io locally on port 5000, then URL would be
<code>http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/history</code>.<br><br>
If you are using the hosted/subscription version of changedetection.io, then the URL is based on your login URL, for example.<br>
<code>https://&lt;your login url&gt;/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/history</code>
</p>
<p>
<h5>Authentication</h5>
Almost all API requests require some authentication, this is provided as an <strong>API Key</strong> in the header of the HTTP request.<br><br>
For example;
<br><code>x-api-key: YOUR_API_KEY</code><br>
</p>
</div>
-1
View File
@@ -1 +0,0 @@
<h4>API Documentation</h4>
-506
View File
@@ -1,506 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API Documentation</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css" rel="stylesheet">
<style>
body { background-color: #f8f9fa; }
.sidebar { position: sticky; top: 0; height: 100vh; overflow-y: auto; background: white; box-shadow: 2px 0 5px rgba(0,0,0,0.1); }
.content { padding: 20px; }
.endpoint { margin-bottom: 40px; padding: 20px; background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.method { font-weight: bold; text-transform: uppercase; padding: 4px 8px; border-radius: 4px; }
.method.get { background: #d4edda; color: #155724; }
.method.post { background: #cce5ff; color: #004085; }
.method.put { background: #fff3cd; color: #856404; }
.method.delete { background: #f8d7da; color: #721c24; }
.param-table { font-size: 0.9em; }
.optional { color: #6c757d; font-style: italic; }
.example { background: #f8f9fa; border-left: 4px solid #007bff; }
pre { font-size: 0.85em; }
.copy-btn { opacity: 0.7; transition: opacity 0.2s ease; }
.copy-btn:hover { opacity: 1; }
.example:hover .copy-btn { opacity: 1; }
.nav-link.active { background-color: #007bff; color: white; font-weight: bold; }
.nav-link { transition: all 0.2s ease; }
.nav-link:hover:not(.active) { background-color: #e3f2fd; color: #0056b3; }
.group-header.active { font-weight: bold; color: #007bff; }
/* Custom scrollbar styling */
.sidebar::-webkit-scrollbar { width: 8px; }
.sidebar::-webkit-scrollbar-track { background: #f8f9fa; border-radius: 4px; }
.sidebar::-webkit-scrollbar-thumb { background: #dee2e6; border-radius: 4px; }
.sidebar::-webkit-scrollbar-thumb:hover { background: #adb5bd; }
/* Firefox scrollbar */
.sidebar { scrollbar-width: thin; scrollbar-color: #dee2e6 #f8f9fa; }
/* Mobile styles - disable sticky sidebar */
@media (max-width: 800px) {
.sidebar {
position: static !important;
height: auto !important;
overflow-y: visible !important;
}
}
</style>
</head>
<body>
<div class="container-fluid">
<div class="row">
<!-- Sidebar -->
<div class="col-md-3 sidebar">
<div class="p-3">
<a href="#introduction" class="text-decoration-none">
{{ sidebar_header_content|safe }}
</a>
<hr>
{% if introduction_content %}
<div class="mb-3">
<a href="#introduction" class="text-decoration-none">
<h6 class="text-muted">Introduction</h6>
</a>
</div>
{% endif %}
{% for group, endpoints in grouped_endpoints.items() %}
<div class="mb-3">
<h6 class="text-muted group-header" data-group="{{ group }}">{{ group }}</h6>
{% for endpoint in endpoints %}
<div class="ms-2 mb-1">
<a href="#{{ group|replace(' ', '_')|replace('/', '')|replace('-', '')|lower }}_{{ endpoint.method|upper }}" class="nav-link py-1 px-2 rounded" data-endpoint="{{ group|replace(' ', '_')|replace('/', '')|replace('-', '')|lower }}_{{ endpoint.method|upper }}">
<span class="method {{ endpoint.method }}">{{ endpoint.method }}</span>
{{ endpoint.title or endpoint.name or endpoint.description }}
</a>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
</div>
<!-- Main content -->
<div class="col-md-9 content">
{% if introduction_content %}
<div id="introduction" class="mb-5">
{{ introduction_content|safe }}
</div>
{% endif %}
{% for group, endpoints in grouped_endpoints.items() %}
<h2 class="text-primary mb-4" id="group-{{ group|replace(' ', '_')|lower }}">{{ group }}</h2>
{% for endpoint in endpoints %}
<div class="endpoint" id="{{ group|replace(' ', '_')|replace('/', '')|replace('-', '')|lower }}_{{ endpoint.method|upper }}" data-group="{{ group }}">
<div class="row">
<div class="col-md-8">
<h4>
<span class="method {{ endpoint.method }}">{{ endpoint.method }}</span>
<code>{{ endpoint.url|e }}</code>
</h4>
<h5 class="text-muted">{{ endpoint.name or endpoint.title }}</h5>
{% if endpoint.description %}
<p class="mt-3">{{ endpoint.description|safe }}</p>
{% endif %}
</div>
</div>
{% if endpoint.params %}
<h6 class="mt-4">Parameters</h6>
<div class="table-responsive">
<table class="table table-sm param-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Required</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{% for param in endpoint.params %}
<tr>
<td><code>{{ param.name }}</code></td>
<td><span class="badge bg-secondary">{{ param.type }}</span></td>
<td>{% if param.optional %}<span class="optional">Optional</span>{% else %}<span class="text-danger">Required</span>{% endif %}</td>
<td>{{ param.description }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% if endpoint.query %}
<h6 class="mt-4">Query Parameters</h6>
<div class="table-responsive">
<table class="table table-sm param-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Required</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{% for param in endpoint.query %}
<tr>
<td><code>{{ param.name }}</code></td>
<td><span class="badge bg-info">{{ param.type }}</span></td>
<td>{% if param.optional %}<span class="optional">Optional</span>{% else %}<span class="text-danger">Required</span>{% endif %}</td>
<td>{{ param.description }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% if endpoint.success %}
<h6 class="mt-4">Success Responses</h6>
{% for success in endpoint.success %}
<div class="mb-2">
<span class="badge bg-success">{{ success.status }}</span>
<span class="badge bg-secondary ms-1">{{ success.type }}</span>
<strong class="ms-2">{{ success.name }}</strong>
<span class="ms-2 text-muted">{{ success.description }}</span>
</div>
{% endfor %}
{% endif %}
{% if endpoint.error %}
<h6 class="mt-4">Error Responses</h6>
{% for error in endpoint.error %}
<div class="mb-2">
<span class="badge bg-danger">{{ error.status }}</span>
<span class="badge bg-secondary ms-1">{{ error.type }}</span>
<span class="ms-2 text-muted">{{ error.description }}</span>
</div>
{% endfor %}
{% endif %}
{% if endpoint.example or endpoint.example_request or endpoint.example_response %}
<h6 class="mt-4">Example</h6>
{% if endpoint.example_request %}
<h7 class="mt-3 mb-2 text-muted">Request</h7>
<div class="example p-3 rounded position-relative mb-3">
<button class="btn btn-outline-secondary btn-sm position-absolute top-0 end-0 m-2 copy-btn"
data-bs-toggle="tooltip"
data-bs-placement="left"
title="Copy to clipboard"
onclick="copyToClipboard(this)">
<i class="bi bi-clipboard" aria-hidden="true"></i>
</button>
<pre><code class="language-bash">{{ endpoint.example_request }}</code></pre>
</div>
{% endif %}
{% if endpoint.example_response %}
<h7 class="mt-3 mb-2 text-muted">Response</h7>
<div class="example p-3 rounded position-relative mb-3">
<button class="btn btn-outline-secondary btn-sm position-absolute top-0 end-0 m-2 copy-btn"
data-bs-toggle="tooltip"
data-bs-placement="left"
title="Copy to clipboard"
onclick="copyToClipboard(this)">
<i class="bi bi-clipboard" aria-hidden="true"></i>
</button>
<pre><code class="language-json">{{ endpoint.example_response }}</code></pre>
</div>
{% endif %}
{% if endpoint.example and not endpoint.example_request and not endpoint.example_response %}
<div class="example p-3 rounded position-relative">
<button class="btn btn-outline-secondary btn-sm position-absolute top-0 end-0 m-2 copy-btn"
data-bs-toggle="tooltip"
data-bs-placement="left"
title="Copy to clipboard"
onclick="copyToClipboard(this)">
<i class="bi bi-clipboard" aria-hidden="true"></i>
</button>
<pre><code class="language-bash">{{ endpoint.example }}</code></pre>
</div>
{% endif %}
{% endif %}
</div>
{% endfor %}
{% endfor %}
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
<script>
$(document).ready(function() {
let isScrolling = false;
let isNavigating = false;
// Check if we should disable scroll handling on mobile
function isMobileWidth() {
return window.innerWidth < 800;
}
// Debounced scroll handler
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// Function to scroll sidebar link into view if needed
function scrollIntoViewIfNeeded(element) {
if (!element) return;
const sidebar = $('.sidebar')[0];
const rect = element.getBoundingClientRect();
const sidebarRect = sidebar.getBoundingClientRect();
// Check if element is outside the sidebar viewport
const isAboveView = rect.top < sidebarRect.top;
const isBelowView = rect.bottom > sidebarRect.bottom;
if (isAboveView || isBelowView) {
element.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
}
}
// Intersection Observer for more efficient viewport detection
const observerOptions = {
root: null,
rootMargin: '-20% 0px -70% 0px', // Trigger when element is in top 30% of viewport
threshold: 0
};
const observer = new IntersectionObserver((entries) => {
// Don't update if user is actively navigating or on mobile
if (isNavigating || isMobileWidth()) return;
entries.forEach(entry => {
if (entry.isIntersecting) {
const targetId = entry.target.id;
const targetGroup = entry.target.dataset.group;
// Update window location hash
if (window.location.hash !== '#' + targetId) {
history.replaceState(null, null, '#' + targetId);
}
// Remove all active states
$('.nav-link').removeClass('active');
$('.group-header').removeClass('active');
// Add active state to current item
const $activeLink = $(`.nav-link[data-endpoint="${targetId}"]`);
$activeLink.addClass('active');
$(`.group-header[data-group="${targetGroup}"]`).addClass('active');
// Handle introduction section
if (targetId === 'introduction') {
const $introLink = $('a[href="#introduction"]');
$introLink.addClass('active');
// Scroll intro link into view in sidebar
scrollIntoViewIfNeeded($introLink[0]);
} else {
// Scroll active link into view in sidebar
if ($activeLink.length) {
scrollIntoViewIfNeeded($activeLink[0]);
}
}
}
});
}, observerOptions);
// Observe all endpoints and introduction (only on desktop)
if (!isMobileWidth()) {
$('.endpoint').each(function() {
observer.observe(this);
});
if ($('#introduction').length) {
observer.observe($('#introduction')[0]);
}
}
// Smooth scrolling for navigation links
$('a[href^="#"]').on('click', function(e) {
e.preventDefault();
const targetHref = this.getAttribute('href');
const target = $(targetHref);
if (target.length) {
// Set navigation flag to prevent observer interference
isNavigating = true;
// Update window location hash immediately
history.pushState(null, null, targetHref);
$('html, body').animate({
scrollTop: target.offset().top - 20
}, 300, function() {
// Clear navigation flag after animation completes
setTimeout(() => {
isNavigating = false;
}, 100);
});
}
});
// Fallback scroll handler with debouncing
const handleScroll = debounce(() => {
if (isScrolling || isNavigating || isMobileWidth()) return;
let current = '';
let currentGroup = '';
// Check which section is currently in view
$('.endpoint, #introduction').each(function() {
const element = $(this);
const elementTop = element.offset().top;
const elementBottom = elementTop + element.outerHeight();
const scrollTop = $(window).scrollTop() + 100; // Offset for better UX
if (scrollTop >= elementTop && scrollTop < elementBottom) {
current = this.id;
currentGroup = element.data('group');
return false; // Break loop
}
});
if (current) {
// Update window location hash
if (window.location.hash !== '#' + current) {
history.replaceState(null, null, '#' + current);
}
$('.nav-link').removeClass('active');
$('.group-header').removeClass('active');
const $activeLink = $(`.nav-link[data-endpoint="${current}"]`);
$activeLink.addClass('active');
if (currentGroup) {
$(`.group-header[data-group="${currentGroup}"]`).addClass('active');
}
if (current === 'introduction') {
const $introLink = $('a[href="#introduction"]');
$introLink.addClass('active');
scrollIntoViewIfNeeded($introLink[0]);
} else if ($activeLink.length) {
scrollIntoViewIfNeeded($activeLink[0]);
}
}
}, 50);
// Only bind scroll handler on desktop
if (!isMobileWidth()) {
$(window).on('scroll', handleScroll);
// Initial call
handleScroll();
}
// Initialize tooltips
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
const tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl);
});
});
// Copy to clipboard function
function copyToClipboard(button) {
const codeBlock = button.parentElement.querySelector('code');
const text = codeBlock.textContent;
// Use modern clipboard API
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(() => {
showCopyFeedback(button, true);
}).catch(() => {
fallbackCopyToClipboard(text, button);
});
} else {
fallbackCopyToClipboard(text, button);
}
}
// Fallback for older browsers
function fallbackCopyToClipboard(text, button) {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand('copy');
showCopyFeedback(button, successful);
} catch (err) {
showCopyFeedback(button, false);
}
document.body.removeChild(textArea);
}
// Show copy feedback
function showCopyFeedback(button, success) {
const icon = button.querySelector('i');
const originalClass = icon.className;
const originalTitle = button.getAttribute('data-bs-original-title') || button.getAttribute('title');
if (success) {
icon.className = 'bi bi-check';
button.classList.remove('btn-outline-secondary');
button.classList.add('btn-success');
button.setAttribute('title', 'Copied!');
} else {
icon.className = 'bi bi-x';
button.classList.remove('btn-outline-secondary');
button.classList.add('btn-danger');
button.setAttribute('title', 'Failed to copy');
}
// Update tooltip
const tooltip = bootstrap.Tooltip.getInstance(button);
if (tooltip) {
tooltip.dispose();
new bootstrap.Tooltip(button);
}
// Reset after 2 seconds
setTimeout(() => {
icon.className = originalClass;
button.classList.remove('btn-success', 'btn-danger');
button.classList.add('btn-outline-secondary');
button.setAttribute('title', originalTitle);
// Update tooltip again
const tooltip = bootstrap.Tooltip.getInstance(button);
if (tooltip) {
tooltip.dispose();
new bootstrap.Tooltip(button);
}
}, 2000);
}
</script>
</body>
</html>
+9 -3
View File
@@ -41,13 +41,16 @@ jsonpath-ng~=1.5.3
# Notification library
apprise==1.9.3
# - Needed for apprise/spush, and maybe others? hopefully doesnt trigger a rust compile.
# - Requires extra wheel for rPi, adds build time for arm/v8 which is not in piwheels
# Pinned to 43.0.1 for ARM compatibility (45.x may not have pre-built ARM wheels)
# Also pinned because dependabot wants specific versions
cryptography==44.0.1
# apprise mqtt https://github.com/dgtlmoon/changedetection.io/issues/315
# use any version other than 2.0.x due to https://github.com/eclipse/paho.mqtt.python/issues/814
paho-mqtt!=2.0.*
# Requires extra wheel for rPi
#cryptography~=42.0.8
# Used for CSS filtering
beautifulsoup4>=4.0.0
@@ -89,6 +92,9 @@ pytest-flask ~=1.2
# Anything 4.0 and up but not 5.0
jsonschema ~= 4.0
# OpenAPI validation support
openapi-core[flask] >= 0.19.0
loguru