Compare commits
	
		
			29 Commits
		
	
	
		
			ui-html-va
			...
			pyppeteer-
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 30001980d0 | ||
|   | ff33ca48c1 | ||
|   | 92084b4b00 | ||
|   | d58a71cffc | ||
|   | 036b006226 | ||
|   | f29f89d078 | ||
|   | 289f118581 | ||
|   | 10b2bbea83 | ||
|   | 32d110b92f | ||
|   | 860a5f5c1a | ||
|   | 70a18ee4b5 | ||
|   | 73189672c3 | ||
|   | 7e7d5dc383 | ||
|   | 1c2cfc37aa | ||
|   | 0634fe021d | ||
|   | 04934b6b3b | ||
|   | ff00417bc5 | ||
|   | 849c5b2293 | ||
|   | 4bf560256b | ||
|   | 7903b03a0c | ||
|   | 5e7c0880c1 | ||
|   | 957aef4ff3 | ||
|   | 8e9a83d8f4 | ||
|   | 5961838143 | ||
|   | 8cf4a8128b | ||
|   | 24c3bfe5ad | ||
|   | bdd9760f3c | ||
|   | e37467f649 | ||
|   | d42fdf0257 | 
							
								
								
									
										6
									
								
								.github/workflows/pypi-release.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -45,8 +45,12 @@ jobs: | ||||
|     - name: Test that the basic pip built package runs without error | ||||
|       run: | | ||||
|         set -ex | ||||
|         pip3 install dist/changedetection.io*.whl | ||||
|         ls -alR  | ||||
|          | ||||
|         # Find and install the first .whl file | ||||
|         find dist -type f -name "*.whl" -exec pip3 install {} \; -quit | ||||
|         changedetection.io -d /tmp -p 10000 & | ||||
|          | ||||
|         sleep 3 | ||||
|         curl --retry-connrefused --retry 6 http://127.0.0.1:10000/static/styles/pure-min.css >/dev/null | ||||
|         curl --retry-connrefused --retry 6 http://127.0.0.1:10000/ >/dev/null | ||||
|   | ||||
| @@ -2,6 +2,7 @@ recursive-include changedetectionio/api * | ||||
| recursive-include changedetectionio/apprise_plugin * | ||||
| recursive-include changedetectionio/blueprint * | ||||
| recursive-include changedetectionio/content_fetchers * | ||||
| recursive-include changedetectionio/conditions * | ||||
| recursive-include changedetectionio/model * | ||||
| recursive-include changedetectionio/processors * | ||||
| recursive-include changedetectionio/static * | ||||
|   | ||||
| @@ -2,7 +2,7 @@ | ||||
|  | ||||
| # Read more https://github.com/dgtlmoon/changedetection.io/wiki | ||||
|  | ||||
| __version__ = '0.49.1' | ||||
| __version__ = '0.49.7' | ||||
|  | ||||
| from changedetectionio.strtobool import strtobool | ||||
| from json.decoder import JSONDecodeError | ||||
|   | ||||
							
								
								
									
										62
									
								
								changedetectionio/api/Import.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,62 @@ | ||||
| import os | ||||
| from changedetectionio.strtobool import strtobool | ||||
| from flask_restful import abort, Resource | ||||
| from flask import request | ||||
| import validators | ||||
| from . import auth | ||||
|  | ||||
|  | ||||
| class Import(Resource): | ||||
|     def __init__(self, **kwargs): | ||||
|         # datastore is a black box dependency | ||||
|         self.datastore = kwargs['datastore'] | ||||
|  | ||||
|     @auth.check_token | ||||
|     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 Watch | ||||
|         @apiSuccess (200) {List} OK List of watch UUIDs added | ||||
|         @apiSuccess (500) {String} ERR Some other error | ||||
|         """ | ||||
|  | ||||
|         extras = {} | ||||
|  | ||||
|         if request.args.get('proxy'): | ||||
|             plist = self.datastore.proxy_list | ||||
|             if not request.args.get('proxy') in plist: | ||||
|                 return "Invalid proxy choice, currently supported proxies are '{}'".format(', '.join(plist)), 400 | ||||
|             else: | ||||
|                 extras['proxy'] = request.args.get('proxy') | ||||
|  | ||||
|         dedupe = strtobool(request.args.get('dedupe', 'true')) | ||||
|  | ||||
|         tags = request.args.get('tag') | ||||
|         tag_uuids = request.args.get('tag_uuids') | ||||
|  | ||||
|         if tag_uuids: | ||||
|             tag_uuids = tag_uuids.split(',') | ||||
|  | ||||
|         urls = request.get_data().decode('utf8').splitlines() | ||||
|         added = [] | ||||
|         allow_simplehost = not strtobool(os.getenv('BLOCK_SIMPLEHOSTS', 'False')) | ||||
|         for url in urls: | ||||
|             url = url.strip() | ||||
|             if not len(url): | ||||
|                 continue | ||||
|  | ||||
|             # If hosts that only contain alphanumerics are allowed ("localhost" for example) | ||||
|             if not validators.url(url, simple_host=allow_simplehost): | ||||
|                 return f"Invalid or unsupported URL - {url}", 400 | ||||
|  | ||||
|             if dedupe and self.datastore.url_exists(url): | ||||
|                 continue | ||||
|  | ||||
|             new_uuid = self.datastore.add_watch(url=url, extras=extras, tag=tags, tag_uuids=tag_uuids) | ||||
|             added.append(new_uuid) | ||||
|  | ||||
|         return added | ||||
							
								
								
									
										54
									
								
								changedetectionio/api/SystemInfo.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,54 @@ | ||||
| from flask_restful import Resource | ||||
| from . import auth | ||||
|  | ||||
|  | ||||
| class SystemInfo(Resource): | ||||
|     def __init__(self, **kwargs): | ||||
|         # datastore is a black box dependency | ||||
|         self.datastore = kwargs['datastore'] | ||||
|         self.update_q = kwargs['update_q'] | ||||
|  | ||||
|     @auth.check_token | ||||
|     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 | ||||
|         """ | ||||
|         import time | ||||
|         overdue_watches = [] | ||||
|  | ||||
|         # Check all watches and report which have not been checked but should have been | ||||
|  | ||||
|         for uuid, watch in self.datastore.data.get('watching', {}).items(): | ||||
|             # see if now - last_checked is greater than the time that should have been | ||||
|             # this is not super accurate (maybe they just edited it) but better than nothing | ||||
|             t = watch.threshold_seconds() | ||||
|             if not t: | ||||
|                 # Use the system wide default | ||||
|                 t = self.datastore.threshold_seconds | ||||
|  | ||||
|             time_since_check = time.time() - watch.get('last_checked') | ||||
|  | ||||
|             # Allow 5 minutes of grace time before we decide it's overdue | ||||
|             if time_since_check - (5 * 60) > t: | ||||
|                 overdue_watches.append(uuid) | ||||
|         from changedetectionio import __version__ as main_version | ||||
|         return { | ||||
|                    'queue_size': self.update_q.qsize(), | ||||
|                    'overdue_watches': overdue_watches, | ||||
|                    'uptime': round(time.time() - self.datastore.start_time, 2), | ||||
|                    'watch_count': len(self.datastore.data.get('watching', {})), | ||||
|                    'version': main_version | ||||
|                }, 200 | ||||
							
								
								
									
										156
									
								
								changedetectionio/api/Tags.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,156 @@ | ||||
| from flask_expects_json import expects_json | ||||
| from flask_restful import abort, Resource | ||||
| from flask import request | ||||
| from . import auth | ||||
|  | ||||
| # Import schemas from __init__.py | ||||
| from . import schema_tag, schema_create_tag, schema_update_tag | ||||
|  | ||||
|  | ||||
| class Tag(Resource): | ||||
|     def __init__(self, **kwargs): | ||||
|         # datastore is a black box dependency | ||||
|         self.datastore = kwargs['datastore'] | ||||
|  | ||||
|     # Get information about a single tag | ||||
|     # curl http://localhost:5000/api/v1/tag/<string:uuid> | ||||
|     @auth.check_token | ||||
|     def get(self, uuid): | ||||
|         """ | ||||
|         @api {get} /api/v1/tag/:uuid Single tag - get data or toggle notification muting. | ||||
|         @apiDescription Retrieve tag information and set notification_muted status | ||||
|         @apiExample {curl} Example usage: | ||||
|             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" | ||||
|         @apiName Tag | ||||
|         @apiGroup Tag | ||||
|         @apiParam {uuid} uuid Tag unique ID. | ||||
|         @apiQuery {String} [muted] =`muted` or =`unmuted` , Sets the MUTE NOTIFICATIONS state | ||||
|         @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 | ||||
|         """ | ||||
|         from copy import deepcopy | ||||
|         tag = deepcopy(self.datastore.data['settings']['application']['tags'].get(uuid)) | ||||
|         if not tag: | ||||
|             abort(404, message=f'No tag exists with the UUID of {uuid}') | ||||
|  | ||||
|         if request.args.get('muted', '') == 'muted': | ||||
|             self.datastore.data['settings']['application']['tags'][uuid]['notification_muted'] = True | ||||
|             return "OK", 200 | ||||
|         elif request.args.get('muted', '') == 'unmuted': | ||||
|             self.datastore.data['settings']['application']['tags'][uuid]['notification_muted'] = False | ||||
|             return "OK", 200 | ||||
|  | ||||
|         return tag | ||||
|  | ||||
|     @auth.check_token | ||||
|     def delete(self, uuid): | ||||
|         """ | ||||
|         @api {delete} /api/v1/tag/:uuid Delete a tag and remove it from all watches | ||||
|         @apiExample {curl} Example usage: | ||||
|             curl http://localhost:5000/api/v1/tag/cc0cfffa-f449-477b-83ea-0caafd1dc091 -X DELETE -H"x-api-key:813031b16330fe25e3780cf0325daa45" | ||||
|         @apiParam {uuid} uuid Tag unique ID. | ||||
|         @apiName DeleteTag | ||||
|         @apiGroup Tag | ||||
|         @apiSuccess (200) {String} OK Was deleted | ||||
|         """ | ||||
|         if not self.datastore.data['settings']['application']['tags'].get(uuid): | ||||
|             abort(400, message='No tag exists with the UUID of {}'.format(uuid)) | ||||
|  | ||||
|         # Delete the tag, and any tag reference | ||||
|         del self.datastore.data['settings']['application']['tags'][uuid] | ||||
|          | ||||
|         # Remove tag from all watches | ||||
|         for watch_uuid, watch in self.datastore.data['watching'].items(): | ||||
|             if watch.get('tags') and uuid in watch['tags']: | ||||
|                 watch['tags'].remove(uuid) | ||||
|  | ||||
|         return 'OK', 204 | ||||
|  | ||||
|     @auth.check_token | ||||
|     @expects_json(schema_update_tag) | ||||
|     def put(self, uuid): | ||||
|         """ | ||||
|         @api {put} /api/v1/tag/:uuid Update tag information | ||||
|         @apiExample {curl} Example usage: | ||||
|             Update (PUT) | ||||
|             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"}' | ||||
|  | ||||
|         @apiDescription Updates an existing tag using JSON | ||||
|         @apiParam {uuid} uuid Tag unique ID. | ||||
|         @apiName UpdateTag | ||||
|         @apiGroup Tag | ||||
|         @apiSuccess (200) {String} OK Was updated | ||||
|         @apiSuccess (500) {String} ERR Some other error | ||||
|         """ | ||||
|         tag = self.datastore.data['settings']['application']['tags'].get(uuid) | ||||
|         if not tag: | ||||
|             abort(404, message='No tag exists with the UUID of {}'.format(uuid)) | ||||
|  | ||||
|         tag.update(request.json) | ||||
|         self.datastore.needs_write_urgent = True | ||||
|  | ||||
|         return "OK", 200 | ||||
|  | ||||
|  | ||||
|     @auth.check_token | ||||
|     # Only cares for {'title': 'xxxx'} | ||||
|     def post(self): | ||||
|         """ | ||||
|         @api {post} /api/v1/watch Create a single tag | ||||
|         @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 Tag | ||||
|         @apiSuccess (200) {String} OK Was created | ||||
|         @apiSuccess (500) {String} ERR Some other error | ||||
|         """ | ||||
|  | ||||
|         json_data = request.get_json() | ||||
|         title = json_data.get("title",'').strip() | ||||
|  | ||||
|  | ||||
|         new_uuid = self.datastore.add_tag(title=title) | ||||
|         if new_uuid: | ||||
|             return {'uuid': new_uuid}, 201 | ||||
|         else: | ||||
|             return "Invalid or unsupported tag", 400 | ||||
|  | ||||
| class Tags(Resource): | ||||
|     def __init__(self, **kwargs): | ||||
|         # datastore is a black box dependency | ||||
|         self.datastore = kwargs['datastore'] | ||||
|  | ||||
|     @auth.check_token | ||||
|     def get(self): | ||||
|         """ | ||||
|         @api {get} /api/v1/tags List tags | ||||
|         @apiDescription Return list of available tags | ||||
|         @apiExample {curl} Example usage: | ||||
|             curl http://localhost:5000/api/v1/tags -H"x-api-key:813031b16330fe25e3780cf0325daa45" | ||||
|             { | ||||
|                 "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 Tag Management | ||||
|         @apiSuccess (200) {String} OK JSON dict | ||||
|         """ | ||||
|         result = {} | ||||
|         for uuid, tag in self.datastore.data['settings']['application']['tags'].items(): | ||||
|             result[uuid] = { | ||||
|                 'date_created': tag.get('date_created', 0), | ||||
|                 'notification_muted': tag.get('notification_muted', False), | ||||
|                 'title': tag.get('title', ''), | ||||
|                 'uuid': tag.get('uuid') | ||||
|             } | ||||
|  | ||||
|         return result, 200 | ||||
| @@ -9,20 +9,9 @@ import validators | ||||
| from . import auth | ||||
| import copy | ||||
| 
 | ||||
| # See docs/README.md for rebuilding the docs/apidoc information | ||||
| # Import schemas from __init__.py | ||||
| from . import schema, schema_create_watch, schema_update_watch | ||||
| 
 | ||||
| from . import api_schema | ||||
| from ..model import watch_base | ||||
| 
 | ||||
| # Build a JSON Schema atleast partially based on our Watch model | ||||
| watch_base_config = watch_base() | ||||
| schema = api_schema.build_watch_json_schema(watch_base_config) | ||||
| 
 | ||||
| schema_create_watch = copy.deepcopy(schema) | ||||
| schema_create_watch['required'] = ['url'] | ||||
| 
 | ||||
| schema_update_watch = copy.deepcopy(schema) | ||||
| schema_update_watch['additionalProperties'] = False | ||||
| 
 | ||||
| class Watch(Resource): | ||||
|     def __init__(self, **kwargs): | ||||
| @@ -285,8 +274,6 @@ class CreateWatch(Resource): | ||||
|         list = {} | ||||
| 
 | ||||
|         tag_limit = request.args.get('tag', '').lower() | ||||
| 
 | ||||
| 
 | ||||
|         for uuid, watch in self.datastore.data['watching'].items(): | ||||
|             # Watch tags by name (replace the other calls?) | ||||
|             tags = self.datastore.get_all_tags_for_watch(uuid=uuid) | ||||
| @@ -307,110 +294,4 @@ class CreateWatch(Resource): | ||||
|                 self.update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid})) | ||||
|             return {'status': "OK"}, 200 | ||||
| 
 | ||||
|         return list, 200 | ||||
| 
 | ||||
| class Import(Resource): | ||||
|     def __init__(self, **kwargs): | ||||
|         # datastore is a black box dependency | ||||
|         self.datastore = kwargs['datastore'] | ||||
| 
 | ||||
|     @auth.check_token | ||||
|     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 Watch | ||||
|         @apiSuccess (200) {List} OK List of watch UUIDs added | ||||
|         @apiSuccess (500) {String} ERR Some other error | ||||
|         """ | ||||
| 
 | ||||
|         extras = {} | ||||
| 
 | ||||
|         if request.args.get('proxy'): | ||||
|             plist = self.datastore.proxy_list | ||||
|             if not request.args.get('proxy') in plist: | ||||
|                 return "Invalid proxy choice, currently supported proxies are '{}'".format(', '.join(plist)), 400 | ||||
|             else: | ||||
|                 extras['proxy'] = request.args.get('proxy') | ||||
| 
 | ||||
|         dedupe = strtobool(request.args.get('dedupe', 'true')) | ||||
| 
 | ||||
|         tags = request.args.get('tag') | ||||
|         tag_uuids = request.args.get('tag_uuids') | ||||
| 
 | ||||
|         if tag_uuids: | ||||
|             tag_uuids = tag_uuids.split(',') | ||||
| 
 | ||||
|         urls = request.get_data().decode('utf8').splitlines() | ||||
|         added = [] | ||||
|         allow_simplehost = not strtobool(os.getenv('BLOCK_SIMPLEHOSTS', 'False')) | ||||
|         for url in urls: | ||||
|             url = url.strip() | ||||
|             if not len(url): | ||||
|                 continue | ||||
| 
 | ||||
|             # If hosts that only contain alphanumerics are allowed ("localhost" for example) | ||||
|             if not validators.url(url, simple_host=allow_simplehost): | ||||
|                 return f"Invalid or unsupported URL - {url}", 400 | ||||
| 
 | ||||
|             if dedupe and self.datastore.url_exists(url): | ||||
|                 continue | ||||
| 
 | ||||
|             new_uuid = self.datastore.add_watch(url=url, extras=extras, tag=tags, tag_uuids=tag_uuids) | ||||
|             added.append(new_uuid) | ||||
| 
 | ||||
|         return added | ||||
| 
 | ||||
| class SystemInfo(Resource): | ||||
|     def __init__(self, **kwargs): | ||||
|         # datastore is a black box dependency | ||||
|         self.datastore = kwargs['datastore'] | ||||
|         self.update_q = kwargs['update_q'] | ||||
| 
 | ||||
|     @auth.check_token | ||||
|     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 | ||||
|         """ | ||||
|         import time | ||||
|         overdue_watches = [] | ||||
| 
 | ||||
|         # Check all watches and report which have not been checked but should have been | ||||
| 
 | ||||
|         for uuid, watch in self.datastore.data.get('watching', {}).items(): | ||||
|             # see if now - last_checked is greater than the time that should have been | ||||
|             # this is not super accurate (maybe they just edited it) but better than nothing | ||||
|             t = watch.threshold_seconds() | ||||
|             if not t: | ||||
|                 # Use the system wide default | ||||
|                 t = self.datastore.threshold_seconds | ||||
| 
 | ||||
|             time_since_check = time.time() - watch.get('last_checked') | ||||
| 
 | ||||
|             # Allow 5 minutes of grace time before we decide it's overdue | ||||
|             if time_since_check - (5 * 60) > t: | ||||
|                 overdue_watches.append(uuid) | ||||
|         from changedetectionio import __version__ as main_version | ||||
|         return { | ||||
|                    'queue_size': self.update_q.qsize(), | ||||
|                    'overdue_watches': overdue_watches, | ||||
|                    'uptime': round(time.time() - self.datastore.start_time, 2), | ||||
|                    'watch_count': len(self.datastore.data.get('watching', {})), | ||||
|                    'version': main_version | ||||
|                }, 200 | ||||
|         return list, 200 | ||||
| @@ -0,0 +1,26 @@ | ||||
| import copy | ||||
| from . import api_schema | ||||
| from ..model import watch_base | ||||
|  | ||||
| # Build a JSON Schema atleast partially based on our Watch model | ||||
| watch_base_config = watch_base() | ||||
| schema = api_schema.build_watch_json_schema(watch_base_config) | ||||
|  | ||||
| schema_create_watch = copy.deepcopy(schema) | ||||
| schema_create_watch['required'] = ['url'] | ||||
|  | ||||
| schema_update_watch = copy.deepcopy(schema) | ||||
| schema_update_watch['additionalProperties'] = False | ||||
|  | ||||
| # Tag schema is also based on watch_base since Tag inherits from it | ||||
| schema_tag = copy.deepcopy(schema) | ||||
| schema_create_tag = copy.deepcopy(schema_tag) | ||||
| schema_create_tag['required'] = ['title'] | ||||
| schema_update_tag = copy.deepcopy(schema_tag) | ||||
| schema_update_tag['additionalProperties'] = False | ||||
|  | ||||
| # Import all API resources | ||||
| from .Watch import Watch, WatchHistory, WatchSingleHistory, CreateWatch | ||||
| from .Tags import Tags, Tag | ||||
| from .Import import Import | ||||
| from .SystemInfo import SystemInfo | ||||
|   | ||||
| @@ -11,22 +11,14 @@ def check_token(f): | ||||
|         datastore = args[0].datastore | ||||
|  | ||||
|         config_api_token_enabled = datastore.data['settings']['application'].get('api_access_token_enabled') | ||||
|         if not config_api_token_enabled: | ||||
|             return | ||||
|  | ||||
|         try: | ||||
|             api_key_header = request.headers['x-api-key'] | ||||
|         except KeyError: | ||||
|             return make_response( | ||||
|                 jsonify("No authorization x-api-key header."), 403 | ||||
|             ) | ||||
|  | ||||
|         config_api_token = datastore.data['settings']['application'].get('api_access_token') | ||||
|  | ||||
|         if api_key_header != config_api_token: | ||||
|             return make_response( | ||||
|                 jsonify("Invalid access - API key invalid."), 403 | ||||
|             ) | ||||
|         # config_api_token_enabled - a UI option in settings if access should obey the key or not | ||||
|         if config_api_token_enabled: | ||||
|             if request.headers.get('x-api-key') != config_api_token: | ||||
|                 return make_response( | ||||
|                     jsonify("Invalid access - API key invalid."), 403 | ||||
|                 ) | ||||
|  | ||||
|         return f(*args, **kwargs) | ||||
|  | ||||
|   | ||||
							
								
								
									
										36
									
								
								changedetectionio/auth_decorator.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,36 @@ | ||||
| import os | ||||
| from functools import wraps | ||||
| from flask import current_app, redirect, request | ||||
| from loguru import logger | ||||
|  | ||||
| def login_optionally_required(func): | ||||
|     """ | ||||
|     If password authentication is enabled, verify the user is logged in. | ||||
|     To be used as a decorator for routes that should optionally require login. | ||||
|     This version is blueprint-friendly as it uses current_app instead of directly accessing app. | ||||
|     """ | ||||
|     @wraps(func) | ||||
|     def decorated_view(*args, **kwargs): | ||||
|         from flask import current_app | ||||
|         import flask_login | ||||
|         from flask_login import current_user | ||||
|  | ||||
|         # Access datastore through the app config | ||||
|         datastore = current_app.config['DATASTORE'] | ||||
|         has_password_enabled = datastore.data['settings']['application'].get('password') or os.getenv("SALTED_PASS", False) | ||||
|  | ||||
|         # Permitted | ||||
|         if request.endpoint and 'static_content' in request.endpoint and request.view_args and request.view_args.get('group') == 'styles': | ||||
|             return func(*args, **kwargs) | ||||
|         # Permitted | ||||
|         elif request.endpoint and 'diff_history_page' in request.endpoint and datastore.data['settings']['application'].get('shared_diff_access'): | ||||
|             return func(*args, **kwargs) | ||||
|         elif request.method in flask_login.config.EXEMPT_METHODS: | ||||
|             return func(*args, **kwargs) | ||||
|         elif current_app.config.get('LOGIN_DISABLED'): | ||||
|             return func(*args, **kwargs) | ||||
|         elif has_password_enabled and not current_user.is_authenticated: | ||||
|             return current_app.login_manager.unauthorized() | ||||
|  | ||||
|         return func(*args, **kwargs) | ||||
|     return decorated_view | ||||
| @@ -22,7 +22,10 @@ from loguru import logger | ||||
|  | ||||
| browsersteps_sessions = {} | ||||
| io_interface_context = None | ||||
|  | ||||
| import json | ||||
| import base64 | ||||
| import hashlib | ||||
| from flask import Response | ||||
|  | ||||
| def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|     browser_steps_blueprint = Blueprint('browser_steps', __name__, template_folder="templates") | ||||
| @@ -85,7 +88,7 @@ def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|         browsersteps_start_session['browserstepper'] = browser_steps.browsersteps_live_ui( | ||||
|             playwright_browser=browsersteps_start_session['browser'], | ||||
|             proxy=proxy, | ||||
|             start_url=datastore.data['watching'][watch_uuid].get('url'), | ||||
|             start_url=datastore.data['watching'][watch_uuid].link, | ||||
|             headers=datastore.data['watching'][watch_uuid].get('headers') | ||||
|         ) | ||||
|  | ||||
| @@ -160,14 +163,13 @@ def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|         if not browsersteps_sessions.get(browsersteps_session_id): | ||||
|             return make_response('No session exists under that ID', 500) | ||||
|  | ||||
|  | ||||
|         is_last_step = False | ||||
|         # Actions - step/apply/etc, do the thing and return state | ||||
|         if request.method == 'POST': | ||||
|             # @todo - should always be an existing session | ||||
|             step_operation = request.form.get('operation') | ||||
|             step_selector = request.form.get('selector') | ||||
|             step_optional_value = request.form.get('optional_value') | ||||
|             step_n = int(request.form.get('step_n')) | ||||
|             is_last_step = strtobool(request.form.get('is_last_step')) | ||||
|  | ||||
|             # @todo try.. accept.. nice errors not popups.. | ||||
| @@ -182,16 +184,6 @@ def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|                 # Try to find something of value to give back to the user | ||||
|                 return make_response(str(e).splitlines()[0], 401) | ||||
|  | ||||
|             # Get visual selector ready/update its data (also use the current filter info from the page?) | ||||
|             # When the last 'apply' button was pressed | ||||
|             # @todo this adds overhead because the xpath selection is happening twice | ||||
|             u = browsersteps_sessions[browsersteps_session_id]['browserstepper'].page.url | ||||
|             if is_last_step and u: | ||||
|                 (screenshot, xpath_data) = browsersteps_sessions[browsersteps_session_id]['browserstepper'].request_visualselector_data() | ||||
|                 watch = datastore.data['watching'].get(uuid) | ||||
|                 if watch: | ||||
|                     watch.save_screenshot(screenshot=screenshot) | ||||
|                     watch.save_xpath_data(data=xpath_data) | ||||
|  | ||||
| #        if not this_session.page: | ||||
| #            cleanup_playwright_session() | ||||
| @@ -199,31 +191,35 @@ def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|  | ||||
|         # Screenshots and other info only needed on requesting a step (POST) | ||||
|         try: | ||||
|             state = browsersteps_sessions[browsersteps_session_id]['browserstepper'].get_current_state() | ||||
|             (screenshot, xpath_data) = browsersteps_sessions[browsersteps_session_id]['browserstepper'].get_current_state() | ||||
|             if is_last_step: | ||||
|                 watch = datastore.data['watching'].get(uuid) | ||||
|                 u = browsersteps_sessions[browsersteps_session_id]['browserstepper'].page.url | ||||
|                 if watch and u: | ||||
|                     watch.save_screenshot(screenshot=screenshot) | ||||
|                     watch.save_xpath_data(data=xpath_data) | ||||
|  | ||||
|         except playwright._impl._api_types.Error as e: | ||||
|             return make_response("Browser session ran out of time :( Please reload this page."+str(e), 401) | ||||
|         except Exception as e: | ||||
|             return make_response("Error fetching screenshot and element data - " + str(e), 401) | ||||
|  | ||||
|         # Use send_file() which is way faster than read/write loop on bytes | ||||
|         import json | ||||
|         from tempfile import mkstemp | ||||
|         from flask import send_file | ||||
|         tmp_fd, tmp_file = mkstemp(text=True, suffix=".json", prefix="changedetectionio-") | ||||
|         # SEND THIS BACK TO THE BROWSER | ||||
|  | ||||
|         output = json.dumps({'screenshot': "data:image/jpeg;base64,{}".format( | ||||
|             base64.b64encode(state[0]).decode('ascii')), | ||||
|             'xpath_data': state[1], | ||||
|             'session_age_start': browsersteps_sessions[browsersteps_session_id]['browserstepper'].age_start, | ||||
|             'browser_time_remaining': round(remaining) | ||||
|         }) | ||||
|         output = { | ||||
|             "screenshot": f"data:image/jpeg;base64,{base64.b64encode(screenshot).decode('ascii')}", | ||||
|             "xpath_data": xpath_data, | ||||
|             "session_age_start": browsersteps_sessions[browsersteps_session_id]['browserstepper'].age_start, | ||||
|             "browser_time_remaining": round(remaining) | ||||
|         } | ||||
|         json_data = json.dumps(output) | ||||
|  | ||||
|         with os.fdopen(tmp_fd, 'w') as f: | ||||
|             f.write(output) | ||||
|         # Generate an ETag (hash of the response body) | ||||
|         etag_hash = hashlib.md5(json_data.encode('utf-8')).hexdigest() | ||||
|  | ||||
|         response = make_response(send_file(path_or_file=tmp_file, | ||||
|                                            mimetype='application/json; charset=UTF-8', | ||||
|                                            etag=True)) | ||||
|         # No longer needed | ||||
|         os.unlink(tmp_file) | ||||
|         # Create the response with ETag | ||||
|         response = Response(json_data, mimetype="application/json; charset=UTF-8") | ||||
|         response.set_etag(etag_hash) | ||||
|  | ||||
|         return response | ||||
|  | ||||
|   | ||||
| @@ -1,14 +1,15 @@ | ||||
| #!/usr/bin/env python3 | ||||
|  | ||||
| import os | ||||
| import time | ||||
| import re | ||||
| from random import randint | ||||
| from loguru import logger | ||||
|  | ||||
| from changedetectionio.content_fetchers.helpers import capture_stitched_together_full_page, SCREENSHOT_SIZE_STITCH_THRESHOLD | ||||
| from changedetectionio.content_fetchers.base import manage_user_agent | ||||
| from changedetectionio.safe_jinja import render as jinja_render | ||||
|  | ||||
|  | ||||
|  | ||||
| # Two flags, tell the JS which of the "Selector" or "Value" field should be enabled in the front end | ||||
| # 0- off, 1- on | ||||
| browser_step_ui_config = {'Choose one': '0 0', | ||||
| @@ -31,6 +32,7 @@ browser_step_ui_config = {'Choose one': '0 0', | ||||
| #                          'Extract text and use as filter': '1 0', | ||||
|                           'Goto site': '0 0', | ||||
|                           'Goto URL': '0 1', | ||||
|                           'Make all child elements visible': '1 0', | ||||
|                           'Press Enter': '0 0', | ||||
|                           'Select by label': '1 1', | ||||
|                           'Scroll down': '0 0', | ||||
| @@ -38,6 +40,7 @@ browser_step_ui_config = {'Choose one': '0 0', | ||||
|                           'Wait for seconds': '0 1', | ||||
|                           'Wait for text': '0 1', | ||||
|                           'Wait for text in element': '1 1', | ||||
|                           'Remove elements': '1 0', | ||||
|                           #                          'Press Page Down': '0 0', | ||||
|                           #                          'Press Page Up': '0 0', | ||||
|                           # weird bug, come back to it later | ||||
| @@ -192,6 +195,24 @@ class steppable_browser_interface(): | ||||
|     def action_uncheck_checkbox(self, selector, value): | ||||
|         self.page.locator(selector).uncheck(timeout=self.action_timeout) | ||||
|  | ||||
|     def action_remove_elements(self, selector, value): | ||||
|         """Removes all elements matching the given selector from the DOM.""" | ||||
|         self.page.locator(selector).evaluate_all("els => els.forEach(el => el.remove())") | ||||
|  | ||||
|     def action_make_all_child_elements_visible(self, selector, value): | ||||
|         """Recursively makes all child elements inside the given selector fully visible.""" | ||||
|         self.page.locator(selector).locator("*").evaluate_all(""" | ||||
|             els => els.forEach(el => { | ||||
|                 el.style.display = 'block';   // Forces it to be displayed | ||||
|                 el.style.visibility = 'visible';   // Ensures it's not hidden | ||||
|                 el.style.opacity = '1';   // Fully opaque | ||||
|                 el.style.position = 'relative';   // Avoids 'absolute' hiding | ||||
|                 el.style.height = 'auto';   // Expands collapsed elements | ||||
|                 el.style.width = 'auto';   // Ensures full visibility | ||||
|                 el.removeAttribute('hidden');   // Removes hidden attribute | ||||
|                 el.classList.remove('hidden', 'd-none');  // Removes common CSS hidden classes | ||||
|             }) | ||||
|         """) | ||||
|  | ||||
| # Responsible for maintaining a live 'context' with the chrome CDP | ||||
| # @todo - how long do contexts live for anyway? | ||||
| @@ -259,6 +280,7 @@ class browsersteps_live_ui(steppable_browser_interface): | ||||
|         logger.debug(f"Time to browser setup {time.time()-now:.2f}s") | ||||
|         self.page.wait_for_timeout(1 * 1000) | ||||
|  | ||||
|  | ||||
|     def mark_as_closed(self): | ||||
|         logger.debug("Page closed, cleaning up..") | ||||
|  | ||||
| @@ -276,39 +298,30 @@ class browsersteps_live_ui(steppable_browser_interface): | ||||
|         now = time.time() | ||||
|         self.page.wait_for_timeout(1 * 1000) | ||||
|  | ||||
|         # The actual screenshot | ||||
|         screenshot = self.page.screenshot(type='jpeg', full_page=True, quality=40) | ||||
|  | ||||
|         full_height = self.page.evaluate("document.documentElement.scrollHeight") | ||||
|  | ||||
|         if full_height >= SCREENSHOT_SIZE_STITCH_THRESHOLD: | ||||
|             logger.warning(f"Page full Height: {full_height}px longer than {SCREENSHOT_SIZE_STITCH_THRESHOLD}px, using 'stitched screenshot method'.") | ||||
|             screenshot = capture_stitched_together_full_page(self.page) | ||||
|         else: | ||||
|             screenshot = self.page.screenshot(type='jpeg', full_page=True, quality=40) | ||||
|  | ||||
|         logger.debug(f"Time to get screenshot from browser {time.time() - now:.2f}s") | ||||
|  | ||||
|         now = time.time() | ||||
|         self.page.evaluate("var include_filters=''") | ||||
|         # Go find the interactive elements | ||||
|         # @todo in the future, something smarter that can scan for elements with .click/focus etc event handlers? | ||||
|         elements = 'a,button,input,select,textarea,i,th,td,p,li,h1,h2,h3,h4,div,span' | ||||
|         xpath_element_js = xpath_element_js.replace('%ELEMENTS%', elements) | ||||
|  | ||||
|         xpath_data = self.page.evaluate("async () => {" + xpath_element_js + "}") | ||||
|         # So the JS will find the smallest one first | ||||
|         xpath_data['size_pos'] = sorted(xpath_data['size_pos'], key=lambda k: k['width'] * k['height'], reverse=True) | ||||
|         logger.debug(f"Time to complete get_current_state of browser {time.time()-now:.2f}s") | ||||
|         # except | ||||
|         logger.debug(f"Time to scrape xpath element data in browser {time.time()-now:.2f}s") | ||||
|  | ||||
|         # playwright._impl._api_types.Error: Browser closed. | ||||
|         # @todo show some countdown timer? | ||||
|         return (screenshot, xpath_data) | ||||
|  | ||||
|     def request_visualselector_data(self): | ||||
|         """ | ||||
|         Does the same that the playwright operation in content_fetcher does | ||||
|         This is used to just bump the VisualSelector data so it' ready to go if they click on the tab | ||||
|         @todo refactor and remove duplicate code, add include_filters | ||||
|         :param xpath_data: | ||||
|         :param screenshot: | ||||
|         :param current_include_filters: | ||||
|         :return: | ||||
|         """ | ||||
|         import importlib.resources | ||||
|         self.page.evaluate("var include_filters=''") | ||||
|         xpath_element_js = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('xpath_element_scraper.js').read_text() | ||||
|         from changedetectionio.content_fetchers import visualselector_xpath_selectors | ||||
|         xpath_element_js = xpath_element_js.replace('%ELEMENTS%', visualselector_xpath_selectors) | ||||
|         xpath_data = self.page.evaluate("async () => {" + xpath_element_js + "}") | ||||
|         screenshot = self.page.screenshot(type='jpeg', full_page=True, quality=int(os.getenv("SCREENSHOT_QUALITY", 72))) | ||||
|  | ||||
|         return (screenshot, xpath_data) | ||||
|   | ||||
							
								
								
									
										74
									
								
								changedetectionio/blueprint/imports/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,74 @@ | ||||
| from flask import Blueprint, request, redirect, url_for, flash, render_template | ||||
| from changedetectionio.store import ChangeDetectionStore | ||||
| from changedetectionio.auth_decorator import login_optionally_required | ||||
| from changedetectionio.blueprint.imports.importer import ( | ||||
|     import_url_list,  | ||||
|     import_distill_io_json,  | ||||
|     import_xlsx_wachete,  | ||||
|     import_xlsx_custom | ||||
| ) | ||||
|  | ||||
| def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData): | ||||
|     import_blueprint = Blueprint('imports', __name__, template_folder="templates") | ||||
|      | ||||
|     @import_blueprint.route("/import", methods=['GET', 'POST']) | ||||
|     @login_optionally_required | ||||
|     def import_page(): | ||||
|         remaining_urls = [] | ||||
|         from changedetectionio import forms | ||||
|  | ||||
|         if request.method == 'POST': | ||||
|             # URL List import | ||||
|             if request.values.get('urls') and len(request.values.get('urls').strip()): | ||||
|                 # Import and push into the queue for immediate update check | ||||
|                 importer_handler = import_url_list() | ||||
|                 importer_handler.run(data=request.values.get('urls'), flash=flash, datastore=datastore, processor=request.values.get('processor', 'text_json_diff')) | ||||
|                 for uuid in importer_handler.new_uuids: | ||||
|                     update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid})) | ||||
|  | ||||
|                 if len(importer_handler.remaining_data) == 0: | ||||
|                     return redirect(url_for('index')) | ||||
|                 else: | ||||
|                     remaining_urls = importer_handler.remaining_data | ||||
|  | ||||
|             # Distill.io import | ||||
|             if request.values.get('distill-io') and len(request.values.get('distill-io').strip()): | ||||
|                 # Import and push into the queue for immediate update check | ||||
|                 d_importer = import_distill_io_json() | ||||
|                 d_importer.run(data=request.values.get('distill-io'), flash=flash, datastore=datastore) | ||||
|                 for uuid in d_importer.new_uuids: | ||||
|                     update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid})) | ||||
|  | ||||
|             # XLSX importer | ||||
|             if request.files and request.files.get('xlsx_file'): | ||||
|                 file = request.files['xlsx_file'] | ||||
|  | ||||
|                 if request.values.get('file_mapping') == 'wachete': | ||||
|                     w_importer = import_xlsx_wachete() | ||||
|                     w_importer.run(data=file, flash=flash, datastore=datastore) | ||||
|                 else: | ||||
|                     w_importer = import_xlsx_custom() | ||||
|                     # Building mapping of col # to col # type | ||||
|                     map = {} | ||||
|                     for i in range(10): | ||||
|                         c = request.values.get(f"custom_xlsx[col_{i}]") | ||||
|                         v = request.values.get(f"custom_xlsx[col_type_{i}]") | ||||
|                         if c and v: | ||||
|                             map[int(c)] = v | ||||
|  | ||||
|                     w_importer.import_profile = map | ||||
|                     w_importer.run(data=file, flash=flash, datastore=datastore) | ||||
|  | ||||
|                 for uuid in w_importer.new_uuids: | ||||
|                     update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid})) | ||||
|  | ||||
|         # Could be some remaining, or we could be on GET | ||||
|         form = forms.importForm(formdata=request.form if request.method == 'POST' else None) | ||||
|         output = render_template("import.html", | ||||
|                                 form=form, | ||||
|                                 import_url_list_remaining="\n".join(remaining_urls), | ||||
|                                 original_distill_json='' | ||||
|                                 ) | ||||
|         return output | ||||
|  | ||||
|     return import_blueprint | ||||
| @@ -1,6 +1,5 @@ | ||||
| from abc import ABC, abstractmethod | ||||
| from abc import abstractmethod | ||||
| import time | ||||
| import validators | ||||
| from wtforms import ValidationError | ||||
| from loguru import logger | ||||
| 
 | ||||
| @@ -241,7 +240,7 @@ class import_xlsx_custom(Importer): | ||||
|             return | ||||
| 
 | ||||
|         # @todo cehck atleast 2 rows, same in other method | ||||
|         from .forms import validate_url | ||||
|         from changedetectionio.forms import validate_url | ||||
|         row_i = 1 | ||||
| 
 | ||||
|         try: | ||||
| @@ -300,4 +299,4 @@ class import_xlsx_custom(Importer): | ||||
|             row_i += 1 | ||||
| 
 | ||||
|         flash( | ||||
|             "{} imported from custom .xlsx in {:.2f}s".format(len(self.new_uuids), time.time() - now)) | ||||
|             "{} imported from custom .xlsx in {:.2f}s".format(len(self.new_uuids), time.time() - now)) | ||||
| @@ -13,29 +13,27 @@ | ||||
|     </div> | ||||
| 
 | ||||
|     <div class="box-wrap inner"> | ||||
|         <form class="pure-form" action="{{url_for('import_page')}}" method="POST" enctype="multipart/form-data"> | ||||
|         <form class="pure-form" action="{{url_for('imports.import_page')}}" method="POST" enctype="multipart/form-data"> | ||||
|             <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> | ||||
|             <div class="tab-pane-inner" id="url-list"> | ||||
|                     <legend> | ||||
|                 <div class="pure-control-group"> | ||||
|                         Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma | ||||
|                         (,): | ||||
|                         <br> | ||||
|                         <code>https://example.com tag1, tag2, last tag</code> | ||||
|                         <br> | ||||
|                         <p><strong>Example:  </strong><code>https://example.com tag1, tag2, last tag</code></p> | ||||
|                         URLs which do not pass validation will stay in the textarea. | ||||
|                     </legend> | ||||
|                 </div> | ||||
|                 {{ render_field(form.processor, class="processor") }} | ||||
| 
 | ||||
|                  | ||||
|                 <div class="pure-control-group"> | ||||
|                     <textarea name="urls" class="pure-input-1-2" placeholder="https://" | ||||
|                               style="width: 100%; | ||||
|                                 font-family:monospace; | ||||
|                                 white-space: pre; | ||||
|                                 overflow-wrap: normal; | ||||
|                                 overflow-x: scroll;" rows="25">{{ import_url_list_remaining }}</textarea> | ||||
| 
 | ||||
| <div id="quick-watch-processor-type"> | ||||
| 
 | ||||
|                     </div> | ||||
|                  </div> | ||||
|                  <div id="quick-watch-processor-type"></div> | ||||
| 
 | ||||
|             </div> | ||||
| 
 | ||||
| @@ -43,7 +41,7 @@ | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
|                     <legend> | ||||
|                     <div class="pure-control-group"> | ||||
|                         Copy and Paste your Distill.io watch 'export' file, this should be a JSON file.<br> | ||||
|                         This is <i>experimental</i>, supported fields are <code>name</code>, <code>uri</code>, <code>tags</code>, <code>config:selections</code>, the rest (including <code>schedule</code>) are ignored. | ||||
|                         <br> | ||||
| @@ -51,7 +49,7 @@ | ||||
|                         How to export? <a href="https://distill.io/docs/web-monitor/how-export-and-import-monitors/">https://distill.io/docs/web-monitor/how-export-and-import-monitors/</a><br> | ||||
|                         Be sure to set your default fetcher to Chrome if required.<br> | ||||
|                         </p> | ||||
|                     </legend> | ||||
|                     </div> | ||||
| 
 | ||||
| 
 | ||||
|                     <textarea name="distill-io" class="pure-input-1-2" style="width: 100%; | ||||
| @@ -122,4 +120,4 @@ | ||||
|     </div> | ||||
| </div> | ||||
| 
 | ||||
| {% endblock %} | ||||
| {% endblock %} | ||||
							
								
								
									
										103
									
								
								changedetectionio/blueprint/rss/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,103 @@ | ||||
| import time | ||||
| import datetime | ||||
| import pytz | ||||
| from flask import Blueprint, make_response, request, url_for | ||||
| from loguru import logger | ||||
| from feedgen.feed import FeedGenerator | ||||
|  | ||||
| from changedetectionio.store import ChangeDetectionStore | ||||
| from changedetectionio.safe_jinja import render as jinja_render | ||||
|  | ||||
| def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|     rss_blueprint = Blueprint('rss', __name__) | ||||
|      | ||||
|     # Import the login decorator if needed | ||||
|     # from changedetectionio.auth_decorator import login_optionally_required | ||||
|  | ||||
|     @rss_blueprint.route("/", methods=['GET']) | ||||
|     def feed(): | ||||
|         now = time.time() | ||||
|         # Always requires token set | ||||
|         app_rss_token = datastore.data['settings']['application'].get('rss_access_token') | ||||
|         rss_url_token = request.args.get('token') | ||||
|         if rss_url_token != app_rss_token: | ||||
|             return "Access denied, bad token", 403 | ||||
|  | ||||
|         from changedetectionio import diff | ||||
|         limit_tag = request.args.get('tag', '').lower().strip() | ||||
|         # Be sure limit_tag is a uuid | ||||
|         for uuid, tag in datastore.data['settings']['application'].get('tags', {}).items(): | ||||
|             if limit_tag == tag.get('title', '').lower().strip(): | ||||
|                 limit_tag = uuid | ||||
|  | ||||
|         # Sort by last_changed and add the uuid which is usually the key.. | ||||
|         sorted_watches = [] | ||||
|  | ||||
|         # @todo needs a .itemsWithTag() or something - then we can use that in Jinaj2 and throw this away | ||||
|         for uuid, watch in datastore.data['watching'].items(): | ||||
|             # @todo tag notification_muted skip also (improve Watch model) | ||||
|             if datastore.data['settings']['application'].get('rss_hide_muted_watches') and watch.get('notification_muted'): | ||||
|                 continue | ||||
|             if limit_tag and not limit_tag in watch['tags']: | ||||
|                 continue | ||||
|             watch['uuid'] = uuid | ||||
|             sorted_watches.append(watch) | ||||
|  | ||||
|         sorted_watches.sort(key=lambda x: x.last_changed, reverse=False) | ||||
|  | ||||
|         fg = FeedGenerator() | ||||
|         fg.title('changedetection.io') | ||||
|         fg.description('Feed description') | ||||
|         fg.link(href='https://changedetection.io') | ||||
|  | ||||
|         for watch in sorted_watches: | ||||
|  | ||||
|             dates = list(watch.history.keys()) | ||||
|             # Re #521 - Don't bother processing this one if theres less than 2 snapshots, means we never had a change detected. | ||||
|             if len(dates) < 2: | ||||
|                 continue | ||||
|  | ||||
|             if not watch.viewed: | ||||
|                 # Re #239 - GUID needs to be individual for each event | ||||
|                 # @todo In the future make this a configurable link back (see work on BASE_URL https://github.com/dgtlmoon/changedetection.io/pull/228) | ||||
|                 guid = "{}/{}".format(watch['uuid'], watch.last_changed) | ||||
|                 fe = fg.add_entry() | ||||
|  | ||||
|                 # Include a link to the diff page, they will have to login here to see if password protection is enabled. | ||||
|                 # Description is the page you watch, link takes you to the diff JS UI page | ||||
|                 # Dict val base_url will get overriden with the env var if it is set. | ||||
|                 ext_base_url = datastore.data['settings']['application'].get('active_base_url') | ||||
|  | ||||
|                 # Because we are called via whatever web server, flask should figure out the right path ( | ||||
|                 diff_link = {'href': url_for('ui.ui_views.diff_history_page', uuid=watch['uuid'], _external=True)} | ||||
|  | ||||
|                 fe.link(link=diff_link) | ||||
|  | ||||
|                 # @todo watch should be a getter - watch.get('title') (internally if URL else..) | ||||
|  | ||||
|                 watch_title = watch.get('title') if watch.get('title') else watch.get('url') | ||||
|                 fe.title(title=watch_title) | ||||
|  | ||||
|                 html_diff = diff.render_diff(previous_version_file_contents=watch.get_history_snapshot(dates[-2]), | ||||
|                                              newest_version_file_contents=watch.get_history_snapshot(dates[-1]), | ||||
|                                              include_equal=False, | ||||
|                                              line_feed_sep="<br>") | ||||
|  | ||||
|                 # @todo Make this configurable and also consider html-colored markup | ||||
|                 # @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) | ||||
|  | ||||
|                 fe.content(content=content, type='CDATA') | ||||
|  | ||||
|                 fe.guid(guid, permalink=False) | ||||
|                 dt = datetime.datetime.fromtimestamp(int(watch.newest_history_key)) | ||||
|                 dt = dt.replace(tzinfo=pytz.UTC) | ||||
|                 fe.pubDate(dt) | ||||
|  | ||||
|         response = make_response(fg.rss_str()) | ||||
|         response.headers.set('Content-Type', 'application/rss+xml;charset=utf-8') | ||||
|         logger.trace(f"RSS generated in {time.time() - now:.3f}s") | ||||
|         return response | ||||
|  | ||||
|     return rss_blueprint | ||||
							
								
								
									
										120
									
								
								changedetectionio/blueprint/settings/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,120 @@ | ||||
| import os | ||||
| from copy import deepcopy | ||||
| from datetime import datetime | ||||
| from zoneinfo import ZoneInfo, available_timezones | ||||
| import secrets | ||||
| import flask_login | ||||
| from flask import Blueprint, render_template, request, redirect, url_for, flash | ||||
|  | ||||
| from changedetectionio.store import ChangeDetectionStore | ||||
| from changedetectionio.auth_decorator import login_optionally_required | ||||
|  | ||||
|  | ||||
| def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|     settings_blueprint = Blueprint('settings', __name__, template_folder="templates") | ||||
|  | ||||
|     @settings_blueprint.route("/", methods=['GET', "POST"]) | ||||
|     @login_optionally_required | ||||
|     def settings_page(): | ||||
|         from changedetectionio import forms | ||||
|  | ||||
|         default = deepcopy(datastore.data['settings']) | ||||
|         if datastore.proxy_list is not None: | ||||
|             available_proxies = list(datastore.proxy_list.keys()) | ||||
|             # When enabled | ||||
|             system_proxy = datastore.data['settings']['requests']['proxy'] | ||||
|             # In the case it doesnt exist anymore | ||||
|             if not system_proxy in available_proxies: | ||||
|                 system_proxy = None | ||||
|  | ||||
|             default['requests']['proxy'] = system_proxy if system_proxy is not None else available_proxies[0] | ||||
|             # Used by the form handler to keep or remove the proxy settings | ||||
|             default['proxy_list'] = available_proxies[0] | ||||
|  | ||||
|         # Don't use form.data on POST so that it doesnt overrid the checkbox status from the POST status | ||||
|         form = forms.globalSettingsForm(formdata=request.form if request.method == 'POST' else None, | ||||
|                                         data=default, | ||||
|                                         extra_notification_tokens=datastore.get_unique_notification_tokens_available() | ||||
|                                         ) | ||||
|  | ||||
|         # Remove the last option 'System default' | ||||
|         form.application.form.notification_format.choices.pop() | ||||
|  | ||||
|         if datastore.proxy_list is None: | ||||
|             # @todo - Couldn't get setattr() etc dynamic addition working, so remove it instead | ||||
|             del form.requests.form.proxy | ||||
|         else: | ||||
|             form.requests.form.proxy.choices = [] | ||||
|             for p in datastore.proxy_list: | ||||
|                 form.requests.form.proxy.choices.append(tuple((p, datastore.proxy_list[p]['label']))) | ||||
|  | ||||
|         if request.method == 'POST': | ||||
|             # Password unset is a GET, but we can lock the session to a salted env password to always need the password | ||||
|             if form.application.form.data.get('removepassword_button', False): | ||||
|                 # SALTED_PASS means the password is "locked" to what we set in the Env var | ||||
|                 if not os.getenv("SALTED_PASS", False): | ||||
|                     datastore.remove_password() | ||||
|                     flash("Password protection removed.", 'notice') | ||||
|                     flask_login.logout_user() | ||||
|                     return redirect(url_for('settings.settings_page')) | ||||
|  | ||||
|             if form.validate(): | ||||
|                 # Don't set password to False when a password is set - should be only removed with the `removepassword` button | ||||
|                 app_update = dict(deepcopy(form.data['application'])) | ||||
|  | ||||
|                 # Never update password with '' or False (Added by wtforms when not in submission) | ||||
|                 if 'password' in app_update and not app_update['password']: | ||||
|                     del (app_update['password']) | ||||
|  | ||||
|                 datastore.data['settings']['application'].update(app_update) | ||||
|                 datastore.data['settings']['requests'].update(form.data['requests']) | ||||
|  | ||||
|                 if not os.getenv("SALTED_PASS", False) and len(form.application.form.password.encrypted_password): | ||||
|                     datastore.data['settings']['application']['password'] = form.application.form.password.encrypted_password | ||||
|                     datastore.needs_write_urgent = True | ||||
|                     flash("Password protection enabled.", 'notice') | ||||
|                     flask_login.logout_user() | ||||
|                     return redirect(url_for('index')) | ||||
|  | ||||
|                 datastore.needs_write_urgent = True | ||||
|                 flash("Settings updated.") | ||||
|  | ||||
|             else: | ||||
|                 flash("An error occurred, please see below.", "error") | ||||
|  | ||||
|         # Convert to ISO 8601 format, all date/time relative events stored as UTC time | ||||
|         utc_time = datetime.now(ZoneInfo("UTC")).isoformat() | ||||
|  | ||||
|         output = render_template("settings.html", | ||||
|                                 api_key=datastore.data['settings']['application'].get('api_access_token'), | ||||
|                                 available_timezones=sorted(available_timezones()), | ||||
|                                 emailprefix=os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False), | ||||
|                                 extra_notification_token_placeholder_info=datastore.get_unique_notification_token_placeholders_available(), | ||||
|                                 form=form, | ||||
|                                 hide_remove_pass=os.getenv("SALTED_PASS", False), | ||||
|                                 min_system_recheck_seconds=int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 3)), | ||||
|                                 settings_application=datastore.data['settings']['application'], | ||||
|                                 timezone_default_config=datastore.data['settings']['application'].get('timezone'), | ||||
|                                 utc_time=utc_time, | ||||
|                                 ) | ||||
|  | ||||
|         return output | ||||
|  | ||||
|     @settings_blueprint.route("/reset-api-key", methods=['GET']) | ||||
|     @login_optionally_required | ||||
|     def settings_reset_api_key(): | ||||
|         secret = secrets.token_hex(16) | ||||
|         datastore.data['settings']['application']['api_access_token'] = secret | ||||
|         datastore.needs_write_urgent = True | ||||
|         flash("API Key was regenerated.") | ||||
|         return redirect(url_for('settings.settings_page')+'#api') | ||||
|          | ||||
|     @settings_blueprint.route("/notification-logs", methods=['GET']) | ||||
|     @login_optionally_required | ||||
|     def notification_logs(): | ||||
|         from changedetectionio.flask_app import notification_debug_log | ||||
|         output = render_template("notification-log.html", | ||||
|                                logs=notification_debug_log if len(notification_debug_log) else ["Notification logs are empty - no notifications sent yet."]) | ||||
|         return output | ||||
|  | ||||
|     return settings_blueprint | ||||
| @@ -4,7 +4,7 @@ | ||||
| {% from '_helpers.html' import render_field, render_checkbox_field, render_button, render_time_schedule_form %} | ||||
| {% from '_common_fields.html' import render_common_settings_form %} | ||||
| <script> | ||||
|     const notification_base_url="{{url_for('ajax_callback_send_notification_test', mode="global-settings")}}"; | ||||
|     const notification_base_url="{{url_for('ui.ui_notification.ajax_callback_send_notification_test', mode="global-settings")}}"; | ||||
| {% if emailprefix %} | ||||
|     const email_notification_prefix=JSON.parse('{{emailprefix|tojson}}'); | ||||
| {% endif %} | ||||
| @@ -28,7 +28,7 @@ | ||||
|         </ul> | ||||
|     </div> | ||||
|     <div class="box-wrap inner"> | ||||
|         <form class="pure-form pure-form-stacked settings" action="{{url_for('settings_page')}}" method="POST"> | ||||
|         <form class="pure-form pure-form-stacked settings" action="{{url_for('settings.settings_page')}}" method="POST"> | ||||
|             <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" > | ||||
|             <div class="tab-pane-inner" id="general"> | ||||
|                 <fieldset> | ||||
| @@ -203,7 +203,7 @@ nav | ||||
|                     </div> | ||||
|                 </div> | ||||
|                 <div class="pure-control-group"> | ||||
|                     <a href="{{url_for('settings_reset_api_key')}}" class="pure-button button-small button-cancel">Regenerate API key</a> | ||||
|                     <a href="{{url_for('settings.settings_reset_api_key')}}" class="pure-button button-small button-cancel">Regenerate API key</a> | ||||
|                 </div> | ||||
|                 <div class="pure-control-group"> | ||||
|                     <h4>Chrome Extension</h4> | ||||
| @@ -300,7 +300,7 @@ nav | ||||
|                 <div class="pure-control-group"> | ||||
|                     {{ render_button(form.save_button) }} | ||||
|                     <a href="{{url_for('index')}}" class="pure-button button-small button-cancel">Back</a> | ||||
|                     <a href="{{url_for('clear_all_history')}}" class="pure-button button-small button-error">Clear Snapshot History</a> | ||||
|                     <a href="{{url_for('ui.clear_all_history')}}" class="pure-button button-small button-error">Clear Snapshot History</a> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </form> | ||||
| @@ -3,7 +3,7 @@ | ||||
| {% from '_helpers.html' import render_field, render_checkbox_field, render_button %} | ||||
| {% from '_common_fields.html' import render_common_settings_form %} | ||||
| <script> | ||||
|     const notification_base_url="{{url_for('ajax_callback_send_notification_test', mode="group-settings")}}"; | ||||
|     const notification_base_url="{{url_for('ui.ui_notification.ajax_callback_send_notification_test', mode="group-settings")}}"; | ||||
| </script> | ||||
|  | ||||
| <script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> | ||||
| @@ -124,7 +124,7 @@ nav | ||||
|                         {% if has_default_notification_urls %} | ||||
|                         <div class="inline-warning"> | ||||
|                             <img class="inline-warning-icon" src="{{url_for('static_content', group='images', filename='notice.svg')}}" alt="Look out!" title="Lookout!" > | ||||
|                             There are <a href="{{ url_for('settings_page')}}#notifications">system-wide notification URLs enabled</a>, this form will override notification settings for this watch only ‐ an empty Notification URL list here will still send notifications. | ||||
|                             There are <a href="{{ url_for('settings.settings_page')}}#notifications">system-wide notification URLs enabled</a>, this form will override notification settings for this watch only ‐ an empty Notification URL list here will still send notifications. | ||||
|                         </div> | ||||
|                         {% endif %} | ||||
|                         <a href="#notifications" id="notification-setting-reset-to-default" class="pure-button button-xsmall" style="right: 20px; top: 20px; position: absolute; background-color: #5f42dd; border-radius: 4px; font-size: 70%; color: #fff">Use system defaults</a> | ||||
|   | ||||
							
								
								
									
										301
									
								
								changedetectionio/blueprint/ui/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,301 @@ | ||||
| import time | ||||
| from flask import Blueprint, request, redirect, url_for, flash, render_template, session | ||||
| from loguru import logger | ||||
| from functools import wraps | ||||
|  | ||||
| from changedetectionio.store import ChangeDetectionStore | ||||
| from changedetectionio.blueprint.ui.edit import construct_blueprint as construct_edit_blueprint | ||||
| from changedetectionio.blueprint.ui.notification import construct_blueprint as construct_notification_blueprint | ||||
| from changedetectionio.blueprint.ui.views import construct_blueprint as construct_views_blueprint | ||||
|  | ||||
| def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_update_threads, queuedWatchMetaData): | ||||
|     ui_blueprint = Blueprint('ui', __name__, template_folder="templates") | ||||
|      | ||||
|     # Register the edit blueprint | ||||
|     edit_blueprint = construct_edit_blueprint(datastore, update_q, queuedWatchMetaData) | ||||
|     ui_blueprint.register_blueprint(edit_blueprint) | ||||
|      | ||||
|     # Register the notification blueprint | ||||
|     notification_blueprint = construct_notification_blueprint(datastore) | ||||
|     ui_blueprint.register_blueprint(notification_blueprint) | ||||
|      | ||||
|     # Register the views blueprint | ||||
|     views_blueprint = construct_views_blueprint(datastore, update_q, queuedWatchMetaData) | ||||
|     ui_blueprint.register_blueprint(views_blueprint) | ||||
|      | ||||
|     # Import the login decorator | ||||
|     from changedetectionio.auth_decorator import login_optionally_required | ||||
|  | ||||
|     @ui_blueprint.route("/clear_history/<string:uuid>", methods=['GET']) | ||||
|     @login_optionally_required | ||||
|     def clear_watch_history(uuid): | ||||
|         try: | ||||
|             datastore.clear_watch_history(uuid) | ||||
|         except KeyError: | ||||
|             flash('Watch not found', 'error') | ||||
|         else: | ||||
|             flash("Cleared snapshot history for watch {}".format(uuid)) | ||||
|  | ||||
|         return redirect(url_for('index')) | ||||
|  | ||||
|     @ui_blueprint.route("/clear_history", methods=['GET', 'POST']) | ||||
|     @login_optionally_required | ||||
|     def clear_all_history(): | ||||
|         if request.method == 'POST': | ||||
|             confirmtext = request.form.get('confirmtext') | ||||
|  | ||||
|             if confirmtext == 'clear': | ||||
|                 for uuid in datastore.data['watching'].keys(): | ||||
|                     datastore.clear_watch_history(uuid) | ||||
|  | ||||
|                 flash("Cleared snapshot history for all watches") | ||||
|             else: | ||||
|                 flash('Incorrect confirmation text.', 'error') | ||||
|  | ||||
|             return redirect(url_for('index')) | ||||
|  | ||||
|         output = render_template("clear_all_history.html") | ||||
|         return output | ||||
|  | ||||
|     # Clear all statuses, so we do not see the 'unviewed' class | ||||
|     @ui_blueprint.route("/form/mark-all-viewed", methods=['GET']) | ||||
|     @login_optionally_required | ||||
|     def mark_all_viewed(): | ||||
|         # Save the current newest history as the most recently viewed | ||||
|         with_errors = request.args.get('with_errors') == "1" | ||||
|         for watch_uuid, watch in datastore.data['watching'].items(): | ||||
|             if with_errors and not watch.get('last_error'): | ||||
|                 continue | ||||
|             datastore.set_last_viewed(watch_uuid, int(time.time())) | ||||
|  | ||||
|         return redirect(url_for('index')) | ||||
|  | ||||
|     @ui_blueprint.route("/delete", methods=['GET']) | ||||
|     @login_optionally_required | ||||
|     def form_delete(): | ||||
|         uuid = request.args.get('uuid') | ||||
|  | ||||
|         if uuid != 'all' and not uuid in datastore.data['watching'].keys(): | ||||
|             flash('The watch by UUID {} does not exist.'.format(uuid), 'error') | ||||
|             return redirect(url_for('index')) | ||||
|  | ||||
|         # More for testing, possible to return the first/only | ||||
|         if uuid == 'first': | ||||
|             uuid = list(datastore.data['watching'].keys()).pop() | ||||
|         datastore.delete(uuid) | ||||
|         flash('Deleted.') | ||||
|  | ||||
|         return redirect(url_for('index')) | ||||
|  | ||||
|     @ui_blueprint.route("/clone", methods=['GET']) | ||||
|     @login_optionally_required | ||||
|     def form_clone(): | ||||
|         uuid = request.args.get('uuid') | ||||
|         # More for testing, possible to return the first/only | ||||
|         if uuid == 'first': | ||||
|             uuid = list(datastore.data['watching'].keys()).pop() | ||||
|  | ||||
|         new_uuid = datastore.clone(uuid) | ||||
|         if new_uuid: | ||||
|             if not datastore.data['watching'].get(uuid).get('paused'): | ||||
|                 update_q.put(queuedWatchMetaData.PrioritizedItem(priority=5, item={'uuid': new_uuid})) | ||||
|             flash('Cloned.') | ||||
|  | ||||
|         return redirect(url_for('index')) | ||||
|  | ||||
|     @ui_blueprint.route("/checknow", methods=['GET']) | ||||
|     @login_optionally_required | ||||
|     def form_watch_checknow(): | ||||
|         # Forced recheck will skip the 'skip if content is the same' rule (, 'reprocess_existing_data': True}))) | ||||
|         tag = request.args.get('tag') | ||||
|         uuid = request.args.get('uuid') | ||||
|         with_errors = request.args.get('with_errors') == "1" | ||||
|  | ||||
|         i = 0 | ||||
|  | ||||
|         running_uuids = [] | ||||
|         for t in running_update_threads: | ||||
|             running_uuids.append(t.current_uuid) | ||||
|  | ||||
|         if uuid: | ||||
|             if uuid not in running_uuids: | ||||
|                 update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid})) | ||||
|                 i += 1 | ||||
|  | ||||
|         else: | ||||
|             # Recheck all, including muted | ||||
|             for watch_uuid, watch in datastore.data['watching'].items(): | ||||
|                 if not watch['paused']: | ||||
|                     if watch_uuid not in running_uuids: | ||||
|                         if with_errors and not watch.get('last_error'): | ||||
|                             continue | ||||
|  | ||||
|                         if tag != None and tag not in watch['tags']: | ||||
|                             continue | ||||
|  | ||||
|                         update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid})) | ||||
|                         i += 1 | ||||
|  | ||||
|         if i == 1: | ||||
|             flash("Queued 1 watch for rechecking.") | ||||
|         if i > 1: | ||||
|             flash("Queued {} watches for rechecking.".format(i)) | ||||
|         if i == 0: | ||||
|             flash("No watches available to recheck.") | ||||
|  | ||||
|         return redirect(url_for('index')) | ||||
|  | ||||
|     @ui_blueprint.route("/form/checkbox-operations", methods=['POST']) | ||||
|     @login_optionally_required | ||||
|     def form_watch_list_checkbox_operations(): | ||||
|         op = request.form['op'] | ||||
|         uuids = request.form.getlist('uuids') | ||||
|  | ||||
|         if (op == 'delete'): | ||||
|             for uuid in uuids: | ||||
|                 uuid = uuid.strip() | ||||
|                 if datastore.data['watching'].get(uuid): | ||||
|                     datastore.delete(uuid.strip()) | ||||
|             flash("{} watches deleted".format(len(uuids))) | ||||
|  | ||||
|         elif (op == 'pause'): | ||||
|             for uuid in uuids: | ||||
|                 uuid = uuid.strip() | ||||
|                 if datastore.data['watching'].get(uuid): | ||||
|                     datastore.data['watching'][uuid.strip()]['paused'] = True | ||||
|             flash("{} watches paused".format(len(uuids))) | ||||
|  | ||||
|         elif (op == 'unpause'): | ||||
|             for uuid in uuids: | ||||
|                 uuid = uuid.strip() | ||||
|                 if datastore.data['watching'].get(uuid): | ||||
|                     datastore.data['watching'][uuid.strip()]['paused'] = False | ||||
|             flash("{} watches unpaused".format(len(uuids))) | ||||
|  | ||||
|         elif (op == 'mark-viewed'): | ||||
|             for uuid in uuids: | ||||
|                 uuid = uuid.strip() | ||||
|                 if datastore.data['watching'].get(uuid): | ||||
|                     datastore.set_last_viewed(uuid, int(time.time())) | ||||
|             flash("{} watches updated".format(len(uuids))) | ||||
|  | ||||
|         elif (op == 'mute'): | ||||
|             for uuid in uuids: | ||||
|                 uuid = uuid.strip() | ||||
|                 if datastore.data['watching'].get(uuid): | ||||
|                     datastore.data['watching'][uuid.strip()]['notification_muted'] = True | ||||
|             flash("{} watches muted".format(len(uuids))) | ||||
|  | ||||
|         elif (op == 'unmute'): | ||||
|             for uuid in uuids: | ||||
|                 uuid = uuid.strip() | ||||
|                 if datastore.data['watching'].get(uuid): | ||||
|                     datastore.data['watching'][uuid.strip()]['notification_muted'] = False | ||||
|             flash("{} watches un-muted".format(len(uuids))) | ||||
|  | ||||
|         elif (op == 'recheck'): | ||||
|             for uuid in uuids: | ||||
|                 uuid = uuid.strip() | ||||
|                 if datastore.data['watching'].get(uuid): | ||||
|                     # Recheck and require a full reprocessing | ||||
|                     update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid})) | ||||
|             flash("{} watches queued for rechecking".format(len(uuids))) | ||||
|  | ||||
|         elif (op == 'clear-errors'): | ||||
|             for uuid in uuids: | ||||
|                 uuid = uuid.strip() | ||||
|                 if datastore.data['watching'].get(uuid): | ||||
|                     datastore.data['watching'][uuid]["last_error"] = False | ||||
|             flash(f"{len(uuids)} watches errors cleared") | ||||
|  | ||||
|         elif (op == 'clear-history'): | ||||
|             for uuid in uuids: | ||||
|                 uuid = uuid.strip() | ||||
|                 if datastore.data['watching'].get(uuid): | ||||
|                     datastore.clear_watch_history(uuid) | ||||
|             flash("{} watches cleared/reset.".format(len(uuids))) | ||||
|  | ||||
|         elif (op == 'notification-default'): | ||||
|             from changedetectionio.notification import ( | ||||
|                 default_notification_format_for_watch | ||||
|             ) | ||||
|             for uuid in uuids: | ||||
|                 uuid = uuid.strip() | ||||
|                 if datastore.data['watching'].get(uuid): | ||||
|                     datastore.data['watching'][uuid.strip()]['notification_title'] = None | ||||
|                     datastore.data['watching'][uuid.strip()]['notification_body'] = None | ||||
|                     datastore.data['watching'][uuid.strip()]['notification_urls'] = [] | ||||
|                     datastore.data['watching'][uuid.strip()]['notification_format'] = default_notification_format_for_watch | ||||
|             flash("{} watches set to use default notification settings".format(len(uuids))) | ||||
|  | ||||
|         elif (op == 'assign-tag'): | ||||
|             op_extradata = request.form.get('op_extradata', '').strip() | ||||
|             if op_extradata: | ||||
|                 tag_uuid = datastore.add_tag(title=op_extradata) | ||||
|                 if op_extradata and tag_uuid: | ||||
|                     for uuid in uuids: | ||||
|                         uuid = uuid.strip() | ||||
|                         if datastore.data['watching'].get(uuid): | ||||
|                             # Bug in old versions caused by bad edit page/tag handler | ||||
|                             if isinstance(datastore.data['watching'][uuid]['tags'], str): | ||||
|                                 datastore.data['watching'][uuid]['tags'] = [] | ||||
|  | ||||
|                             datastore.data['watching'][uuid]['tags'].append(tag_uuid) | ||||
|  | ||||
|             flash(f"{len(uuids)} watches were tagged") | ||||
|  | ||||
|         return redirect(url_for('index')) | ||||
|  | ||||
|  | ||||
|     @ui_blueprint.route("/share-url/<string:uuid>", methods=['GET']) | ||||
|     @login_optionally_required | ||||
|     def form_share_put_watch(uuid): | ||||
|         """Given a watch UUID, upload the info and return a share-link | ||||
|            the share-link can be imported/added""" | ||||
|         import requests | ||||
|         import json | ||||
|         from copy import deepcopy | ||||
|  | ||||
|         # more for testing | ||||
|         if uuid == 'first': | ||||
|             uuid = list(datastore.data['watching'].keys()).pop() | ||||
|  | ||||
|         # copy it to memory as trim off what we dont need (history) | ||||
|         watch = deepcopy(datastore.data['watching'].get(uuid)) | ||||
|         # For older versions that are not a @property | ||||
|         if (watch.get('history')): | ||||
|             del (watch['history']) | ||||
|  | ||||
|         # for safety/privacy | ||||
|         for k in list(watch.keys()): | ||||
|             if k.startswith('notification_'): | ||||
|                 del watch[k] | ||||
|  | ||||
|         for r in['uuid', 'last_checked', 'last_changed']: | ||||
|             if watch.get(r): | ||||
|                 del (watch[r]) | ||||
|  | ||||
|         # Add the global stuff which may have an impact | ||||
|         watch['ignore_text'] += datastore.data['settings']['application']['global_ignore_text'] | ||||
|         watch['subtractive_selectors'] += datastore.data['settings']['application']['global_subtractive_selectors'] | ||||
|  | ||||
|         watch_json = json.dumps(watch) | ||||
|  | ||||
|         try: | ||||
|             r = requests.request(method="POST", | ||||
|                                  data={'watch': watch_json}, | ||||
|                                  url="https://changedetection.io/share/share", | ||||
|                                  headers={'App-Guid': datastore.data['app_guid']}) | ||||
|             res = r.json() | ||||
|  | ||||
|             # Add to the flask session | ||||
|             session['share-link'] = f"https://changedetection.io/share/{res['share_key']}" | ||||
|  | ||||
|  | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error sharing -{str(e)}") | ||||
|             flash(f"Could not share, something went wrong while communicating with the share server - {str(e)}", 'error') | ||||
|  | ||||
|         return redirect(url_for('index')) | ||||
|  | ||||
|     return ui_blueprint | ||||
							
								
								
									
										333
									
								
								changedetectionio/blueprint/ui/edit.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,333 @@ | ||||
| import time | ||||
| from copy import deepcopy | ||||
| import os | ||||
| import importlib.resources | ||||
| from flask import Blueprint, request, redirect, url_for, flash, render_template, make_response, send_from_directory, abort | ||||
| from loguru import logger | ||||
| from jinja2 import Environment, FileSystemLoader | ||||
|  | ||||
| from changedetectionio.store import ChangeDetectionStore | ||||
| from changedetectionio.auth_decorator import login_optionally_required | ||||
| from changedetectionio.time_handler import is_within_schedule | ||||
|  | ||||
| def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData): | ||||
|     edit_blueprint = Blueprint('ui_edit', __name__, template_folder="../ui/templates") | ||||
|      | ||||
|     def _watch_has_tag_options_set(watch): | ||||
|         """This should be fixed better so that Tag is some proper Model, a tag is just a Watch also""" | ||||
|         for tag_uuid, tag in datastore.data['settings']['application'].get('tags', {}).items(): | ||||
|             if tag_uuid in watch.get('tags', []) and (tag.get('include_filters') or tag.get('subtractive_selectors')): | ||||
|                 return True | ||||
|  | ||||
|     @edit_blueprint.route("/edit/<string:uuid>", methods=['GET', 'POST']) | ||||
|     @login_optionally_required | ||||
|     # https://stackoverflow.com/questions/42984453/wtforms-populate-form-with-data-if-data-exists | ||||
|     # https://wtforms.readthedocs.io/en/3.0.x/forms/#wtforms.form.Form.populate_obj ? | ||||
|     def edit_page(uuid): | ||||
|         from changedetectionio import forms | ||||
|         from changedetectionio.blueprint.browser_steps.browser_steps import browser_step_ui_config | ||||
|         from changedetectionio import processors | ||||
|         import importlib | ||||
|  | ||||
|         # More for testing, possible to return the first/only | ||||
|         if not datastore.data['watching'].keys(): | ||||
|             flash("No watches to edit", "error") | ||||
|             return redirect(url_for('index')) | ||||
|  | ||||
|         if uuid == 'first': | ||||
|             uuid = list(datastore.data['watching'].keys()).pop() | ||||
|  | ||||
|         if not uuid in datastore.data['watching']: | ||||
|             flash("No watch with the UUID %s found." % (uuid), "error") | ||||
|             return redirect(url_for('index')) | ||||
|  | ||||
|         switch_processor = request.args.get('switch_processor') | ||||
|         if switch_processor: | ||||
|             for p in processors.available_processors(): | ||||
|                 if p[0] == switch_processor: | ||||
|                     datastore.data['watching'][uuid]['processor'] = switch_processor | ||||
|                     flash(f"Switched to mode - {p[1]}.") | ||||
|                     datastore.clear_watch_history(uuid) | ||||
|                     redirect(url_for('ui_edit.edit_page', uuid=uuid)) | ||||
|  | ||||
|         # be sure we update with a copy instead of accidently editing the live object by reference | ||||
|         default = deepcopy(datastore.data['watching'][uuid]) | ||||
|  | ||||
|         # Defaults for proxy choice | ||||
|         if datastore.proxy_list is not None:  # When enabled | ||||
|             # @todo | ||||
|             # Radio needs '' not None, or incase that the chosen one no longer exists | ||||
|             if default['proxy'] is None or not any(default['proxy'] in tup for tup in datastore.proxy_list): | ||||
|                 default['proxy'] = '' | ||||
|         # proxy_override set to the json/text list of the items | ||||
|  | ||||
|         # Does it use some custom form? does one exist? | ||||
|         processor_name = datastore.data['watching'][uuid].get('processor', '') | ||||
|         processor_classes = next((tpl for tpl in processors.find_processors() if tpl[1] == processor_name), None) | ||||
|         if not processor_classes: | ||||
|             flash(f"Cannot load the edit form for processor/plugin '{processor_classes[1]}', plugin missing?", 'error') | ||||
|             return redirect(url_for('index')) | ||||
|  | ||||
|         parent_module = processors.get_parent_module(processor_classes[0]) | ||||
|  | ||||
|         try: | ||||
|             # Get the parent of the "processor.py" go up one, get the form (kinda spaghetti but its reusing existing code) | ||||
|             forms_module = importlib.import_module(f"{parent_module.__name__}.forms") | ||||
|             # Access the 'processor_settings_form' class from the 'forms' module | ||||
|             form_class = getattr(forms_module, 'processor_settings_form') | ||||
|         except ModuleNotFoundError as e: | ||||
|             # .forms didnt exist | ||||
|             form_class = forms.processor_text_json_diff_form | ||||
|         except AttributeError as e: | ||||
|             # .forms exists but no useful form | ||||
|             form_class = forms.processor_text_json_diff_form | ||||
|  | ||||
|         form = form_class(formdata=request.form if request.method == 'POST' else None, | ||||
|                           data=default, | ||||
|                           extra_notification_tokens=default.extra_notification_token_values(), | ||||
|                           default_system_settings=datastore.data['settings'] | ||||
|                           ) | ||||
|  | ||||
|         # For the form widget tag UUID back to "string name" for the field | ||||
|         form.tags.datastore = datastore | ||||
|  | ||||
|         # Used by some forms that need to dig deeper | ||||
|         form.datastore = datastore | ||||
|         form.watch = default | ||||
|  | ||||
|         for p in datastore.extra_browsers: | ||||
|             form.fetch_backend.choices.append(p) | ||||
|  | ||||
|         form.fetch_backend.choices.append(("system", 'System settings default')) | ||||
|  | ||||
|         # form.browser_steps[0] can be assumed that we 'goto url' first | ||||
|  | ||||
|         if datastore.proxy_list is None: | ||||
|             # @todo - Couldn't get setattr() etc dynamic addition working, so remove it instead | ||||
|             del form.proxy | ||||
|         else: | ||||
|             form.proxy.choices = [('', 'Default')] | ||||
|             for p in datastore.proxy_list: | ||||
|                 form.proxy.choices.append(tuple((p, datastore.proxy_list[p]['label']))) | ||||
|  | ||||
|  | ||||
|         if request.method == 'POST' and form.validate(): | ||||
|  | ||||
|             # If they changed processor, it makes sense to reset it. | ||||
|             if datastore.data['watching'][uuid].get('processor') != form.data.get('processor'): | ||||
|                 datastore.data['watching'][uuid].clear_watch() | ||||
|                 flash("Reset watch history due to change of processor") | ||||
|  | ||||
|             extra_update_obj = { | ||||
|                 'consecutive_filter_failures': 0, | ||||
|                 'last_error' : False | ||||
|             } | ||||
|  | ||||
|             if request.args.get('unpause_on_save'): | ||||
|                 extra_update_obj['paused'] = False | ||||
|  | ||||
|             extra_update_obj['time_between_check'] = form.time_between_check.data | ||||
|  | ||||
|              # Ignore text | ||||
|             form_ignore_text = form.ignore_text.data | ||||
|             datastore.data['watching'][uuid]['ignore_text'] = form_ignore_text | ||||
|  | ||||
|             # Be sure proxy value is None | ||||
|             if datastore.proxy_list is not None and form.data['proxy'] == '': | ||||
|                 extra_update_obj['proxy'] = None | ||||
|  | ||||
|             # Unsetting all filter_text methods should make it go back to default | ||||
|             # This particularly affects tests running | ||||
|             if 'filter_text_added' in form.data and not form.data.get('filter_text_added') \ | ||||
|                     and 'filter_text_replaced' in form.data and not form.data.get('filter_text_replaced') \ | ||||
|                     and 'filter_text_removed' in form.data and not form.data.get('filter_text_removed'): | ||||
|                 extra_update_obj['filter_text_added'] = True | ||||
|                 extra_update_obj['filter_text_replaced'] = True | ||||
|                 extra_update_obj['filter_text_removed'] = True | ||||
|  | ||||
|             # Because wtforms doesn't support accessing other data in process_ , but we convert the CSV list of tags back to a list of UUIDs | ||||
|             tag_uuids = [] | ||||
|             if form.data.get('tags'): | ||||
|                 # Sometimes in testing this can be list, dont know why | ||||
|                 if type(form.data.get('tags')) == list: | ||||
|                     extra_update_obj['tags'] = form.data.get('tags') | ||||
|                 else: | ||||
|                     for t in form.data.get('tags').split(','): | ||||
|                         tag_uuids.append(datastore.add_tag(title=t)) | ||||
|                     extra_update_obj['tags'] = tag_uuids | ||||
|  | ||||
|             datastore.data['watching'][uuid].update(form.data) | ||||
|             datastore.data['watching'][uuid].update(extra_update_obj) | ||||
|  | ||||
|             if not datastore.data['watching'][uuid].get('tags'): | ||||
|                 # Force it to be a list, because form.data['tags'] will be string if nothing found | ||||
|                 # And del(form.data['tags'] ) wont work either for some reason | ||||
|                 datastore.data['watching'][uuid]['tags'] = [] | ||||
|  | ||||
|             # Recast it if need be to right data Watch handler | ||||
|             watch_class = processors.get_custom_watch_obj_for_processor(form.data.get('processor')) | ||||
|             datastore.data['watching'][uuid] = watch_class(datastore_path=datastore.datastore_path, default=datastore.data['watching'][uuid]) | ||||
|             flash("Updated watch - unpaused!" if request.args.get('unpause_on_save') else "Updated watch.") | ||||
|  | ||||
|             # Re #286 - We wait for syncing new data to disk in another thread every 60 seconds | ||||
|             # But in the case something is added we should save straight away | ||||
|             datastore.needs_write_urgent = True | ||||
|  | ||||
|             # Do not queue on edit if its not within the time range | ||||
|  | ||||
|             # @todo maybe it should never queue anyway on edit... | ||||
|             is_in_schedule = True | ||||
|             watch = datastore.data['watching'].get(uuid) | ||||
|  | ||||
|             if watch.get('time_between_check_use_default'): | ||||
|                 time_schedule_limit = datastore.data['settings']['requests'].get('time_schedule_limit', {}) | ||||
|             else: | ||||
|                 time_schedule_limit = watch.get('time_schedule_limit') | ||||
|  | ||||
|             tz_name = time_schedule_limit.get('timezone') | ||||
|             if not tz_name: | ||||
|                 tz_name = datastore.data['settings']['application'].get('timezone', 'UTC') | ||||
|  | ||||
|             if time_schedule_limit and time_schedule_limit.get('enabled'): | ||||
|                 try: | ||||
|                     is_in_schedule = is_within_schedule(time_schedule_limit=time_schedule_limit, | ||||
|                                                       default_tz=tz_name | ||||
|                                                       ) | ||||
|                 except Exception as e: | ||||
|                     logger.error( | ||||
|                         f"{uuid} - Recheck scheduler, error handling timezone, check skipped - TZ name '{tz_name}' - {str(e)}") | ||||
|                     return False | ||||
|  | ||||
|             ############################# | ||||
|             if not datastore.data['watching'][uuid].get('paused') and is_in_schedule: | ||||
|                 # Queue the watch for immediate recheck, with a higher priority | ||||
|                 update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid})) | ||||
|  | ||||
|             # Diff page [edit] link should go back to diff page | ||||
|             if request.args.get("next") and request.args.get("next") == 'diff': | ||||
|                 return redirect(url_for('ui.ui_views.diff_history_page', uuid=uuid)) | ||||
|  | ||||
|             return redirect(url_for('index', tag=request.args.get("tag",''))) | ||||
|  | ||||
|         else: | ||||
|             if request.method == 'POST' and not form.validate(): | ||||
|                 flash("An error occurred, please see below.", "error") | ||||
|  | ||||
|             visualselector_data_is_ready = datastore.visualselector_data_is_ready(uuid) | ||||
|  | ||||
|  | ||||
|             # JQ is difficult to install on windows and must be manually added (outside requirements.txt) | ||||
|             jq_support = True | ||||
|             try: | ||||
|                 import jq | ||||
|             except ModuleNotFoundError: | ||||
|                 jq_support = False | ||||
|  | ||||
|             watch = datastore.data['watching'].get(uuid) | ||||
|  | ||||
|             system_uses_webdriver = datastore.data['settings']['application']['fetch_backend'] == 'html_webdriver' | ||||
|  | ||||
|             watch_uses_webdriver = False | ||||
|             if (watch.get('fetch_backend') == 'system' and system_uses_webdriver) or watch.get('fetch_backend') == 'html_webdriver' or watch.get('fetch_backend', '').startswith('extra_browser_'): | ||||
|                 watch_uses_webdriver = True | ||||
|  | ||||
|             from zoneinfo import available_timezones | ||||
|  | ||||
|             # Only works reliably with Playwright | ||||
|  | ||||
|             template_args = { | ||||
|                 'available_processors': processors.available_processors(), | ||||
|                 'available_timezones': sorted(available_timezones()), | ||||
|                 'browser_steps_config': browser_step_ui_config, | ||||
|                 'emailprefix': os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False), | ||||
|                 'extra_notification_token_placeholder_info': datastore.get_unique_notification_token_placeholders_available(), | ||||
|                 'extra_processor_config': form.extra_tab_content(), | ||||
|                 'extra_title': f" - Edit - {watch.label}", | ||||
|                 'form': form, | ||||
|                 'has_default_notification_urls': True if len(datastore.data['settings']['application']['notification_urls']) else False, | ||||
|                 'has_extra_headers_file': len(datastore.get_all_headers_in_textfile_for_watch(uuid=uuid)) > 0, | ||||
|                 'has_special_tag_options': _watch_has_tag_options_set(watch=watch), | ||||
|                 'watch_uses_webdriver': watch_uses_webdriver, | ||||
|                 'jq_support': jq_support, | ||||
|                 'playwright_enabled': os.getenv('PLAYWRIGHT_DRIVER_URL', False), | ||||
|                 'settings_application': datastore.data['settings']['application'], | ||||
|                 'timezone_default_config': datastore.data['settings']['application'].get('timezone'), | ||||
|                 'using_global_webdriver_wait': not default['webdriver_delay'], | ||||
|                 'uuid': uuid, | ||||
|                 'watch': watch | ||||
|             } | ||||
|  | ||||
|             included_content = None | ||||
|             if form.extra_form_content(): | ||||
|                 # So that the extra panels can access _helpers.html etc, we set the environment to load from templates/ | ||||
|                 # And then render the code from the module | ||||
|                 templates_dir = str(importlib.resources.files("changedetectionio").joinpath('templates')) | ||||
|                 env = Environment(loader=FileSystemLoader(templates_dir)) | ||||
|                 template = env.from_string(form.extra_form_content()) | ||||
|                 included_content = template.render(**template_args) | ||||
|  | ||||
|             output = render_template("edit.html", | ||||
|                                      extra_tab_content=form.extra_tab_content() if form.extra_tab_content() else None, | ||||
|                                      extra_form_content=included_content, | ||||
|                                      **template_args | ||||
|                                      ) | ||||
|  | ||||
|         return output | ||||
|  | ||||
|     @edit_blueprint.route("/edit/<string:uuid>/get-html", methods=['GET']) | ||||
|     @login_optionally_required | ||||
|     def watch_get_latest_html(uuid): | ||||
|         from io import BytesIO | ||||
|         from flask import send_file | ||||
|         import brotli | ||||
|  | ||||
|         watch = datastore.data['watching'].get(uuid) | ||||
|         if watch and watch.history.keys() and os.path.isdir(watch.watch_data_dir): | ||||
|             latest_filename = list(watch.history.keys())[-1] | ||||
|             html_fname = os.path.join(watch.watch_data_dir, f"{latest_filename}.html.br") | ||||
|             with open(html_fname, 'rb') as f: | ||||
|                 if html_fname.endswith('.br'): | ||||
|                     # Read and decompress the Brotli file | ||||
|                     decompressed_data = brotli.decompress(f.read()) | ||||
|                 else: | ||||
|                     decompressed_data = f.read() | ||||
|  | ||||
|             buffer = BytesIO(decompressed_data) | ||||
|  | ||||
|             return send_file(buffer, as_attachment=True, download_name=f"{latest_filename}.html", mimetype='text/html') | ||||
|  | ||||
|         # Return a 500 error | ||||
|         abort(500) | ||||
|  | ||||
|     # Ajax callback | ||||
|     @edit_blueprint.route("/edit/<string:uuid>/preview-rendered", methods=['POST']) | ||||
|     @login_optionally_required | ||||
|     def watch_get_preview_rendered(uuid): | ||||
|         '''For when viewing the "preview" of the rendered text from inside of Edit''' | ||||
|         from flask import jsonify | ||||
|         from changedetectionio.processors.text_json_diff import prepare_filter_prevew | ||||
|         result = prepare_filter_prevew(watch_uuid=uuid, form_data=request.form, datastore=datastore) | ||||
|         return jsonify(result) | ||||
|  | ||||
|     @edit_blueprint.route("/highlight_submit_ignore_url", methods=['POST']) | ||||
|     @login_optionally_required | ||||
|     def highlight_submit_ignore_url(): | ||||
|         import re | ||||
|         mode = request.form.get('mode') | ||||
|         selection = request.form.get('selection') | ||||
|  | ||||
|         uuid = request.args.get('uuid','') | ||||
|         if datastore.data["watching"].get(uuid): | ||||
|             if mode == 'exact': | ||||
|                 for l in selection.splitlines(): | ||||
|                     datastore.data["watching"][uuid]['ignore_text'].append(l.strip()) | ||||
|             elif mode == 'digit-regex': | ||||
|                 for l in selection.splitlines(): | ||||
|                     # Replace any series of numbers with a regex | ||||
|                     s = re.escape(l.strip()) | ||||
|                     s = re.sub(r'[0-9]+', r'\\d+', s) | ||||
|                     datastore.data["watching"][uuid]['ignore_text'].append('/' + s + '/') | ||||
|  | ||||
|         return f"<a href={url_for('ui.ui_views.preview_page', uuid=uuid)}>Click to preview</a>" | ||||
|      | ||||
|     return edit_blueprint | ||||
							
								
								
									
										107
									
								
								changedetectionio/blueprint/ui/notification.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,107 @@ | ||||
| from flask import Blueprint, request, make_response | ||||
| import random | ||||
| from loguru import logger | ||||
|  | ||||
| from changedetectionio.store import ChangeDetectionStore | ||||
| from changedetectionio.auth_decorator import login_optionally_required | ||||
|  | ||||
| def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|     notification_blueprint = Blueprint('ui_notification', __name__, template_folder="../ui/templates") | ||||
|      | ||||
|     # AJAX endpoint for sending a test | ||||
|     @notification_blueprint.route("/notification/send-test/<string:watch_uuid>", methods=['POST']) | ||||
|     @notification_blueprint.route("/notification/send-test", methods=['POST']) | ||||
|     @notification_blueprint.route("/notification/send-test/", methods=['POST']) | ||||
|     @login_optionally_required | ||||
|     def ajax_callback_send_notification_test(watch_uuid=None): | ||||
|  | ||||
|         # Watch_uuid could be unset in the case it`s used in tag editor, global settings | ||||
|         import apprise | ||||
|         from changedetectionio.apprise_asset import asset | ||||
|         apobj = apprise.Apprise(asset=asset) | ||||
|  | ||||
|         # so that the custom endpoints are registered | ||||
|         from changedetectionio.apprise_plugin import apprise_custom_api_call_wrapper | ||||
|         is_global_settings_form = request.args.get('mode', '') == 'global-settings' | ||||
|         is_group_settings_form = request.args.get('mode', '') == 'group-settings' | ||||
|  | ||||
|         # Use an existing random one on the global/main settings form | ||||
|         if not watch_uuid and (is_global_settings_form or is_group_settings_form) \ | ||||
|                 and datastore.data.get('watching'): | ||||
|             logger.debug(f"Send test notification - Choosing random Watch {watch_uuid}") | ||||
|             watch_uuid = random.choice(list(datastore.data['watching'].keys())) | ||||
|  | ||||
|         if not watch_uuid: | ||||
|             return make_response("Error: You must have atleast one watch configured for 'test notification' to work", 400) | ||||
|  | ||||
|         watch = datastore.data['watching'].get(watch_uuid) | ||||
|  | ||||
|         notification_urls = None | ||||
|  | ||||
|         if request.form.get('notification_urls'): | ||||
|             notification_urls = request.form['notification_urls'].strip().splitlines() | ||||
|  | ||||
|         if not notification_urls: | ||||
|             logger.debug("Test notification - Trying by group/tag in the edit form if available") | ||||
|             # On an edit page, we should also fire off to the tags if they have notifications | ||||
|             if request.form.get('tags') and request.form['tags'].strip(): | ||||
|                 for k in request.form['tags'].split(','): | ||||
|                     tag = datastore.tag_exists_by_name(k.strip()) | ||||
|                     notification_urls = tag.get('notifications_urls') if tag and tag.get('notifications_urls') else None | ||||
|  | ||||
|         if not notification_urls and not is_global_settings_form and not is_group_settings_form: | ||||
|             # In the global settings, use only what is typed currently in the text box | ||||
|             logger.debug("Test notification - Trying by global system settings notifications") | ||||
|             if datastore.data['settings']['application'].get('notification_urls'): | ||||
|                 notification_urls = datastore.data['settings']['application']['notification_urls'] | ||||
|  | ||||
|         if not notification_urls: | ||||
|             return 'Error: No Notification URLs set/found' | ||||
|  | ||||
|         for n_url in notification_urls: | ||||
|             if len(n_url.strip()): | ||||
|                 if not apobj.add(n_url): | ||||
|                     return f'Error:  {n_url} is not a valid AppRise URL.' | ||||
|  | ||||
|         try: | ||||
|             # use the same as when it is triggered, but then override it with the form test values | ||||
|             n_object = { | ||||
|                 'watch_url': request.form.get('window_url', "https://changedetection.io"), | ||||
|                 'notification_urls': notification_urls | ||||
|             } | ||||
|  | ||||
|             # Only use if present, if not set in n_object it should use the default system value | ||||
|             if 'notification_format' in request.form and request.form['notification_format'].strip(): | ||||
|                 n_object['notification_format'] = request.form.get('notification_format', '').strip() | ||||
|  | ||||
|             if 'notification_title' in request.form and request.form['notification_title'].strip(): | ||||
|                 n_object['notification_title'] = request.form.get('notification_title', '').strip() | ||||
|             elif datastore.data['settings']['application'].get('notification_title'): | ||||
|                 n_object['notification_title'] = datastore.data['settings']['application'].get('notification_title') | ||||
|             else: | ||||
|                 n_object['notification_title'] = "Test title" | ||||
|  | ||||
|             if 'notification_body' in request.form and request.form['notification_body'].strip(): | ||||
|                 n_object['notification_body'] = request.form.get('notification_body', '').strip() | ||||
|             elif datastore.data['settings']['application'].get('notification_body'): | ||||
|                 n_object['notification_body'] = datastore.data['settings']['application'].get('notification_body') | ||||
|             else: | ||||
|                 n_object['notification_body'] = "Test body" | ||||
|  | ||||
|             n_object['as_async'] = False | ||||
|             n_object.update(watch.extra_notification_token_values()) | ||||
|             from changedetectionio.notification import process_notification | ||||
|             sent_obj = process_notification(n_object, datastore) | ||||
|  | ||||
|         except Exception as e: | ||||
|             e_str = str(e) | ||||
|             # Remove this text which is not important and floods the container | ||||
|             e_str = e_str.replace( | ||||
|                 "DEBUG - <class 'apprise.decorators.base.CustomNotifyPlugin.instantiate_plugin.<locals>.CustomNotifyPluginWrapper'>", | ||||
|                 '') | ||||
|  | ||||
|             return make_response(e_str, 400) | ||||
|  | ||||
|         return 'OK - Sent test notifications' | ||||
|  | ||||
|     return notification_blueprint | ||||
| @@ -3,7 +3,7 @@ | ||||
|   <div class="box-wrap inner"> | ||||
|     <form | ||||
|       class="pure-form pure-form-stacked" | ||||
|       action="{{url_for('clear_all_history')}}" | ||||
|       action="{{url_for('ui.clear_all_history')}}" | ||||
|       method="POST" | ||||
|     > | ||||
|       <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" > | ||||
							
								
								
									
										220
									
								
								changedetectionio/blueprint/ui/views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,220 @@ | ||||
| from flask import Blueprint, request, redirect, url_for, flash, render_template, make_response, send_from_directory, abort | ||||
| from flask_login import current_user | ||||
| import os | ||||
| import time | ||||
| from copy import deepcopy | ||||
|  | ||||
| from changedetectionio.store import ChangeDetectionStore | ||||
| from changedetectionio.auth_decorator import login_optionally_required | ||||
| from changedetectionio import html_tools | ||||
|  | ||||
| def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData): | ||||
|     views_blueprint = Blueprint('ui_views', __name__, template_folder="../ui/templates") | ||||
|      | ||||
|     @views_blueprint.route("/preview/<string:uuid>", methods=['GET']) | ||||
|     @login_optionally_required | ||||
|     def preview_page(uuid): | ||||
|         content = [] | ||||
|         versions = [] | ||||
|         timestamp = None | ||||
|  | ||||
|         # More for testing, possible to return the first/only | ||||
|         if uuid == 'first': | ||||
|             uuid = list(datastore.data['watching'].keys()).pop() | ||||
|  | ||||
|         try: | ||||
|             watch = datastore.data['watching'][uuid] | ||||
|         except KeyError: | ||||
|             flash("No history found for the specified link, bad link?", "error") | ||||
|             return redirect(url_for('index')) | ||||
|  | ||||
|         system_uses_webdriver = datastore.data['settings']['application']['fetch_backend'] == 'html_webdriver' | ||||
|         extra_stylesheets = [url_for('static_content', group='styles', filename='diff.css')] | ||||
|  | ||||
|         is_html_webdriver = False | ||||
|         if (watch.get('fetch_backend') == 'system' and system_uses_webdriver) or watch.get('fetch_backend') == 'html_webdriver' or watch.get('fetch_backend', '').startswith('extra_browser_'): | ||||
|             is_html_webdriver = True | ||||
|         triggered_line_numbers = [] | ||||
|         if datastore.data['watching'][uuid].history_n == 0 and (watch.get_error_text() or watch.get_error_snapshot()): | ||||
|             flash("Preview unavailable - No fetch/check completed or triggers not reached", "error") | ||||
|         else: | ||||
|             # So prepare the latest preview or not | ||||
|             preferred_version = request.args.get('version') | ||||
|             versions = list(watch.history.keys()) | ||||
|             timestamp = versions[-1] | ||||
|             if preferred_version and preferred_version in versions: | ||||
|                 timestamp = preferred_version | ||||
|  | ||||
|             try: | ||||
|                 versions = list(watch.history.keys()) | ||||
|                 content = watch.get_history_snapshot(timestamp) | ||||
|  | ||||
|                 triggered_line_numbers = html_tools.strip_ignore_text(content=content, | ||||
|                                                                       wordlist=watch['trigger_text'], | ||||
|                                                                       mode='line numbers' | ||||
|                                                                       ) | ||||
|  | ||||
|             except Exception as e: | ||||
|                 content.append({'line': f"File doesnt exist or unable to read timestamp {timestamp}", 'classes': ''}) | ||||
|  | ||||
|         output = render_template("preview.html", | ||||
|                                  content=content, | ||||
|                                  current_version=timestamp, | ||||
|                                  history_n=watch.history_n, | ||||
|                                  extra_stylesheets=extra_stylesheets, | ||||
|                                  extra_title=f" - Diff - {watch.label} @ {timestamp}", | ||||
|                                  triggered_line_numbers=triggered_line_numbers, | ||||
|                                  current_diff_url=watch['url'], | ||||
|                                  screenshot=watch.get_screenshot(), | ||||
|                                  watch=watch, | ||||
|                                  uuid=uuid, | ||||
|                                  is_html_webdriver=is_html_webdriver, | ||||
|                                  last_error=watch['last_error'], | ||||
|                                  last_error_text=watch.get_error_text(), | ||||
|                                  last_error_screenshot=watch.get_error_snapshot(), | ||||
|                                  versions=versions | ||||
|                                 ) | ||||
|  | ||||
|         return output | ||||
|  | ||||
|     @views_blueprint.route("/diff/<string:uuid>", methods=['GET', 'POST']) | ||||
|     @login_optionally_required | ||||
|     def diff_history_page(uuid): | ||||
|         from changedetectionio import forms | ||||
|  | ||||
|         # More for testing, possible to return the first/only | ||||
|         if uuid == 'first': | ||||
|             uuid = list(datastore.data['watching'].keys()).pop() | ||||
|  | ||||
|         extra_stylesheets = [url_for('static_content', group='styles', filename='diff.css')] | ||||
|         try: | ||||
|             watch = datastore.data['watching'][uuid] | ||||
|         except KeyError: | ||||
|             flash("No history found for the specified link, bad link?", "error") | ||||
|             return redirect(url_for('index')) | ||||
|  | ||||
|         # For submission of requesting an extract | ||||
|         extract_form = forms.extractDataForm(request.form) | ||||
|         if request.method == 'POST': | ||||
|             if not extract_form.validate(): | ||||
|                 flash("An error occurred, please see below.", "error") | ||||
|  | ||||
|             else: | ||||
|                 extract_regex = request.form.get('extract_regex').strip() | ||||
|                 output = watch.extract_regex_from_all_history(extract_regex) | ||||
|                 if output: | ||||
|                     watch_dir = os.path.join(datastore.datastore_path, uuid) | ||||
|                     response = make_response(send_from_directory(directory=watch_dir, path=output, as_attachment=True)) | ||||
|                     response.headers['Content-type'] = 'text/csv' | ||||
|                     response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' | ||||
|                     response.headers['Pragma'] = 'no-cache' | ||||
|                     response.headers['Expires'] = 0 | ||||
|                     return response | ||||
|  | ||||
|                 flash('Nothing matches that RegEx', 'error') | ||||
|                 redirect(url_for('ui_views.diff_history_page', uuid=uuid)+'#extract') | ||||
|  | ||||
|         history = watch.history | ||||
|         dates = list(history.keys()) | ||||
|  | ||||
|         if len(dates) < 2: | ||||
|             flash("Not enough saved change detection snapshots to produce a report.", "error") | ||||
|             return redirect(url_for('index')) | ||||
|  | ||||
|         # Save the current newest history as the most recently viewed | ||||
|         datastore.set_last_viewed(uuid, time.time()) | ||||
|  | ||||
|         # Read as binary and force decode as UTF-8 | ||||
|         # Windows may fail decode in python if we just use 'r' mode (chardet decode exception) | ||||
|         from_version = request.args.get('from_version') | ||||
|         from_version_index = -2  # second newest | ||||
|         if from_version and from_version in dates: | ||||
|             from_version_index = dates.index(from_version) | ||||
|         else: | ||||
|             from_version = dates[from_version_index] | ||||
|  | ||||
|         try: | ||||
|             from_version_file_contents = watch.get_history_snapshot(dates[from_version_index]) | ||||
|         except Exception as e: | ||||
|             from_version_file_contents = f"Unable to read to-version at index {dates[from_version_index]}.\n" | ||||
|  | ||||
|         to_version = request.args.get('to_version') | ||||
|         to_version_index = -1 | ||||
|         if to_version and to_version in dates: | ||||
|             to_version_index = dates.index(to_version) | ||||
|         else: | ||||
|             to_version = dates[to_version_index] | ||||
|  | ||||
|         try: | ||||
|             to_version_file_contents = watch.get_history_snapshot(dates[to_version_index]) | ||||
|         except Exception as e: | ||||
|             to_version_file_contents = "Unable to read to-version at index{}.\n".format(dates[to_version_index]) | ||||
|  | ||||
|         screenshot_url = watch.get_screenshot() | ||||
|  | ||||
|         system_uses_webdriver = datastore.data['settings']['application']['fetch_backend'] == 'html_webdriver' | ||||
|  | ||||
|         is_html_webdriver = False | ||||
|         if (watch.get('fetch_backend') == 'system' and system_uses_webdriver) or watch.get('fetch_backend') == 'html_webdriver' or watch.get('fetch_backend', '').startswith('extra_browser_'): | ||||
|             is_html_webdriver = True | ||||
|  | ||||
|         password_enabled_and_share_is_off = False | ||||
|         if datastore.data['settings']['application'].get('password') or os.getenv("SALTED_PASS", False): | ||||
|             password_enabled_and_share_is_off = not datastore.data['settings']['application'].get('shared_diff_access') | ||||
|  | ||||
|         output = render_template("diff.html", | ||||
|                                  current_diff_url=watch['url'], | ||||
|                                  from_version=str(from_version), | ||||
|                                  to_version=str(to_version), | ||||
|                                  extra_stylesheets=extra_stylesheets, | ||||
|                                  extra_title=f" - Diff - {watch.label}", | ||||
|                                  extract_form=extract_form, | ||||
|                                  is_html_webdriver=is_html_webdriver, | ||||
|                                  last_error=watch['last_error'], | ||||
|                                  last_error_screenshot=watch.get_error_snapshot(), | ||||
|                                  last_error_text=watch.get_error_text(), | ||||
|                                  left_sticky=True, | ||||
|                                  newest=to_version_file_contents, | ||||
|                                  newest_version_timestamp=dates[-1], | ||||
|                                  password_enabled_and_share_is_off=password_enabled_and_share_is_off, | ||||
|                                  from_version_file_contents=from_version_file_contents, | ||||
|                                  to_version_file_contents=to_version_file_contents, | ||||
|                                  screenshot=screenshot_url, | ||||
|                                  uuid=uuid, | ||||
|                                  versions=dates, # All except current/last | ||||
|                                  watch_a=watch | ||||
|                                  ) | ||||
|  | ||||
|         return output | ||||
|  | ||||
|     @views_blueprint.route("/form/add/quickwatch", methods=['POST']) | ||||
|     @login_optionally_required | ||||
|     def form_quick_watch_add(): | ||||
|         from changedetectionio import forms | ||||
|         form = forms.quickWatchForm(request.form) | ||||
|  | ||||
|         if not form.validate(): | ||||
|             for widget, l in form.errors.items(): | ||||
|                 flash(','.join(l), 'error') | ||||
|             return redirect(url_for('index')) | ||||
|  | ||||
|         url = request.form.get('url').strip() | ||||
|         if datastore.url_exists(url): | ||||
|             flash(f'Warning, URL {url} already exists', "notice") | ||||
|  | ||||
|         add_paused = request.form.get('edit_and_watch_submit_button') != None | ||||
|         processor = request.form.get('processor', 'text_json_diff') | ||||
|         new_uuid = datastore.add_watch(url=url, tag=request.form.get('tags').strip(), extras={'paused': add_paused, 'processor': processor}) | ||||
|  | ||||
|         if new_uuid: | ||||
|             if add_paused: | ||||
|                 flash('Watch added in Paused state, saving will unpause.') | ||||
|                 return redirect(url_for('ui.ui_edit.edit_page', uuid=new_uuid, unpause_on_save=1, tag=request.args.get('tag'))) | ||||
|             else: | ||||
|                 # Straight into the queue. | ||||
|                 update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': new_uuid})) | ||||
|                 flash("Watch added.") | ||||
|  | ||||
|         return redirect(url_for('index', tag=request.args.get('tag',''))) | ||||
|  | ||||
|     return views_blueprint | ||||
							
								
								
									
										135
									
								
								changedetectionio/conditions/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,135 @@ | ||||
| from flask import Blueprint | ||||
|  | ||||
| from json_logic.builtins import BUILTINS | ||||
|  | ||||
| from .exceptions import EmptyConditionRuleRowNotUsable | ||||
| from .pluggy_interface import plugin_manager  # Import the pluggy plugin manager | ||||
| from . import default_plugin | ||||
|  | ||||
| # List of all supported JSON Logic operators | ||||
| operator_choices = [ | ||||
|     (None, "Choose one"), | ||||
|     (">", "Greater Than"), | ||||
|     ("<", "Less Than"), | ||||
|     (">=", "Greater Than or Equal To"), | ||||
|     ("<=", "Less Than or Equal To"), | ||||
|     ("==", "Equals"), | ||||
|     ("!=", "Not Equals"), | ||||
|     ("in", "Contains"), | ||||
|     ("!in", "Does Not Contain"), | ||||
| ] | ||||
|  | ||||
| # Fields available in the rules | ||||
| field_choices = [ | ||||
|     (None, "Choose one"), | ||||
| ] | ||||
|  | ||||
| # The data we will feed the JSON Rules to see if it passes the test/conditions or not | ||||
| EXECUTE_DATA = {} | ||||
|  | ||||
|  | ||||
| # Define the extended operations dictionary | ||||
| CUSTOM_OPERATIONS = { | ||||
|     **BUILTINS,  # Include all standard operators | ||||
| } | ||||
|  | ||||
| def filter_complete_rules(ruleset): | ||||
|     rules = [ | ||||
|         rule for rule in ruleset | ||||
|         if all(value not in ("", False, "None", None) for value in [rule["operator"], rule["field"], rule["value"]]) | ||||
|     ] | ||||
|     return rules | ||||
|  | ||||
| def convert_to_jsonlogic(logic_operator: str, rule_dict: list): | ||||
|     """ | ||||
|     Convert a structured rule dict into a JSON Logic rule. | ||||
|  | ||||
|     :param rule_dict: Dictionary containing conditions. | ||||
|     :return: JSON Logic rule as a dictionary. | ||||
|     """ | ||||
|  | ||||
|  | ||||
|     json_logic_conditions = [] | ||||
|  | ||||
|     for condition in rule_dict: | ||||
|         operator = condition["operator"] | ||||
|         field = condition["field"] | ||||
|         value = condition["value"] | ||||
|  | ||||
|         if not operator or operator == 'None' or not value or not field: | ||||
|             raise EmptyConditionRuleRowNotUsable() | ||||
|  | ||||
|         # Convert value to int/float if possible | ||||
|         try: | ||||
|             if isinstance(value, str) and "." in value and str != "None": | ||||
|                 value = float(value) | ||||
|             else: | ||||
|                 value = int(value) | ||||
|         except (ValueError, TypeError): | ||||
|             pass  # Keep as a string if conversion fails | ||||
|  | ||||
|         # Handle different JSON Logic operators properly | ||||
|         if operator == "in": | ||||
|             json_logic_conditions.append({"in": [value, {"var": field}]})  # value first | ||||
|         elif operator in ("!", "!!", "-"): | ||||
|             json_logic_conditions.append({operator: [{"var": field}]})  # Unary operators | ||||
|         elif operator in ("min", "max", "cat"): | ||||
|             json_logic_conditions.append({operator: value})  # Multi-argument operators | ||||
|         else: | ||||
|             json_logic_conditions.append({operator: [{"var": field}, value]})  # Standard binary operators | ||||
|  | ||||
|     return {logic_operator: json_logic_conditions} if len(json_logic_conditions) > 1 else json_logic_conditions[0] | ||||
|  | ||||
|  | ||||
| def execute_ruleset_against_all_plugins(current_watch_uuid: str, application_datastruct, ephemeral_data={} ): | ||||
|     """ | ||||
|     Build our data and options by calling our plugins then pass it to jsonlogic and see if the conditions pass | ||||
|  | ||||
|     :param ruleset: JSON Logic rule dictionary. | ||||
|     :param extracted_data: Dictionary containing the facts.   <-- maybe the app struct+uuid | ||||
|     :return: Dictionary of plugin results. | ||||
|     """ | ||||
|     from json_logic import jsonLogic | ||||
|  | ||||
|     EXECUTE_DATA = {} | ||||
|     result = True | ||||
|      | ||||
|     ruleset_settings = application_datastruct['watching'].get(current_watch_uuid) | ||||
|  | ||||
|     if ruleset_settings.get("conditions"): | ||||
|         logic_operator = "and" if ruleset_settings.get("conditions_match_logic", "ALL") == "ALL" else "or" | ||||
|         complete_rules = filter_complete_rules(ruleset_settings['conditions']) | ||||
|         if complete_rules: | ||||
|             # Give all plugins a chance to update the data dict again (that we will test the conditions against) | ||||
|             for plugin in plugin_manager.get_plugins(): | ||||
|                 new_execute_data = plugin.add_data(current_watch_uuid=current_watch_uuid, | ||||
|                                                    application_datastruct=application_datastruct, | ||||
|                                                    ephemeral_data=ephemeral_data) | ||||
|  | ||||
|                 if new_execute_data and isinstance(new_execute_data, dict): | ||||
|                     EXECUTE_DATA.update(new_execute_data) | ||||
|  | ||||
|             # Create the ruleset | ||||
|             ruleset = convert_to_jsonlogic(logic_operator=logic_operator, rule_dict=complete_rules) | ||||
|              | ||||
|             # Pass the custom operations dictionary to jsonLogic | ||||
|             if not jsonLogic(logic=ruleset, data=EXECUTE_DATA, operations=CUSTOM_OPERATIONS): | ||||
|                 result = False | ||||
|  | ||||
|     return result | ||||
|  | ||||
|  | ||||
| # Load plugins dynamically | ||||
| for plugin in plugin_manager.get_plugins(): | ||||
|     new_ops = plugin.register_operators() | ||||
|     if isinstance(new_ops, dict): | ||||
|         CUSTOM_OPERATIONS.update(new_ops) | ||||
|  | ||||
|     new_operator_choices = plugin.register_operator_choices() | ||||
|     if isinstance(new_operator_choices, list): | ||||
|         operator_choices.extend(new_operator_choices) | ||||
|  | ||||
|     new_field_choices = plugin.register_field_choices() | ||||
|     if isinstance(new_field_choices, list): | ||||
|         field_choices.extend(new_field_choices) | ||||
|  | ||||
							
								
								
									
										80
									
								
								changedetectionio/conditions/blueprint.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,80 @@ | ||||
| # Flask Blueprint Definition | ||||
| import json | ||||
|  | ||||
| from flask import Blueprint | ||||
|  | ||||
| from changedetectionio.conditions import execute_ruleset_against_all_plugins | ||||
|  | ||||
|  | ||||
| def construct_blueprint(datastore): | ||||
|     from changedetectionio.flask_app import login_optionally_required | ||||
|  | ||||
|     conditions_blueprint = Blueprint('conditions', __name__, template_folder="templates") | ||||
|  | ||||
|     @conditions_blueprint.route("/<string:watch_uuid>/verify-condition-single-rule", methods=['POST']) | ||||
|     @login_optionally_required | ||||
|     def verify_condition_single_rule(watch_uuid): | ||||
|         """Verify a single condition rule against the current snapshot""" | ||||
|         from changedetectionio.processors.text_json_diff import prepare_filter_prevew | ||||
|         from flask import request, jsonify | ||||
|         from copy import deepcopy | ||||
|  | ||||
|         ephemeral_data = {} | ||||
|  | ||||
|         # Get the watch data | ||||
|         watch = datastore.data['watching'].get(watch_uuid) | ||||
|         if not watch: | ||||
|             return jsonify({'status': 'error', 'message': 'Watch not found'}), 404 | ||||
|  | ||||
|         # First use prepare_filter_prevew to process the form data | ||||
|         # This will return text_after_filter which is after all current form settings are applied | ||||
|         # Create ephemeral data with the text from the current snapshot | ||||
|  | ||||
|         try: | ||||
|             # Call prepare_filter_prevew to get a processed version of the content with current form settings | ||||
|             # We'll ignore the returned response and just use the datastore which is modified by the function | ||||
|  | ||||
|             # this should apply all filters etc so then we can run the CONDITIONS against the final output text | ||||
|             result = prepare_filter_prevew(datastore=datastore, | ||||
|                                            form_data=request.form, | ||||
|                                            watch_uuid=watch_uuid) | ||||
|  | ||||
|             ephemeral_data['text'] = result.get('after_filter', '') | ||||
|             # Create a temporary watch data structure with this single rule | ||||
|             tmp_watch_data = deepcopy(datastore.data['watching'].get(watch_uuid)) | ||||
|  | ||||
|             # Override the conditions in the temporary watch | ||||
|             rule_json = request.args.get("rule") | ||||
|             rule = json.loads(rule_json) if rule_json else None | ||||
|  | ||||
|             # Should be key/value of field, operator, value | ||||
|             tmp_watch_data['conditions'] = [rule] | ||||
|             tmp_watch_data['conditions_match_logic'] = "ALL"  # Single rule, so use ALL | ||||
|  | ||||
|             # Create a temporary application data structure for the rule check | ||||
|             temp_app_data = { | ||||
|                 'watching': { | ||||
|                     watch_uuid: tmp_watch_data | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             # Execute the rule against the current snapshot with form data | ||||
|             result = execute_ruleset_against_all_plugins( | ||||
|                 current_watch_uuid=watch_uuid, | ||||
|                 application_datastruct=temp_app_data, | ||||
|                 ephemeral_data=ephemeral_data | ||||
|             ) | ||||
|  | ||||
|             return jsonify({ | ||||
|                 'status': 'success', | ||||
|                 'result': result, | ||||
|                 'message': 'Condition passes' if result else 'Condition does not pass' | ||||
|             }) | ||||
|  | ||||
|         except Exception as e: | ||||
|             return jsonify({ | ||||
|                 'status': 'error', | ||||
|                 'message': f'Error verifying condition: {str(e)}' | ||||
|             }), 500 | ||||
|  | ||||
|     return conditions_blueprint | ||||
							
								
								
									
										78
									
								
								changedetectionio/conditions/default_plugin.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,78 @@ | ||||
| import re | ||||
|  | ||||
| import pluggy | ||||
| from price_parser import Price | ||||
| from loguru import logger | ||||
|  | ||||
| hookimpl = pluggy.HookimplMarker("changedetectionio_conditions") | ||||
|  | ||||
|  | ||||
| @hookimpl | ||||
| def register_operators(): | ||||
|     def starts_with(_, text, prefix): | ||||
|         return text.lower().strip().startswith(str(prefix).strip().lower()) | ||||
|  | ||||
|     def ends_with(_, text, suffix): | ||||
|         return text.lower().strip().endswith(str(suffix).strip().lower()) | ||||
|  | ||||
|     def length_min(_, text, strlen): | ||||
|         return len(text) >= int(strlen) | ||||
|  | ||||
|     def length_max(_, text, strlen): | ||||
|         return len(text) <= int(strlen) | ||||
|  | ||||
|     # ✅ Custom function for case-insensitive regex matching | ||||
|     def contains_regex(_, text, pattern): | ||||
|         """Returns True if `text` contains `pattern` (case-insensitive regex match).""" | ||||
|         return bool(re.search(pattern, str(text), re.IGNORECASE)) | ||||
|  | ||||
|     # ✅ Custom function for NOT matching case-insensitive regex | ||||
|     def not_contains_regex(_, text, pattern): | ||||
|         """Returns True if `text` does NOT contain `pattern` (case-insensitive regex match).""" | ||||
|         return not bool(re.search(pattern, str(text), re.IGNORECASE)) | ||||
|  | ||||
|     return { | ||||
|         "!contains_regex": not_contains_regex, | ||||
|         "contains_regex": contains_regex, | ||||
|         "ends_with": ends_with, | ||||
|         "length_max": length_max, | ||||
|         "length_min": length_min, | ||||
|         "starts_with": starts_with, | ||||
|     } | ||||
|  | ||||
| @hookimpl | ||||
| def register_operator_choices(): | ||||
|     return [ | ||||
|         ("starts_with", "Text Starts With"), | ||||
|         ("ends_with", "Text Ends With"), | ||||
|         ("length_min", "Length minimum"), | ||||
|         ("length_max", "Length maximum"), | ||||
|         ("contains_regex", "Text Matches Regex"), | ||||
|         ("!contains_regex", "Text Does NOT Match Regex"), | ||||
|     ] | ||||
|  | ||||
| @hookimpl | ||||
| def register_field_choices(): | ||||
|     return [ | ||||
|         ("extracted_number", "Extracted number after 'Filters & Triggers'"), | ||||
| #        ("meta_description", "Meta Description"), | ||||
| #        ("meta_keywords", "Meta Keywords"), | ||||
|         ("page_filtered_text", "Page text after 'Filters & Triggers'"), | ||||
|         #("page_title", "Page <title>"), # actual page title <title> | ||||
|     ] | ||||
|  | ||||
| @hookimpl | ||||
| def add_data(current_watch_uuid, application_datastruct, ephemeral_data): | ||||
|  | ||||
|     res = {} | ||||
|     if 'text' in ephemeral_data: | ||||
|         res['page_filtered_text'] = ephemeral_data['text'] | ||||
|  | ||||
|         # Better to not wrap this in try/except so that the UI can see any errors | ||||
|         price = Price.fromstring(ephemeral_data.get('text')) | ||||
|         if price and price.amount != None: | ||||
|             # This is slightly misleading, it's extracting a PRICE not a Number.. | ||||
|             res['extracted_number'] = float(price.amount) | ||||
|             logger.debug(f"Extracted number result: '{price}' - returning float({res['extracted_number']})") | ||||
|  | ||||
|     return res | ||||
							
								
								
									
										6
									
								
								changedetectionio/conditions/exceptions.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,6 @@ | ||||
| class EmptyConditionRuleRowNotUsable(Exception): | ||||
|     def __init__(self): | ||||
|         super().__init__("One of the 'conditions' rulesets is incomplete, cannot run.") | ||||
|  | ||||
|     def __str__(self): | ||||
|         return self.args[0] | ||||
							
								
								
									
										44
									
								
								changedetectionio/conditions/form.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,44 @@ | ||||
| # Condition Rule Form (for each rule row) | ||||
| from wtforms import Form, SelectField, StringField, validators | ||||
| from wtforms import validators | ||||
|  | ||||
| class ConditionFormRow(Form): | ||||
|  | ||||
|     # ✅ Ensure Plugins Are Loaded BEFORE Importing Choices | ||||
|     from changedetectionio.conditions import plugin_manager | ||||
|     from changedetectionio.conditions import operator_choices, field_choices | ||||
|     field = SelectField( | ||||
|         "Field", | ||||
|         choices=field_choices, | ||||
|         validators=[validators.Optional()] | ||||
|     ) | ||||
|  | ||||
|     operator = SelectField( | ||||
|         "Operator", | ||||
|         choices=operator_choices, | ||||
|         validators=[validators.Optional()] | ||||
|     ) | ||||
|  | ||||
|     value = StringField("Value", validators=[validators.Optional()]) | ||||
|  | ||||
|     def validate(self, extra_validators=None): | ||||
|         # First, run the default validators | ||||
|         if not super().validate(extra_validators): | ||||
|             return False | ||||
|  | ||||
|         # Custom validation logic | ||||
|         # If any of the operator/field/value is set, then they must be all set | ||||
|         if any(value not in ("", False, "None", None) for value in [self.operator.data, self.field.data, self.value.data]): | ||||
|             if not self.operator.data or self.operator.data == 'None': | ||||
|                 self.operator.errors.append("Operator is required.") | ||||
|                 return False | ||||
|  | ||||
|             if not self.field.data or self.field.data == 'None': | ||||
|                 self.field.errors.append("Field is required.") | ||||
|                 return False | ||||
|  | ||||
|             if not self.value.data: | ||||
|                 self.value.errors.append("Value is required.") | ||||
|                 return False | ||||
|  | ||||
|         return True  # Only return True if all conditions pass | ||||
							
								
								
									
										44
									
								
								changedetectionio/conditions/pluggy_interface.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,44 @@ | ||||
| import pluggy | ||||
| from . import default_plugin  # Import the default plugin | ||||
|  | ||||
| # ✅ Ensure that the namespace in HookspecMarker matches PluginManager | ||||
| PLUGIN_NAMESPACE = "changedetectionio_conditions" | ||||
|  | ||||
| hookspec = pluggy.HookspecMarker(PLUGIN_NAMESPACE) | ||||
| hookimpl = pluggy.HookimplMarker(PLUGIN_NAMESPACE) | ||||
|  | ||||
|  | ||||
| class ConditionsSpec: | ||||
|     """Hook specifications for extending JSON Logic conditions.""" | ||||
|  | ||||
|     @hookspec | ||||
|     def register_operators(): | ||||
|         """Return a dictionary of new JSON Logic operators.""" | ||||
|         pass | ||||
|  | ||||
|     @hookspec | ||||
|     def register_operator_choices(): | ||||
|         """Return a list of new operator choices.""" | ||||
|         pass | ||||
|  | ||||
|     @hookspec | ||||
|     def register_field_choices(): | ||||
|         """Return a list of new field choices.""" | ||||
|         pass | ||||
|  | ||||
|     @hookspec | ||||
|     def add_data(current_watch_uuid, application_datastruct, ephemeral_data): | ||||
|         """Add to the datadict""" | ||||
|         pass | ||||
|  | ||||
| # ✅ Set up Pluggy Plugin Manager | ||||
| plugin_manager = pluggy.PluginManager(PLUGIN_NAMESPACE) | ||||
|  | ||||
| # ✅ Register hookspecs (Ensures they are detected) | ||||
| plugin_manager.add_hookspecs(ConditionsSpec) | ||||
|  | ||||
| # ✅ Register built-in plugins manually | ||||
| plugin_manager.register(default_plugin, "default_plugin") | ||||
|  | ||||
| # ✅ Discover installed plugins from external packages (if any) | ||||
| plugin_manager.load_setuptools_entrypoints(PLUGIN_NAMESPACE) | ||||
							
								
								
									
										104
									
								
								changedetectionio/content_fetchers/helpers.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,104 @@ | ||||
|  | ||||
| # Pages with a vertical height longer than this will use the 'stitch together' method. | ||||
|  | ||||
| # - Many GPUs have a max texture size of 16384x16384px (or lower on older devices). | ||||
| # - If a page is taller than ~8000–10000px, it risks exceeding GPU memory limits. | ||||
| # - This is especially important on headless Chromium, where Playwright may fail to allocate a massive full-page buffer. | ||||
|  | ||||
|  | ||||
| # The size at which we will switch to stitching method | ||||
| SCREENSHOT_SIZE_STITCH_THRESHOLD=8000 | ||||
|  | ||||
| from loguru import logger | ||||
|  | ||||
| def capture_stitched_together_full_page(page): | ||||
|     import io | ||||
|     import os | ||||
|     import time | ||||
|     from PIL import Image, ImageDraw, ImageFont | ||||
|  | ||||
|     MAX_TOTAL_HEIGHT = SCREENSHOT_SIZE_STITCH_THRESHOLD*4  # Maximum total height for the final image (When in stitch mode) | ||||
|     MAX_CHUNK_HEIGHT = 4000  # Height per screenshot chunk | ||||
|     WARNING_TEXT_HEIGHT = 20  # Height of the warning text overlay | ||||
|  | ||||
|     # Save the original viewport size | ||||
|     original_viewport = page.viewport_size | ||||
|     now = time.time() | ||||
|  | ||||
|     try: | ||||
|         viewport = page.viewport_size | ||||
|         page_height = page.evaluate("document.documentElement.scrollHeight") | ||||
|  | ||||
|         # Limit the total capture height | ||||
|         capture_height = min(page_height, MAX_TOTAL_HEIGHT) | ||||
|  | ||||
|         images = [] | ||||
|         total_captured_height = 0 | ||||
|  | ||||
|         for offset in range(0, capture_height, MAX_CHUNK_HEIGHT): | ||||
|             # Ensure we do not exceed the total height limit | ||||
|             chunk_height = min(MAX_CHUNK_HEIGHT, MAX_TOTAL_HEIGHT - total_captured_height) | ||||
|  | ||||
|             # Adjust viewport size for this chunk | ||||
|             page.set_viewport_size({"width": viewport["width"], "height": chunk_height}) | ||||
|  | ||||
|             # Scroll to the correct position | ||||
|             page.evaluate(f"window.scrollTo(0, {offset})") | ||||
|  | ||||
|             # Capture screenshot chunk | ||||
|             screenshot_bytes = page.screenshot(type='jpeg', quality=int(os.getenv("SCREENSHOT_QUALITY", 30))) | ||||
|             images.append(Image.open(io.BytesIO(screenshot_bytes))) | ||||
|  | ||||
|             total_captured_height += chunk_height | ||||
|  | ||||
|             # Stop if we reached the maximum total height | ||||
|             if total_captured_height >= MAX_TOTAL_HEIGHT: | ||||
|                 break | ||||
|  | ||||
|         # Create the final stitched image | ||||
|         stitched_image = Image.new('RGB', (viewport["width"], total_captured_height)) | ||||
|         y_offset = 0 | ||||
|  | ||||
|         # Stitch the screenshot chunks together | ||||
|         for img in images: | ||||
|             stitched_image.paste(img, (0, y_offset)) | ||||
|             y_offset += img.height | ||||
|  | ||||
|         logger.debug(f"Screenshot stitched together in {time.time()-now:.2f}s") | ||||
|  | ||||
|         # Overlay warning text if the screenshot was trimmed | ||||
|         if page_height > MAX_TOTAL_HEIGHT: | ||||
|             draw = ImageDraw.Draw(stitched_image) | ||||
|             warning_text = f"WARNING: Screenshot was {page_height}px but trimmed to {MAX_TOTAL_HEIGHT}px because it was too long" | ||||
|  | ||||
|             # Load font (default system font if Arial is unavailable) | ||||
|             try: | ||||
|                 font = ImageFont.truetype("arial.ttf", WARNING_TEXT_HEIGHT)  # Arial (Windows/Mac) | ||||
|             except IOError: | ||||
|                 font = ImageFont.load_default()  # Default font if Arial not found | ||||
|  | ||||
|             # Get text bounding box (correct method for newer Pillow versions) | ||||
|             text_bbox = draw.textbbox((0, 0), warning_text, font=font) | ||||
|             text_width = text_bbox[2] - text_bbox[0]  # Calculate text width | ||||
|             text_height = text_bbox[3] - text_bbox[1]  # Calculate text height | ||||
|  | ||||
|             # Define background rectangle (top of the image) | ||||
|             draw.rectangle([(0, 0), (viewport["width"], WARNING_TEXT_HEIGHT)], fill="white") | ||||
|  | ||||
|             # Center text horizontally within the warning area | ||||
|             text_x = (viewport["width"] - text_width) // 2 | ||||
|             text_y = (WARNING_TEXT_HEIGHT - text_height) // 2 | ||||
|  | ||||
|             # Draw the warning text in red | ||||
|             draw.text((text_x, text_y), warning_text, fill="red", font=font) | ||||
|  | ||||
|         # Save or return the final image | ||||
|         output = io.BytesIO() | ||||
|         stitched_image.save(output, format="JPEG", quality=int(os.getenv("SCREENSHOT_QUALITY", 30))) | ||||
|         screenshot = output.getvalue() | ||||
|  | ||||
|     finally: | ||||
|         # Restore the original viewport size | ||||
|         page.set_viewport_size(original_viewport) | ||||
|  | ||||
|     return screenshot | ||||
| @@ -4,6 +4,7 @@ from urllib.parse import urlparse | ||||
|  | ||||
| from loguru import logger | ||||
|  | ||||
| from changedetectionio.content_fetchers.helpers import capture_stitched_together_full_page, SCREENSHOT_SIZE_STITCH_THRESHOLD | ||||
| from changedetectionio.content_fetchers.base import Fetcher, manage_user_agent | ||||
| from changedetectionio.content_fetchers.exceptions import PageUnloadable, Non200ErrorCodeReceived, EmptyReply, ScreenshotUnavailable | ||||
|  | ||||
| @@ -89,6 +90,7 @@ class fetcher(Fetcher): | ||||
|         from playwright.sync_api import sync_playwright | ||||
|         import playwright._impl._errors | ||||
|         from changedetectionio.content_fetchers import visualselector_xpath_selectors | ||||
|         import time | ||||
|         self.delete_browser_steps_screenshots() | ||||
|         response = None | ||||
|  | ||||
| @@ -179,6 +181,7 @@ class fetcher(Fetcher): | ||||
|  | ||||
|             self.page.wait_for_timeout(extra_wait * 1000) | ||||
|  | ||||
|             now = time.time() | ||||
|             # So we can find an element on the page where its selector was entered manually (maybe not xPath etc) | ||||
|             if current_include_filters is not None: | ||||
|                 self.page.evaluate("var include_filters={}".format(json.dumps(current_include_filters))) | ||||
| @@ -190,6 +193,8 @@ class fetcher(Fetcher): | ||||
|             self.instock_data = self.page.evaluate("async () => {" + self.instock_data_js + "}") | ||||
|  | ||||
|             self.content = self.page.content() | ||||
|             logger.debug(f"Time to scrape xpath element data in browser {time.time() - now:.2f}s") | ||||
|  | ||||
|             # Bug 3 in Playwright screenshot handling | ||||
|             # Some bug where it gives the wrong screenshot size, but making a request with the clip set first seems to solve it | ||||
|             # JPEG is better here because the screenshots can be very very large | ||||
| @@ -199,10 +204,15 @@ class fetcher(Fetcher): | ||||
|             # acceptable screenshot quality here | ||||
|             try: | ||||
|                 # The actual screenshot - this always base64 and needs decoding! horrible! huge CPU usage | ||||
|                 self.screenshot = self.page.screenshot(type='jpeg', | ||||
|                                                        full_page=True, | ||||
|                                                        quality=int(os.getenv("SCREENSHOT_QUALITY", 72)), | ||||
|                                                        ) | ||||
|                 full_height = self.page.evaluate("document.documentElement.scrollHeight") | ||||
|  | ||||
|                 if full_height >= SCREENSHOT_SIZE_STITCH_THRESHOLD: | ||||
|                     logger.warning( | ||||
|                         f"Page full Height: {full_height}px longer than {SCREENSHOT_SIZE_STITCH_THRESHOLD}px, using 'stitched screenshot method'.") | ||||
|                     self.screenshot = capture_stitched_together_full_page(self.page) | ||||
|                 else: | ||||
|                     self.screenshot = self.page.screenshot(type='jpeg', full_page=True, quality=int(os.getenv("SCREENSHOT_QUALITY", 30))) | ||||
|  | ||||
|             except Exception as e: | ||||
|                 # It's likely the screenshot was too long/big and something crashed | ||||
|                 raise ScreenshotUnavailable(url=url, status_code=self.status_code) | ||||
|   | ||||
| @@ -29,8 +29,11 @@ function isItemInStock() { | ||||
|         'currently unavailable', | ||||
|         'dieser artikel ist bald wieder verfügbar', | ||||
|         'dostępne wkrótce', | ||||
|         'en rupture', | ||||
|         'en rupture de stock', | ||||
|         'épuisé', | ||||
|         'esgotado', | ||||
|         'indisponible', | ||||
|         'indisponível', | ||||
|         'isn\'t in stock right now', | ||||
|         'isnt in stock right now', | ||||
| @@ -53,6 +56,7 @@ function isItemInStock() { | ||||
|         'niet op voorraad', | ||||
|         'no disponible', | ||||
|         'non disponibile', | ||||
|         'non disponible', | ||||
|         'no longer in stock', | ||||
|         'no tickets available', | ||||
|         'not available', | ||||
| @@ -65,8 +69,10 @@ function isItemInStock() { | ||||
|         'não estamos a aceitar encomendas', | ||||
|         'out of stock', | ||||
|         'out-of-stock', | ||||
|         'plus disponible', | ||||
|         'prodotto esaurito', | ||||
|         'produkt niedostępny', | ||||
|         'rupture', | ||||
|         'sold out', | ||||
|         'sold-out', | ||||
|         'stokta yok', | ||||
|   | ||||
| @@ -41,7 +41,7 @@ const findUpTag = (el) => { | ||||
|  | ||||
|     //  Strategy 1: If it's an input, with name, and there's only one, prefer that | ||||
|     if (el.name !== undefined && el.name.length) { | ||||
|         var proposed = el.tagName + "[name=" + el.name + "]"; | ||||
|         var proposed = el.tagName + "[name=\"" + CSS.escape(el.name) + "\"]"; | ||||
|         var proposed_element = window.document.querySelectorAll(proposed); | ||||
|         if (proposed_element.length) { | ||||
|             if (proposed_element.length === 1) { | ||||
| @@ -102,13 +102,15 @@ function collectVisibleElements(parent, visibleElements) { | ||||
|     const children = parent.children; | ||||
|     for (let i = 0; i < children.length; i++) { | ||||
|         const child = children[i]; | ||||
|         const computedStyle = window.getComputedStyle(child); | ||||
|  | ||||
|         if ( | ||||
|             child.nodeType === Node.ELEMENT_NODE && | ||||
|             window.getComputedStyle(child).display !== 'none' && | ||||
|             window.getComputedStyle(child).visibility !== 'hidden' && | ||||
|             computedStyle.display !== 'none' && | ||||
|             computedStyle.visibility !== 'hidden' && | ||||
|             child.offsetWidth >= 0 && | ||||
|             child.offsetHeight >= 0 && | ||||
|             window.getComputedStyle(child).contentVisibility !== 'hidden' | ||||
|             computedStyle.contentVisibility !== 'hidden' | ||||
|         ) { | ||||
|             // If the child is an element and is visible, recursively collect visible elements | ||||
|             collectVisibleElements(child, visibleElements); | ||||
| @@ -173,6 +175,7 @@ visibleElementsArray.forEach(function (element) { | ||||
|  | ||||
|     // Try to identify any possible currency amounts "Sale: 4000" or "Sale now 3000 Kc", can help with the training. | ||||
|     const hasDigitCurrency = (/\d/.test(text.slice(0, 6)) || /\d/.test(text.slice(-6)) ) &&  /([€£$¥₩₹]|USD|AUD|EUR|Kč|kr|SEK|,–)/.test(text) ; | ||||
|     const computedStyle = window.getComputedStyle(element); | ||||
|  | ||||
|     size_pos.push({ | ||||
|         xpath: xpath_result, | ||||
| @@ -184,10 +187,10 @@ visibleElementsArray.forEach(function (element) { | ||||
|         tagName: (element.tagName) ? element.tagName.toLowerCase() : '', | ||||
|         // tagtype used by Browser Steps | ||||
|         tagtype: (element.tagName.toLowerCase() === 'input' && element.type) ? element.type.toLowerCase() : '', | ||||
|         isClickable: window.getComputedStyle(element).cursor === "pointer", | ||||
|         isClickable: computedStyle.cursor === "pointer", | ||||
|         // Used by the keras trainer | ||||
|         fontSize: window.getComputedStyle(element).getPropertyValue('font-size'), | ||||
|         fontWeight: window.getComputedStyle(element).getPropertyValue('font-weight'), | ||||
|         fontSize: computedStyle.getPropertyValue('font-size'), | ||||
|         fontWeight: computedStyle.getPropertyValue('font-weight'), | ||||
|         hasDigitCurrency: hasDigitCurrency, | ||||
|         label: label, | ||||
|     }); | ||||
|   | ||||
| @@ -3,6 +3,7 @@ import re | ||||
| from loguru import logger | ||||
| from wtforms.widgets.core import TimeInput | ||||
|  | ||||
| from changedetectionio.conditions.form import ConditionFormRow | ||||
| from changedetectionio.strtobool import strtobool | ||||
|  | ||||
| from wtforms import ( | ||||
| @@ -171,7 +172,7 @@ class validateTimeZoneName(object): | ||||
|  | ||||
| class ScheduleLimitDaySubForm(Form): | ||||
|     enabled = BooleanField("not set", default=True) | ||||
|     start_time = TimeStringField("Start At", default="00:00", render_kw={"placeholder": "HH:MM"}, validators=[validators.Optional()]) | ||||
|     start_time = TimeStringField("Start At", default="00:00", validators=[validators.Optional()]) | ||||
|     duration = FormField(TimeDurationForm, label="Run duration") | ||||
|  | ||||
| class ScheduleLimitForm(Form): | ||||
| @@ -305,8 +306,10 @@ class ValidateAppRiseServers(object): | ||||
|     def __call__(self, form, field): | ||||
|         import apprise | ||||
|         apobj = apprise.Apprise() | ||||
|  | ||||
|         # so that the custom endpoints are registered | ||||
|         from changedetectionio.apprise_plugin import apprise_custom_api_call_wrapper | ||||
|         from .apprise_asset import asset | ||||
|  | ||||
|         for server_url in field.data: | ||||
|             url = server_url.strip() | ||||
|             if url.startswith("#"): | ||||
| @@ -509,6 +512,7 @@ class quickWatchForm(Form): | ||||
|     edit_and_watch_submit_button = SubmitField('Edit > Watch', render_kw={"class": "pure-button pure-button-primary"}) | ||||
|  | ||||
|  | ||||
|  | ||||
| # Common to a single watch and the global settings | ||||
| class commonSettingsForm(Form): | ||||
|     from . import processors | ||||
| @@ -596,6 +600,10 @@ class processor_text_json_diff_form(commonSettingsForm): | ||||
|     notification_muted = BooleanField('Notifications Muted / Off', default=False) | ||||
|     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 | ||||
|  | ||||
|  | ||||
|     def extra_tab_content(self): | ||||
|         return None | ||||
|  | ||||
|   | ||||
| @@ -83,7 +83,7 @@ class model(watch_base): | ||||
|                     flash, Markup, url_for | ||||
|                 ) | ||||
|                 message = Markup('<a href="{}#general">The URL {} is invalid and cannot be used, click to edit</a>'.format( | ||||
|                     url_for('edit_page', uuid=self.get('uuid')), self.get('url', ''))) | ||||
|                     url_for('ui.ui_edit.edit_page', uuid=self.get('uuid')), self.get('url', ''))) | ||||
|                 flash(message, 'error') | ||||
|                 return '' | ||||
|  | ||||
| @@ -296,11 +296,11 @@ class model(watch_base): | ||||
|         with open(filepath, 'r', encoding='utf-8', errors='ignore') as f: | ||||
|             return f.read() | ||||
|  | ||||
|     # Save some text file to the appropriate path and bump the history | ||||
|    # Save some text file to the appropriate path and bump the history | ||||
|     # result_obj from fetch_site_status.run() | ||||
|     def save_history_text(self, contents, timestamp, snapshot_id): | ||||
|         import brotli | ||||
|  | ||||
|         import tempfile | ||||
|         logger.trace(f"{self.get('uuid')} - Updating history.txt with timestamp {timestamp}") | ||||
|  | ||||
|         self.ensure_data_dir_exists() | ||||
| @@ -308,26 +308,37 @@ class model(watch_base): | ||||
|         threshold = int(os.getenv('SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD', 1024)) | ||||
|         skip_brotli = strtobool(os.getenv('DISABLE_BROTLI_TEXT_SNAPSHOT', 'False')) | ||||
|  | ||||
|         # Decide on snapshot filename and destination path | ||||
|         if not skip_brotli and len(contents) > threshold: | ||||
|             snapshot_fname = f"{snapshot_id}.txt.br" | ||||
|             dest = os.path.join(self.watch_data_dir, snapshot_fname) | ||||
|             if not os.path.exists(dest): | ||||
|                 with open(dest, 'wb') as f: | ||||
|                     f.write(brotli.compress(contents.encode('utf-8'), mode=brotli.MODE_TEXT)) | ||||
|             encoded_data = brotli.compress(contents.encode('utf-8'), mode=brotli.MODE_TEXT) | ||||
|         else: | ||||
|             snapshot_fname = f"{snapshot_id}.txt" | ||||
|             dest = os.path.join(self.watch_data_dir, snapshot_fname) | ||||
|             if not os.path.exists(dest): | ||||
|                 with open(dest, 'wb') as f: | ||||
|                     f.write(contents.encode('utf-8')) | ||||
|             encoded_data = contents.encode('utf-8') | ||||
|  | ||||
|         # Append to index | ||||
|         # @todo check last char was \n | ||||
|         dest = os.path.join(self.watch_data_dir, snapshot_fname) | ||||
|  | ||||
|         # Write snapshot file atomically if it doesn't exist | ||||
|         if not os.path.exists(dest): | ||||
|             with tempfile.NamedTemporaryFile('wb', delete=False, dir=self.watch_data_dir) as tmp: | ||||
|                 tmp.write(encoded_data) | ||||
|                 tmp.flush() | ||||
|                 os.fsync(tmp.fileno()) | ||||
|                 tmp_path = tmp.name | ||||
|             os.rename(tmp_path, dest) | ||||
|  | ||||
|         # Append to history.txt atomically | ||||
|         index_fname = os.path.join(self.watch_data_dir, "history.txt") | ||||
|         with open(index_fname, 'a') as f: | ||||
|             f.write("{},{}\n".format(timestamp, snapshot_fname)) | ||||
|             f.close() | ||||
|         index_line = f"{timestamp},{snapshot_fname}\n" | ||||
|  | ||||
|         # Lets try force flush here since it's usually a very small file | ||||
|         # If this still fails in the future then try reading all to memory first, re-writing etc | ||||
|         with open(index_fname, 'a', encoding='utf-8') as f: | ||||
|             f.write(index_line) | ||||
|             f.flush() | ||||
|             os.fsync(f.fileno()) | ||||
|  | ||||
|         # Update internal state | ||||
|         self.__newest_history_key = timestamp | ||||
|         self.__history_n += 1 | ||||
|  | ||||
| @@ -527,7 +538,7 @@ class model(watch_base): | ||||
|     def save_error_text(self, contents): | ||||
|         self.ensure_data_dir_exists() | ||||
|         target_path = os.path.join(self.watch_data_dir, "last-error.txt") | ||||
|         with open(target_path, 'w') as f: | ||||
|         with open(target_path, 'w', encoding='utf-8') as f: | ||||
|             f.write(contents) | ||||
|  | ||||
|     def save_xpath_data(self, data, as_error=False): | ||||
|   | ||||
| @@ -28,13 +28,13 @@ def _task(watch, update_handler): | ||||
|     return text_after_filter | ||||
|  | ||||
|  | ||||
| def prepare_filter_prevew(datastore, watch_uuid): | ||||
| def prepare_filter_prevew(datastore, watch_uuid, form_data): | ||||
|     '''Used by @app.route("/edit/<string:uuid>/preview-rendered", methods=['POST'])''' | ||||
|     from changedetectionio import forms, html_tools | ||||
|     from changedetectionio.model.Watch import model as watch_model | ||||
|     from concurrent.futures import ProcessPoolExecutor | ||||
|     from copy import deepcopy | ||||
|     from flask import request, jsonify | ||||
|     from flask import request | ||||
|     import brotli | ||||
|     import importlib | ||||
|     import os | ||||
| @@ -50,12 +50,12 @@ def prepare_filter_prevew(datastore, watch_uuid): | ||||
|  | ||||
|     if tmp_watch and tmp_watch.history and os.path.isdir(tmp_watch.watch_data_dir): | ||||
|         # Splice in the temporary stuff from the form | ||||
|         form = forms.processor_text_json_diff_form(formdata=request.form if request.method == 'POST' else None, | ||||
|                                                    data=request.form | ||||
|         form = forms.processor_text_json_diff_form(formdata=form_data if request.method == 'POST' else None, | ||||
|                                                    data=form_data | ||||
|                                                    ) | ||||
|  | ||||
|         # Only update vars that came in via the AJAX post | ||||
|         p = {k: v for k, v in form.data.items() if k in request.form.keys()} | ||||
|         p = {k: v for k, v in form.data.items() if k in form_data.keys()} | ||||
|         tmp_watch.update(p) | ||||
|         blank_watch_no_filters = watch_model() | ||||
|         blank_watch_no_filters['url'] = tmp_watch.get('url') | ||||
| @@ -103,13 +103,12 @@ def prepare_filter_prevew(datastore, watch_uuid): | ||||
|  | ||||
|     logger.trace(f"Parsed in {time.time() - now:.3f}s") | ||||
|  | ||||
|     return jsonify( | ||||
|         { | ||||
|     return ({ | ||||
|             'after_filter': text_after_filter, | ||||
|             'before_filter': text_before_filter.decode('utf-8') if isinstance(text_before_filter, bytes) else text_before_filter, | ||||
|             'duration': time.time() - now, | ||||
|             'trigger_line_numbers': trigger_line_numbers, | ||||
|             'ignore_line_numbers': ignore_line_numbers, | ||||
|         } | ||||
|     ) | ||||
|         }) | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -6,6 +6,7 @@ import os | ||||
| import re | ||||
| import urllib3 | ||||
|  | ||||
| from changedetectionio.conditions import execute_ruleset_against_all_plugins | ||||
| from changedetectionio.processors import difference_detection_processor | ||||
| from changedetectionio.html_tools import PERL_STYLE_REGEX, cdata_in_document_to_text, TRANSLATE_WHITESPACE_TABLE | ||||
| from changedetectionio import html_tools, content_fetchers | ||||
| @@ -331,6 +332,16 @@ class perform_site_check(difference_detection_processor): | ||||
|             if result: | ||||
|                 blocked = True | ||||
|  | ||||
|         # And check if 'conditions' will let this pass through | ||||
|         if watch.get('conditions') and watch.get('conditions_match_logic'): | ||||
|             if not execute_ruleset_against_all_plugins(current_watch_uuid=watch.get('uuid'), | ||||
|                                                 application_datastruct=self.datastore.data, | ||||
|                                                 ephemeral_data={ | ||||
|                                                     'text': stripped_text_from_html | ||||
|                                                 } | ||||
|                                                 ): | ||||
|                 # Conditions say "Condition not met" so we block it. | ||||
|                 blocked = True | ||||
|  | ||||
|         # Looks like something changed, but did it match all the rules? | ||||
|         if blocked: | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||
| <svg | ||||
|    version="1.1" | ||||
|    id="Layer_1" | ||||
|    id="copy" | ||||
|    x="0px" | ||||
|    y="0px" | ||||
|    viewBox="0 0 115.77 122.88" | ||||
|   | ||||
| Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB | 
| @@ -6,7 +6,7 @@ | ||||
|    height="7.5005589" | ||||
|    width="11.248507" | ||||
|    version="1.1" | ||||
|    id="Layer_1" | ||||
|    id="email" | ||||
|    viewBox="0 0 7.1975545 4.7993639" | ||||
|    xml:space="preserve" | ||||
|    xmlns="http://www.w3.org/2000/svg" | ||||
|   | ||||
| Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB | 
| @@ -1,7 +1,7 @@ | ||||
| <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||
| <svg | ||||
|    version="1.1" | ||||
|    id="Layer_1" | ||||
|    id="schedule" | ||||
|    x="0px" | ||||
|    y="0px" | ||||
|    viewBox="0 0 661.20001 665.40002" | ||||
|   | ||||
| Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB | 
| @@ -221,7 +221,7 @@ $(document).ready(function () { | ||||
|                     // If you switch to "Click X,y" after an element here is setup, it will give the last co-ords anyway | ||||
|                     //if (x['isClickable'] || x['tagName'].startsWith('h') || x['tagName'] === 'a' || x['tagName'] === 'button' || x['tagtype'] === 'submit' || x['tagtype'] === 'checkbox' || x['tagtype'] === 'radio' || x['tagtype'] === 'li') { | ||||
|                         $('select', first_available).val('Click element').change(); | ||||
|                         $('input[type=text]', first_available).first().val(x['xpath']); | ||||
|                         $('input[type=text]', first_available).first().val(x['xpath']).focus(); | ||||
|                         found_something = true; | ||||
|                     //} | ||||
|                 } | ||||
| @@ -305,7 +305,7 @@ $(document).ready(function () { | ||||
|  | ||||
|         if ($(this).val() === 'Click X,Y' && last_click_xy['x'] > 0 && $(elem_value).val().length === 0) { | ||||
|             // @todo handle scale | ||||
|             $(elem_value).val(last_click_xy['x'] + ',' + last_click_xy['y']); | ||||
|             $(elem_value).val(last_click_xy['x'] + ',' + last_click_xy['y']).focus(); | ||||
|         } | ||||
|     }).change(); | ||||
|  | ||||
|   | ||||
							
								
								
									
										150
									
								
								changedetectionio/static/js/conditions.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,150 @@ | ||||
| $(document).ready(function () { | ||||
|     // Function to set up button event handlers | ||||
|     function setupButtonHandlers() { | ||||
|         // Unbind existing handlers first to prevent duplicates | ||||
|         $(".addRuleRow, .removeRuleRow, .verifyRuleRow").off("click"); | ||||
|          | ||||
|         // Add row button handler | ||||
|         $(".addRuleRow").on("click", function(e) { | ||||
|             e.preventDefault(); | ||||
|              | ||||
|             let currentRow = $(this).closest("tr"); | ||||
|              | ||||
|             // Clone without events | ||||
|             let newRow = currentRow.clone(false); | ||||
|              | ||||
|             // Reset input values in the cloned row | ||||
|             newRow.find("input").val(""); | ||||
|             newRow.find("select").prop("selectedIndex", 0); | ||||
|              | ||||
|             // Insert the new row after the current one | ||||
|             currentRow.after(newRow); | ||||
|              | ||||
|             // Reindex all rows | ||||
|             reindexRules(); | ||||
|         }); | ||||
|          | ||||
|         // Remove row button handler | ||||
|         $(".removeRuleRow").on("click", function(e) { | ||||
|             e.preventDefault(); | ||||
|              | ||||
|             // Only remove if there's more than one row | ||||
|             if ($("#rulesTable tbody tr").length > 1) { | ||||
|                 $(this).closest("tr").remove(); | ||||
|                 reindexRules(); | ||||
|             } | ||||
|         }); | ||||
|          | ||||
|         // Verify rule button handler | ||||
|         $(".verifyRuleRow").on("click", function(e) { | ||||
|             e.preventDefault(); | ||||
|              | ||||
|             let row = $(this).closest("tr"); | ||||
|             let field = row.find("select[name$='field']").val(); | ||||
|             let operator = row.find("select[name$='operator']").val(); | ||||
|             let value = row.find("input[name$='value']").val(); | ||||
|              | ||||
|             // Validate that all fields are filled | ||||
|             if (!field || field === "None" || !operator || operator === "None" || !value) { | ||||
|                 alert("Please fill in all fields (Field, Operator, and Value) before verifying."); | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|              | ||||
|             // Create a rule object | ||||
|             const rule = { | ||||
|                 field: field, | ||||
|                 operator: operator, | ||||
|                 value: value | ||||
|             }; | ||||
|              | ||||
|             // Show a spinner or some indication that verification is in progress | ||||
|             const $button = $(this); | ||||
|             const originalHTML = $button.html(); | ||||
|             $button.html("⌛").prop("disabled", true); | ||||
|              | ||||
|             // Collect form data - similar to request_textpreview_update() in watch-settings.js | ||||
|             let formData = new FormData(); | ||||
|             $('#edit-text-filter textarea, #edit-text-filter input').each(function() { | ||||
|                 const $element = $(this); | ||||
|                 const name = $element.attr('name'); | ||||
|                 if (name) { | ||||
|                     if ($element.is(':checkbox')) { | ||||
|                         formData.append(name, $element.is(':checked') ? $element.val() : false); | ||||
|                     } else { | ||||
|                         formData.append(name, $element.val()); | ||||
|                     } | ||||
|                 } | ||||
|             }); | ||||
|              | ||||
|             // Also collect select values | ||||
|             $('#edit-text-filter select').each(function() { | ||||
|                 const $element = $(this); | ||||
|                 const name = $element.attr('name'); | ||||
|                 if (name) { | ||||
|                     formData.append(name, $element.val()); | ||||
|                 } | ||||
|             }); | ||||
|  | ||||
|  | ||||
|             // Send the request to verify the rule | ||||
|             $.ajax({ | ||||
|                 url: verify_condition_rule_url+"?"+ new URLSearchParams({ rule: JSON.stringify(rule) }).toString(), | ||||
|                 type: "POST", | ||||
|                 data: formData, | ||||
|                 processData: false, // Prevent jQuery from converting FormData to a string | ||||
|                 contentType: false, // Let the browser set the correct content type | ||||
|                 success: function (response) { | ||||
|                     if (response.status === "success") { | ||||
|                         if (response.result) { | ||||
|                             alert("✅ Condition PASSES verification against current snapshot!"); | ||||
|                         } else { | ||||
|                             alert("❌ Condition FAILS verification against current snapshot."); | ||||
|                         } | ||||
|                     } else { | ||||
|                         alert("Error: " + response.message); | ||||
|                     } | ||||
|                     $button.html(originalHTML).prop("disabled", false); | ||||
|                 }, | ||||
|                 error: function (xhr) { | ||||
|                     let errorMsg = "Error verifying condition."; | ||||
|                     if (xhr.responseJSON && xhr.responseJSON.message) { | ||||
|                         errorMsg = xhr.responseJSON.message; | ||||
|                     } | ||||
|                     alert(errorMsg); | ||||
|                     $button.html(originalHTML).prop("disabled", false); | ||||
|                 } | ||||
|             }); | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     // Function to reindex form elements and re-setup event handlers | ||||
|     function reindexRules() { | ||||
|         // Unbind all button handlers first | ||||
|         $(".addRuleRow, .removeRuleRow, .verifyRuleRow").off("click"); | ||||
|          | ||||
|         // Reindex all form elements | ||||
|         $("#rulesTable tbody tr").each(function(index) { | ||||
|             $(this).find("select, input").each(function() { | ||||
|                 let oldName = $(this).attr("name"); | ||||
|                 let oldId = $(this).attr("id"); | ||||
|  | ||||
|                 if (oldName) { | ||||
|                     let newName = oldName.replace(/\d+/, index); | ||||
|                     $(this).attr("name", newName); | ||||
|                 } | ||||
|  | ||||
|                 if (oldId) { | ||||
|                     let newId = oldId.replace(/\d+/, index); | ||||
|                     $(this).attr("id", newId); | ||||
|                 } | ||||
|             }); | ||||
|         }); | ||||
|          | ||||
|         // Reattach event handlers after reindexing | ||||
|         setupButtonHandlers(); | ||||
|     } | ||||
|  | ||||
|     // Initial setup of button handlers | ||||
|     setupButtonHandlers(); | ||||
| }); | ||||
| @@ -1,66 +1,47 @@ | ||||
| (function ($) { | ||||
|     $.fn.hashTabs = function (options) { | ||||
|         var settings = $.extend({ | ||||
|             tabContainer: ".tabs ul", | ||||
|             tabSelector: "li a", | ||||
|             tabContent: ".tab-pane-inner", | ||||
|             activeClass: "active", | ||||
|             errorClass: ".messages .error", | ||||
|             bodyClassToggle: "full-width" | ||||
|         }, options); | ||||
| // Rewrite this is a plugin.. is all this JS really 'worth it?' | ||||
|  | ||||
|         var $tabs = $(settings.tabContainer).find(settings.tabSelector); | ||||
| window.addEventListener('hashchange', function () { | ||||
|     var tabs = document.getElementsByClassName('active'); | ||||
|     while (tabs[0]) { | ||||
|         tabs[0].classList.remove('active'); | ||||
|         document.body.classList.remove('full-width'); | ||||
|     } | ||||
|     set_active_tab(); | ||||
| }, false); | ||||
|  | ||||
|         function setActiveTab() { | ||||
|             var hash = window.location.hash; | ||||
|             var $activeTab = $tabs.filter("[href='" + hash + "']"); | ||||
| var has_errors = document.querySelectorAll(".messages .error"); | ||||
| if (!has_errors.length) { | ||||
|     if (document.location.hash == "") { | ||||
|         location.replace(document.querySelector(".tabs ul li:first-child a").hash); | ||||
|     } else { | ||||
|         set_active_tab(); | ||||
|     } | ||||
| } else { | ||||
|     focus_error_tab(); | ||||
| } | ||||
|  | ||||
|             // Remove active class from all tabs | ||||
|             $(settings.tabContainer).find("li").removeClass(settings.activeClass); | ||||
| function set_active_tab() { | ||||
|     document.body.classList.remove('full-width'); | ||||
|     var tab = document.querySelectorAll("a[href='" + location.hash + "']"); | ||||
|     if (tab.length) { | ||||
|         tab[0].parentElement.className = "active"; | ||||
|     } | ||||
| } | ||||
|  | ||||
|             // Add active class to selected tab | ||||
|             if ($activeTab.length) { | ||||
|                 $activeTab.parent().addClass(settings.activeClass); | ||||
|             } | ||||
|  | ||||
|             // Show the correct content | ||||
|             $(settings.tabContent).hide(); | ||||
|             if (hash) { | ||||
|                 $(hash).show(); | ||||
|             } | ||||
| function focus_error_tab() { | ||||
|     // time to use jquery or vuejs really, | ||||
|     // activate the tab with the error | ||||
|     var tabs = document.querySelectorAll('.tabs li a'), i; | ||||
|     for (i = 0; i < tabs.length; ++i) { | ||||
|         var tab_name = tabs[i].hash.replace('#', ''); | ||||
|         var pane_errors = document.querySelectorAll('#' + tab_name + ' .error') | ||||
|         if (pane_errors.length) { | ||||
|             document.location.hash = '#' + tab_name; | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         function focusErrorTab() { | ||||
|             $tabs.each(function () { | ||||
|                 var tabName = this.hash.replace("#", ""); | ||||
|                 if ($("#" + tabName).find(settings.errorClass).length) { | ||||
|                     window.location.hash = "#" + tabName; | ||||
|                     return false; // Stop loop on first error tab | ||||
|                 } | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         function initializeTabs() { | ||||
|             if ($(settings.errorClass).length) { | ||||
|                 focusErrorTab(); | ||||
|             } else if (!window.location.hash) { | ||||
|                 window.location.replace($tabs.first().attr("href")); | ||||
|             } else { | ||||
|                 setActiveTab(); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Listen for hash changes | ||||
|         $(window).on("hashchange", setActiveTab); | ||||
|  | ||||
|         // Initialize on page load | ||||
|         initializeTabs(); | ||||
|  | ||||
|         return this; // Enable jQuery chaining | ||||
|     }; | ||||
| })(jQuery); | ||||
|     } | ||||
|     return false; | ||||
| } | ||||
|  | ||||
|  | ||||
|  | ||||
| $(document).ready(function () { | ||||
|     $(".tabs").hashTabs(); | ||||
| }); | ||||
| @@ -40,19 +40,22 @@ | ||||
|   } | ||||
| } | ||||
|  | ||||
| @media only screen and (min-width: 760px) { | ||||
|  | ||||
| #browser-steps .flex-wrapper { | ||||
|   display: flex; | ||||
|   flex-flow: row; | ||||
|   height: 70vh; | ||||
|   font-size: 80%; | ||||
|   #browser-steps-ui { | ||||
|     flex-grow: 1;      /* Allow it to grow and fill the available space */ | ||||
|     flex-shrink: 1;    /* Allow it to shrink if needed */ | ||||
|     flex-basis: 0;     /* Start with 0 base width so it stretches as much as possible */ | ||||
|     background-color: #eee; | ||||
|     border-radius: 5px; | ||||
|   #browser-steps .flex-wrapper { | ||||
|     display: flex; | ||||
|     flex-flow: row; | ||||
|     height: 70vh; | ||||
|     font-size: 80%; | ||||
|  | ||||
|     #browser-steps-ui { | ||||
|       flex-grow: 1; /* Allow it to grow and fill the available space */ | ||||
|       flex-shrink: 1; /* Allow it to shrink if needed */ | ||||
|       flex-basis: 0; /* Start with 0 base width so it stretches as much as possible */ | ||||
|       background-color: #eee; | ||||
|       border-radius: 5px; | ||||
|  | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   #browser-steps-fieldlist { | ||||
| @@ -63,15 +66,21 @@ | ||||
|     padding-left: 1rem; | ||||
|     overflow-y: scroll; | ||||
|   } | ||||
|  | ||||
|   /*  this is duplicate :( */ | ||||
|   #browsersteps-selector-wrapper { | ||||
|     height: 100% !important; | ||||
|   } | ||||
| } | ||||
|  | ||||
| /*  this is duplicate :( */ | ||||
| #browsersteps-selector-wrapper { | ||||
|   height: 100%; | ||||
|  | ||||
|   width: 100%; | ||||
|   overflow-y: scroll; | ||||
|   position: relative; | ||||
|   //width: 100%; | ||||
|   height: 80vh; | ||||
|  | ||||
|   > img { | ||||
|     position: absolute; | ||||
|     max-width: 100%; | ||||
| @@ -91,7 +100,6 @@ | ||||
|     left: 50%; | ||||
|     top: 50%; | ||||
|     transform: translate(-50%, -50%); | ||||
|     margin-left: -40px; | ||||
|     z-index: 100; | ||||
|     max-width: 350px; | ||||
|     text-align: center; | ||||
|   | ||||
							
								
								
									
										9
									
								
								changedetectionio/static/styles/scss/parts/_edit.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,9 @@ | ||||
| ul#conditions_match_logic { | ||||
|     list-style: none; | ||||
|   input, label, li { | ||||
|     display: inline-block; | ||||
|   } | ||||
|   li { | ||||
|     padding-right: 1em; | ||||
|   } | ||||
| } | ||||
| @@ -13,6 +13,7 @@ | ||||
| @import "parts/_menu"; | ||||
| @import "parts/_love"; | ||||
| @import "parts/preview_text_filter"; | ||||
| @import "parts/_edit"; | ||||
|  | ||||
| body { | ||||
|   color: var(--color-text); | ||||
| @@ -945,7 +946,15 @@ $form-edge-padding: 20px; | ||||
| } | ||||
|  | ||||
| .tab-pane-inner { | ||||
|   display: none; | ||||
|  | ||||
|   &:not(:target) { | ||||
|     display: none; | ||||
|   } | ||||
|  | ||||
|   &:target { | ||||
|     display: block; | ||||
|   } | ||||
|  | ||||
|   // doesnt need padding because theres another row of buttons/activity | ||||
|   padding: 0px; | ||||
| } | ||||
|   | ||||
| @@ -46,21 +46,22 @@ | ||||
|     #browser_steps li > label { | ||||
|       display: none; } | ||||
|  | ||||
| #browser-steps .flex-wrapper { | ||||
|   display: flex; | ||||
|   flex-flow: row; | ||||
|   height: 70vh; | ||||
|   font-size: 80%; } | ||||
|   #browser-steps .flex-wrapper #browser-steps-ui { | ||||
|     flex-grow: 1; | ||||
|     /* Allow it to grow and fill the available space */ | ||||
|     flex-shrink: 1; | ||||
|     /* Allow it to shrink if needed */ | ||||
|     flex-basis: 0; | ||||
|     /* Start with 0 base width so it stretches as much as possible */ | ||||
|     background-color: #eee; | ||||
|     border-radius: 5px; } | ||||
|   #browser-steps .flex-wrapper #browser-steps-fieldlist { | ||||
| @media only screen and (min-width: 760px) { | ||||
|   #browser-steps .flex-wrapper { | ||||
|     display: flex; | ||||
|     flex-flow: row; | ||||
|     height: 70vh; | ||||
|     font-size: 80%; } | ||||
|     #browser-steps .flex-wrapper #browser-steps-ui { | ||||
|       flex-grow: 1; | ||||
|       /* Allow it to grow and fill the available space */ | ||||
|       flex-shrink: 1; | ||||
|       /* Allow it to shrink if needed */ | ||||
|       flex-basis: 0; | ||||
|       /* Start with 0 base width so it stretches as much as possible */ | ||||
|       background-color: #eee; | ||||
|       border-radius: 5px; } | ||||
|   #browser-steps-fieldlist { | ||||
|     flex-grow: 0; | ||||
|     /* Don't allow it to grow */ | ||||
|     flex-shrink: 0; | ||||
| @@ -71,13 +72,16 @@ | ||||
|     /* Set a max width to prevent overflow */ | ||||
|     padding-left: 1rem; | ||||
|     overflow-y: scroll; } | ||||
|   /*  this is duplicate :( */ | ||||
|   #browsersteps-selector-wrapper { | ||||
|     height: 100% !important; } } | ||||
|  | ||||
| /*  this is duplicate :( */ | ||||
| #browsersteps-selector-wrapper { | ||||
|   height: 100%; | ||||
|   width: 100%; | ||||
|   overflow-y: scroll; | ||||
|   position: relative; | ||||
|   height: 80vh; | ||||
|   /* nice tall skinny one */ } | ||||
|   #browsersteps-selector-wrapper > img { | ||||
|     position: absolute; | ||||
| @@ -92,7 +96,6 @@ | ||||
|     left: 50%; | ||||
|     top: 50%; | ||||
|     transform: translate(-50%, -50%); | ||||
|     margin-left: -40px; | ||||
|     z-index: 100; | ||||
|     max-width: 350px; | ||||
|     text-align: center; } | ||||
| @@ -520,6 +523,13 @@ body.preview-text-enabled { | ||||
|   z-index: 3; | ||||
|   box-shadow: 1px 1px 4px var(--color-shadow-jump); } | ||||
|  | ||||
| ul#conditions_match_logic { | ||||
|   list-style: none; } | ||||
|   ul#conditions_match_logic input, ul#conditions_match_logic label, ul#conditions_match_logic li { | ||||
|     display: inline-block; } | ||||
|   ul#conditions_match_logic li { | ||||
|     padding-right: 1em; } | ||||
|  | ||||
| body { | ||||
|   color: var(--color-text); | ||||
|   background: var(--color-background-page); | ||||
| @@ -1159,8 +1169,11 @@ textarea::placeholder { | ||||
|   border-radius: 5px; } | ||||
|  | ||||
| .tab-pane-inner { | ||||
|   display: none; | ||||
|   padding: 0px; } | ||||
|   .tab-pane-inner:not(:target) { | ||||
|     display: none; } | ||||
|   .tab-pane-inner:target { | ||||
|     display: block; } | ||||
|  | ||||
| .beta-logo { | ||||
|   height: 50px; | ||||
|   | ||||
| @@ -571,16 +571,16 @@ class ChangeDetectionStore: | ||||
|  | ||||
|         return ret | ||||
|  | ||||
|     def add_tag(self, name): | ||||
|     def add_tag(self, title): | ||||
|         # If name exists, return that | ||||
|         n = name.strip().lower() | ||||
|         n = title.strip().lower() | ||||
|         logger.debug(f">>> Adding new tag - '{n}'") | ||||
|         if not n: | ||||
|             return False | ||||
|  | ||||
|         for uuid, tag in self.__data['settings']['application'].get('tags', {}).items(): | ||||
|             if n == tag.get('title', '').lower().strip(): | ||||
|                 logger.warning(f"Tag '{name}' already exists, skipping creation.") | ||||
|                 logger.warning(f"Tag '{title}' already exists, skipping creation.") | ||||
|                 return uuid | ||||
|  | ||||
|         # Eventually almost everything todo with a watch will apply as a Tag | ||||
| @@ -588,7 +588,7 @@ class ChangeDetectionStore: | ||||
|         with self.lock: | ||||
|             from .model import Tag | ||||
|             new_tag = Tag.model(datastore_path=self.datastore_path, default={ | ||||
|                 'title': name.strip(), | ||||
|                 'title': title.strip(), | ||||
|                 'date_created': int(time.time()) | ||||
|             }) | ||||
|  | ||||
| @@ -847,7 +847,7 @@ class ChangeDetectionStore: | ||||
|             if tag: | ||||
|                 tag_uuids = [] | ||||
|                 for t in tag.split(','): | ||||
|                     tag_uuids.append(self.add_tag(name=t)) | ||||
|                     tag_uuids.append(self.add_tag(title=t)) | ||||
|  | ||||
|                 self.data['watching'][uuid]['tags'] = tag_uuids | ||||
|  | ||||
|   | ||||
| @@ -28,7 +28,7 @@ | ||||
|                             {% if emailprefix %} | ||||
|                               <a id="add-email-helper" class="pure-button button-secondary button-xsmall" >Add email <img style="height: 1em; display: inline-block" src="{{url_for('static_content', group='images', filename='email.svg')}}" alt="Add an email address"> </a> | ||||
|                             {% endif %} | ||||
|                               <a href="{{url_for('notification_logs')}}" class="pure-button button-secondary button-xsmall" >Notification debug logs</a> | ||||
|                               <a href="{{url_for('settings.notification_logs')}}" class="pure-button button-secondary button-xsmall" >Notification debug logs</a> | ||||
|                               <br> | ||||
|                                 <div id="notification-test-log" style="display: none;"><span class="pure-form-message-inline">Processing..</span></div> | ||||
|                             </div> | ||||
| @@ -40,7 +40,7 @@ | ||||
|                             </div> | ||||
|                             <div class="pure-control-group"> | ||||
|                                 {{ render_field(form.notification_body , rows=5, class="notification-body", placeholder=settings_application['notification_body']) }} | ||||
|                                 <span class="pure-form-message-inline">Body for all notifications ‐ You can use <a target="_new" href="https://jinja.palletsprojects.com/en/3.0.x/templates/">Jinja2</a> templating in the notification title, body and URL, and tokens from below. | ||||
|                                 <span class="pure-form-message-inline">Body for all notifications ‐ You can use <a target="newwindow" href="https://jinja.palletsprojects.com/en/3.0.x/templates/">Jinja2</a> templating in the notification title, body and URL, and tokens from below. | ||||
|                                 </span> | ||||
|  | ||||
|                             </div> | ||||
|   | ||||
| @@ -61,6 +61,55 @@ | ||||
|   {{ field(**kwargs)|safe }} | ||||
| {% endmacro %} | ||||
|  | ||||
| {% macro render_fieldlist_of_formfields_as_table(fieldlist, table_id="rulesTable") %} | ||||
|   <table class="fieldlist_formfields pure-table" id="{{ table_id }}"> | ||||
|     <thead> | ||||
|       <tr> | ||||
|         {% for subfield in fieldlist[0] %} | ||||
|           <th>{{ subfield.label }}</th> | ||||
|         {% endfor %} | ||||
|         <th>Actions</th> | ||||
|       </tr> | ||||
|     </thead> | ||||
|     <tbody> | ||||
|       {% for form_row in fieldlist %} | ||||
|         <tr {% if form_row.errors %} class="error-row" {% endif %}> | ||||
|           {% for subfield in form_row %} | ||||
|             <td> | ||||
|               {{ subfield()|safe }} | ||||
|               {% if subfield.errors %} | ||||
|                 <ul class="errors"> | ||||
|                   {% for error in subfield.errors %} | ||||
|                     <li class="error">{{ error }}</li> | ||||
|                   {% endfor %} | ||||
|                 </ul> | ||||
|               {% endif %} | ||||
|             </td> | ||||
|           {% endfor %} | ||||
|           <td> | ||||
|             <button type="button" class="addRuleRow">+</button> | ||||
|             <button type="button" class="removeRuleRow">-</button> | ||||
|             <button type="button" class="verifyRuleRow" title="Verify this rule against current snapshot">✓</button> | ||||
|           </td> | ||||
|         </tr> | ||||
|       {% endfor %} | ||||
|     </tbody> | ||||
|   </table> | ||||
| {% endmacro %} | ||||
|  | ||||
|  | ||||
| {% macro playwright_warning() %} | ||||
|     <p><strong>Error - Playwright support for Chrome based fetching is not enabled.</strong> Alternatively try our <a href="https://changedetection.io">very affordable subscription based service which has all this setup for you</a>.</p> | ||||
|     <p>You may need to <a href="https://github.com/dgtlmoon/changedetection.io/blob/09ebc6ec6338545bdd694dc6eee57f2e9d2b8075/docker-compose.yml#L31">Enable playwright environment variable</a> and uncomment the <strong>sockpuppetbrowser</strong> in the <a href="https://github.com/dgtlmoon/changedetection.io/blob/master/docker-compose.yml">docker-compose.yml</a> file.</p> | ||||
|     <br> | ||||
|     <p>(Also Selenium/WebDriver can not extract full page screenshots reliably so Playwright is recommended here)</p> | ||||
|  | ||||
| {% endmacro %} | ||||
|  | ||||
| {% macro only_webdriver_type_watches_warning() %} | ||||
|     <p><strong>Sorry, this functionality only works with Playwright/Chrome enabled watches.<br>You need to <a href="#request">Set the fetch method to Playwright/Chrome mode and resave</a> and have the Playwright connection enabled.</strong></p><br> | ||||
| {% endmacro %} | ||||
|  | ||||
| {% macro render_time_schedule_form(form, available_timezones, timezone_default_config) %} | ||||
|     <style> | ||||
|     .day-schedule *, .day-schedule select { | ||||
| @@ -150,7 +199,7 @@ | ||||
|     </div> | ||||
|     {% else %} | ||||
|         <span class="pure-form-message-inline"> | ||||
|             Want to use a time schedule? <a href="{{url_for('settings_page')}}#timedate">First confirm/save your Time Zone Settings</a> | ||||
|             Want to use a time schedule? <a href="{{url_for('settings.settings_page')}}#timedate">First confirm/save your Time Zone Settings</a> | ||||
|         </span> | ||||
|         <br> | ||||
|     {% endif %} | ||||
|   | ||||
| @@ -7,7 +7,7 @@ | ||||
|     <meta name="description" content="Self hosted website change detection." > | ||||
|     <title>Change Detection{{extra_title}}</title> | ||||
|     {% if app_rss_token %} | ||||
|       <link rel="alternate" type="application/rss+xml" title="Changedetection.io » Feed{% if active_tag_uuid %}- {{active_tag.title}}{% endif %}" href="{{ url_for('rss', tag=active_tag_uuid , token=app_rss_token)}}" > | ||||
|       <link rel="alternate" type="application/rss+xml" title="Changedetection.io » Feed{% if active_tag_uuid %}- {{active_tag.title}}{% endif %}" href="{{ url_for('rss.feed', tag=active_tag_uuid , token=app_rss_token)}}" > | ||||
|     {% endif %} | ||||
|     <link rel="stylesheet" href="{{url_for('static_content', group='styles', filename='pure-min.css')}}" > | ||||
|     <link rel="stylesheet" href="{{url_for('static_content', group='styles', filename='styles.css')}}?v={{ get_css_version() }}" > | ||||
| @@ -64,17 +64,17 @@ | ||||
|                 <a href="{{ url_for('tags.tags_overview_page')}}" class="pure-menu-link">GROUPS</a> | ||||
|               </li> | ||||
|               <li class="pure-menu-item"> | ||||
|                 <a href="{{ url_for('settings_page')}}" class="pure-menu-link">SETTINGS</a> | ||||
|                 <a href="{{ url_for('settings.settings_page')}}" class="pure-menu-link">SETTINGS</a> | ||||
|               </li> | ||||
|               <li class="pure-menu-item"> | ||||
|                 <a href="{{ url_for('import_page')}}" class="pure-menu-link">IMPORT</a> | ||||
|                 <a href="{{ url_for('imports.import_page')}}" class="pure-menu-link">IMPORT</a> | ||||
|               </li> | ||||
|               <li class="pure-menu-item"> | ||||
|                 <a href="{{ url_for('backups.index')}}" class="pure-menu-link">BACKUPS</a> | ||||
|               </li> | ||||
|             {% else %} | ||||
|               <li class="pure-menu-item"> | ||||
|                 <a href="{{ url_for('edit_page', uuid=uuid, next='diff') }}" class="pure-menu-link">EDIT</a> | ||||
|                 <a href="{{ url_for('ui.ui_edit.edit_page', uuid=uuid, next='diff') }}" class="pure-menu-link">EDIT</a> | ||||
|               </li> | ||||
|             {% endif %} | ||||
|           {% else %} | ||||
| @@ -144,7 +144,7 @@ | ||||
|     {% endif %} | ||||
|     {% if left_sticky %} | ||||
|       <div class="sticky-tab" id="left-sticky"> | ||||
|         <a href="{{url_for('preview_page', uuid=uuid)}}">Show current snapshot</a><br> | ||||
|         <a href="{{url_for('ui.ui_views.preview_page', uuid=uuid)}}">Show current snapshot</a><br> | ||||
|           Visualise <strong>triggers</strong> and <strong>ignored text</strong> | ||||
|       </div> | ||||
|     {% endif %} | ||||
|   | ||||
| @@ -7,7 +7,7 @@ | ||||
|     const error_screenshot_url="{{url_for('static_content', group='screenshot', filename=uuid, error_screenshot=1) }}"; | ||||
|     {% endif %} | ||||
|  | ||||
|     const highlight_submit_ignore_url="{{url_for('highlight_submit_ignore_url', uuid=uuid)}}"; | ||||
|     const highlight_submit_ignore_url="{{url_for('ui.ui_edit.highlight_submit_ignore_url', uuid=uuid)}}"; | ||||
|  | ||||
| </script> | ||||
| <script src="{{url_for('static_content', group='js', filename='diff-overview.js')}}" defer></script> | ||||
| @@ -125,7 +125,7 @@ | ||||
|      </div> | ||||
|     <div class="tab-pane-inner" id="extract"> | ||||
|         <form id="extract-data-form" class="pure-form pure-form-stacked edit-form" | ||||
|               action="{{ url_for('diff_history_page', uuid=uuid) }}#extract" | ||||
|               action="{{ url_for('ui.ui_views.diff_history_page', uuid=uuid) }}#extract" | ||||
|               method="POST"> | ||||
|             <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> | ||||
|  | ||||
|   | ||||
| @@ -1,11 +1,14 @@ | ||||
| {% 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, playwright_warning, only_webdriver_type_watches_warning, render_fieldlist_of_formfields_as_table %} | ||||
| {% 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> | ||||
| <script src="{{url_for('static_content', group='js', filename='global-settings.js')}}" defer></script> | ||||
| <script src="{{url_for('static_content', group='js', filename='scheduler.js')}}" defer></script> | ||||
| <script src="{{url_for('static_content', group='js', filename='conditions.js')}}" defer></script> | ||||
|  | ||||
|  | ||||
| <script> | ||||
|     const browser_steps_available_screenshots=JSON.parse('{{ watch.get_browsersteps_available_screenshots|tojson }}'); | ||||
|     const browser_steps_config=JSON.parse('{{ browser_steps_config|tojson }}'); | ||||
| @@ -17,7 +20,7 @@ | ||||
| {% if emailprefix %} | ||||
|     const email_notification_prefix=JSON.parse('{{ emailprefix|tojson }}'); | ||||
| {% endif %} | ||||
|     const notification_base_url="{{url_for('ajax_callback_send_notification_test', watch_uuid=uuid)}}"; | ||||
|     const notification_base_url="{{url_for('ui.ui_notification.ajax_callback_send_notification_test', watch_uuid=uuid)}}"; | ||||
|     const playwright_enabled={% if playwright_enabled %}true{% else %}false{% endif %}; | ||||
|     const recheck_proxy_start_url="{{url_for('check_proxies.start_check', uuid=uuid)}}"; | ||||
|     const proxy_recheck_status_url="{{url_for('check_proxies.get_recheck_status', uuid=uuid)}}"; | ||||
| @@ -40,7 +43,7 @@ | ||||
|  | ||||
|     <div class="tabs collapsable"> | ||||
|         <ul> | ||||
|             <li class="tab" id=""><a href="#general">General</a></li> | ||||
|             <li class="tab"><a href="#general">General</a></li> | ||||
|             <li class="tab"><a href="#request">Request</a></li> | ||||
|             {% if extra_tab_content %} | ||||
|             <li class="tab"><a href="#extras_tab">{{ extra_tab_content }}</a></li> | ||||
| @@ -50,6 +53,7 @@ | ||||
|             {% if watch['processor'] == 'text_json_diff' %} | ||||
|             <li class="tab"><a id="visualselector-tab" href="#visualselector">Visual Filter Selector</a></li> | ||||
|             <li class="tab" id="filters-and-triggers-tab"><a href="#filters-and-triggers">Filters & Triggers</a></li> | ||||
|             <li class="tab" id="conditions-tab"><a href="#conditions">Conditions</a></li> | ||||
|             {% endif %} | ||||
|             <li class="tab"><a href="#notifications">Notifications</a></li> | ||||
|             <li class="tab"><a href="#stats">Stats</a></li> | ||||
| @@ -58,7 +62,7 @@ | ||||
|  | ||||
|     <div class="box-wrap inner"> | ||||
|         <form class="pure-form pure-form-stacked" | ||||
|               action="{{ url_for('edit_page', uuid=uuid, next = request.args.get('next'), unpause_on_save = request.args.get('unpause_on_save'), tag = request.args.get('tag')) }}" method="POST"> | ||||
|               action="{{ url_for('ui.ui_edit.edit_page', uuid=uuid, next = request.args.get('next'), unpause_on_save = request.args.get('unpause_on_save'), tag = request.args.get('tag')) }}" method="POST"> | ||||
|              <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> | ||||
|  | ||||
|             <div class="tab-pane-inner" id="general"> | ||||
| @@ -200,7 +204,7 @@ Math: {{ 1 + 1 }}") }} | ||||
|             </div> | ||||
|  | ||||
|             <div class="tab-pane-inner" id="browser-steps"> | ||||
|             {% if playwright_enabled %} | ||||
|             {% if playwright_enabled and watch_uses_webdriver %} | ||||
|                 <img class="beta-logo" src="{{url_for('static_content', group='images', filename='beta-logo.png')}}" alt="New beta functionality"> | ||||
|                 <fieldset> | ||||
|                     <div class="pure-control-group"> | ||||
| @@ -224,7 +228,7 @@ Math: {{ 1 + 1 }}") }} | ||||
|                                     <span class="loader" > | ||||
|                                         <span id="browsersteps-click-start"> | ||||
|                                             <h2 >Click here to Start</h2> | ||||
|                                             <svg style="height: 3.5rem;" version="1.1" viewBox="0 0 32 32"  xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><g id="Layer_1"/><g id="play_x5F_alt"><path d="M16,0C7.164,0,0,7.164,0,16s7.164,16,16,16s16-7.164,16-16S24.836,0,16,0z M10,24V8l16.008,8L10,24z" style="fill: var(--color-grey-400);"/></g></svg><br> | ||||
|                                             <svg style="height: 3.5rem;" version="1.1" viewBox="0 0 32 32"  xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><g id="start"/><g id="play_x5F_alt"><path d="M16,0C7.164,0,0,7.164,0,16s7.164,16,16,16s16-7.164,16-16S24.836,0,16,0z M10,24V8l16.008,8L10,24z" style="fill: var(--color-grey-400);"/></g></svg><br> | ||||
|                                             Please allow 10-15 seconds for the browser to connect.<br> | ||||
|                                         </span> | ||||
|                                         <div class="spinner"  style="display: none;"></div> | ||||
| @@ -242,10 +246,12 @@ Math: {{ 1 + 1 }}") }} | ||||
|                 </fieldset> | ||||
|                 {% else %} | ||||
|                     <span class="pure-form-message-inline"> | ||||
|                         <p>Sorry, this functionality only works with Playwright/Chrome enabled watches.</p> | ||||
|                         <p>Enable the Playwright Chrome fetcher, or alternatively try our <a href="https://lemonade.changedetection.io/start">very affordable subscription based service</a>.</p> | ||||
|                         <p>This is because Selenium/WebDriver can not extract full page screenshots reliably.</p> | ||||
|                         <p>You may need to <a href="https://github.com/dgtlmoon/changedetection.io/blob/09ebc6ec6338545bdd694dc6eee57f2e9d2b8075/docker-compose.yml#L31">Enable playwright environment variable</a> and uncomment the <strong>sockpuppetbrowser</strong> from the docker-compose.yml file.</p> | ||||
|                         {% if not watch_uses_webdriver %} | ||||
|                             {{ only_webdriver_type_watches_warning() }} | ||||
|                         {% endif %} | ||||
|                         {%  if not playwright_enabled %} | ||||
|                             {{ playwright_warning() }} | ||||
|                         {% endif %} | ||||
|                     </span> | ||||
|                 {% endif %} | ||||
|             </div> | ||||
| @@ -256,7 +262,7 @@ Math: {{ 1 + 1 }}") }} | ||||
|                     <div  class="pure-control-group inline-radio"> | ||||
|                       {{ render_checkbox_field(form.notification_muted) }} | ||||
|                     </div> | ||||
|                     {% if is_html_webdriver %} | ||||
|                     {% if watch_uses_webdriver %} | ||||
|                     <div class="pure-control-group inline-radio"> | ||||
|                       {{ render_checkbox_field(form.notification_screenshot) }} | ||||
|                         <span class="pure-form-message-inline"> | ||||
| @@ -268,17 +274,43 @@ Math: {{ 1 + 1 }}") }} | ||||
|                         {% if has_default_notification_urls %} | ||||
|                         <div class="inline-warning"> | ||||
|                             <img class="inline-warning-icon" src="{{url_for('static_content', group='images', filename='notice.svg')}}" alt="Look out!" title="Lookout!" > | ||||
|                             There are <a href="{{ url_for('settings_page')}}#notifications">system-wide notification URLs enabled</a>, this form will override notification settings for this watch only ‐ an empty Notification URL list here will still send notifications. | ||||
|                             There are <a href="{{ url_for('settings.settings_page')}}#notifications">system-wide notification URLs enabled</a>, this form will override notification settings for this watch only ‐ an empty Notification URL list here will still send notifications. | ||||
|                         </div> | ||||
|                         {% endif %} | ||||
|                         <a href="#notifications" id="notification-setting-reset-to-default" class="pure-button button-xsmall" style="right: 20px; top: 20px; position: absolute; background-color: #5f42dd; border-radius: 4px; font-size: 70%; color: #fff">Use system defaults</a> | ||||
|  | ||||
|                         {{ render_common_settings_form(form, emailprefix, settings_application, extra_notification_token_placeholder_info) }} | ||||
|                     </div> | ||||
|                 </fieldset> | ||||
|             </div> | ||||
|  | ||||
|             {% if watch['processor'] == 'text_json_diff' %} | ||||
|  | ||||
|             <div class="tab-pane-inner" id="conditions"> | ||||
|                     <script> | ||||
|                         const verify_condition_rule_url="{{url_for('conditions.verify_condition_single_rule', watch_uuid=uuid)}}"; | ||||
|                     </script> | ||||
|                 <style> | ||||
|                     .verifyRuleRow { | ||||
|                         background-color: #4caf50; | ||||
|                         color: white; | ||||
|                         border: none; | ||||
|                         cursor: pointer; | ||||
|                         font-weight: bold; | ||||
|                     } | ||||
|                     .verifyRuleRow:hover { | ||||
|                         background-color: #45a049; | ||||
|                     } | ||||
|                 </style> | ||||
|                 <div class="pure-control-group"> | ||||
|                     {{ render_field(form.conditions_match_logic) }} | ||||
|                     {{ render_fieldlist_of_formfields_as_table(form.conditions) }} | ||||
|                     <div class="pure-form-message-inline"> | ||||
|                         <br> | ||||
|                         Use the verify (✓) button to test if a condition passes against the current snapshot.<br><br> | ||||
|                         Did you know that <strong>conditions</strong> can be extended with your own custom plugin? tutorials coming soon!<br> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|             <div class="tab-pane-inner" id="filters-and-triggers"> | ||||
|                 <span id="activate-text-preview" class="pure-button pure-button-primary button-xsmall">Activate preview</span> | ||||
|               <div> | ||||
| @@ -448,7 +480,7 @@ keyword") }} | ||||
|             </div> | ||||
|               <div id="text-preview" style="display: none;" > | ||||
|                     <script> | ||||
|                         const preview_text_edit_filters_url="{{url_for('watch_get_preview_rendered', uuid=uuid)}}"; | ||||
|                         const preview_text_edit_filters_url="{{url_for('ui.ui_edit.watch_get_preview_rendered', uuid=uuid)}}"; | ||||
|                     </script> | ||||
|                     <br> | ||||
|                     {#<div id="text-preview-controls"><span id="text-preview-refresh" class="pure-button button-xsmall">Refresh</span></div>#} | ||||
| @@ -479,7 +511,7 @@ keyword") }} | ||||
|  | ||||
|                 <fieldset> | ||||
|                     <div class="pure-control-group"> | ||||
|                         {% if visualselector_enabled %} | ||||
|                         {% if playwright_enabled and watch_uses_webdriver %} | ||||
|                             <span class="pure-form-message-inline" id="visual-selector-heading"> | ||||
|                                 The Visual Selector tool lets you select the <i>text</i> elements that will be used for the change detection. It automatically fills-in the filters in the "CSS/JSONPath/JQ/XPath Filters" box of the <a href="#filters-and-triggers">Filters & Triggers</a> tab. Use <strong>Shift+Click</strong> to select multiple items. | ||||
|                             </span> | ||||
| @@ -497,12 +529,12 @@ keyword") }} | ||||
|                             </div> | ||||
|                             <div id="selector-current-xpath" style="overflow-x: hidden"><strong>Currently:</strong> <span class="text">Loading...</span></div> | ||||
|                         {% else %} | ||||
|                             <span class="pure-form-message-inline"> | ||||
|                                 <p>Sorry, this functionality only works with Playwright/Chrome enabled watches.</p> | ||||
|                                 <p>Enable the Playwright Chrome fetcher, or alternatively try our <a href="https://lemonade.changedetection.io/start">very affordable subscription based service</a>.</p> | ||||
|                                 <p>This is because Selenium/WebDriver can not extract full page screenshots reliably.</p> | ||||
|                                 <p>You may need to <a href="https://github.com/dgtlmoon/changedetection.io/blob/09ebc6ec6338545bdd694dc6eee57f2e9d2b8075/docker-compose.yml#L31">Enable playwright environment variable</a> and uncomment the <strong>sockpuppetbrowser</strong> from the docker-compose.yml file.</p> | ||||
|                             </span> | ||||
|                             {% if not watch_uses_webdriver %} | ||||
|                                 {{ only_webdriver_type_watches_warning() }} | ||||
|                             {% endif %} | ||||
|                             {% if not playwright_enabled %} | ||||
|                                 {{ playwright_warning() }} | ||||
|                             {% endif %} | ||||
|                         {% endif %} | ||||
|                     </div> | ||||
|                 </fieldset> | ||||
| @@ -545,7 +577,7 @@ keyword") }} | ||||
|                     </table> | ||||
|                     {% if watch.history_n %} | ||||
|                         <p> | ||||
|                              <a href="{{url_for('watch_get_latest_html', uuid=uuid)}}" class="pure-button button-small">Download latest HTML snapshot</a> | ||||
|                              <a href="{{url_for('ui.ui_edit.watch_get_latest_html', uuid=uuid)}}" class="pure-button button-small">Download latest HTML snapshot</a> | ||||
|                         </p> | ||||
|                     {% endif %} | ||||
|  | ||||
| @@ -554,11 +586,11 @@ keyword") }} | ||||
|             <div id="actions"> | ||||
|                 <div class="pure-control-group"> | ||||
|                     {{ render_button(form.save_button) }} | ||||
|                     <a href="{{url_for('form_delete', uuid=uuid)}}" | ||||
|                     <a href="{{url_for('ui.form_delete', uuid=uuid)}}" | ||||
|                        class="pure-button button-small button-error ">Delete</a> | ||||
|                     <a href="{{url_for('clear_watch_history', uuid=uuid)}}" | ||||
|                     <a href="{{url_for('ui.clear_watch_history', uuid=uuid)}}" | ||||
|                        class="pure-button button-small button-error ">Clear History</a> | ||||
|                     <a href="{{url_for('form_clone', uuid=uuid)}}" | ||||
|                     <a href="{{url_for('ui.form_clone', uuid=uuid)}}" | ||||
|                        class="pure-button button-small ">Create Copy</a> | ||||
|                 </div> | ||||
|             </div> | ||||
|   | ||||
| @@ -7,7 +7,7 @@ | ||||
|         {% if last_error_screenshot %} | ||||
|             const error_screenshot_url = "{{url_for('static_content', group='screenshot', filename=uuid, error_screenshot=1) }}"; | ||||
|         {% endif %} | ||||
|         const highlight_submit_ignore_url = "{{url_for('highlight_submit_ignore_url', uuid=uuid)}}"; | ||||
|         const highlight_submit_ignore_url = "{{url_for('ui.ui_edit.highlight_submit_ignore_url', uuid=uuid)}}"; | ||||
|     </script> | ||||
|     <script src="{{url_for('static_content', group='js', filename='plugins.js')}}"></script> | ||||
|     <script src="{{ url_for('static_content', group='js', filename='diff-overview.js') }}" defer></script> | ||||
|   | ||||
| @@ -1 +1 @@ | ||||
| <svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 122.879 119.799" enable-background="new 0 0 122.879 119.799" xml:space="preserve"><g><path d="M49.988,0h0.016v0.007C63.803,0.011,76.298,5.608,85.34,14.652c9.027,9.031,14.619,21.515,14.628,35.303h0.007v0.033v0.04 h-0.007c-0.005,5.557-0.917,10.905-2.594,15.892c-0.281,0.837-0.575,1.641-0.877,2.409v0.007c-1.446,3.66-3.315,7.12-5.547,10.307 l29.082,26.139l0.018,0.016l0.157,0.146l0.011,0.011c1.642,1.563,2.536,3.656,2.649,5.78c0.11,2.1-0.543,4.248-1.979,5.971 l-0.011,0.016l-0.175,0.203l-0.035,0.035l-0.146,0.16l-0.016,0.021c-1.565,1.642-3.654,2.534-5.78,2.646 c-2.097,0.111-4.247-0.54-5.971-1.978l-0.015-0.011l-0.204-0.175l-0.029-0.024L78.761,90.865c-0.88,0.62-1.778,1.209-2.687,1.765 c-1.233,0.755-2.51,1.466-3.813,2.115c-6.699,3.342-14.269,5.222-22.272,5.222v0.007h-0.016v-0.007 c-13.799-0.004-26.296-5.601-35.338-14.645C5.605,76.291,0.016,63.805,0.007,50.021H0v-0.033v-0.016h0.007 c0.004-13.799,5.601-26.296,14.645-35.338C23.683,5.608,36.167,0.016,49.955,0.007V0H49.988L49.988,0z M50.004,11.21v0.007h-0.016 h-0.033V11.21c-10.686,0.007-20.372,4.35-27.384,11.359C15.56,29.578,11.213,39.274,11.21,49.973h0.007v0.016v0.033H11.21 c0.007,10.686,4.347,20.367,11.359,27.381c7.009,7.012,16.705,11.359,27.403,11.361v-0.007h0.016h0.033v0.007 c10.686-0.007,20.368-4.348,27.382-11.359c7.011-7.009,11.358-16.702,11.36-27.4h-0.006v-0.016v-0.033h0.006 c-0.006-10.686-4.35-20.372-11.358-27.384C70.396,15.56,60.703,11.213,50.004,11.21L50.004,11.21z"/></g></svg> | ||||
| <svg version="1.1" id="search" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 122.879 119.799" enable-background="new 0 0 122.879 119.799" xml:space="preserve"><g><path d="M49.988,0h0.016v0.007C63.803,0.011,76.298,5.608,85.34,14.652c9.027,9.031,14.619,21.515,14.628,35.303h0.007v0.033v0.04 h-0.007c-0.005,5.557-0.917,10.905-2.594,15.892c-0.281,0.837-0.575,1.641-0.877,2.409v0.007c-1.446,3.66-3.315,7.12-5.547,10.307 l29.082,26.139l0.018,0.016l0.157,0.146l0.011,0.011c1.642,1.563,2.536,3.656,2.649,5.78c0.11,2.1-0.543,4.248-1.979,5.971 l-0.011,0.016l-0.175,0.203l-0.035,0.035l-0.146,0.16l-0.016,0.021c-1.565,1.642-3.654,2.534-5.78,2.646 c-2.097,0.111-4.247-0.54-5.971-1.978l-0.015-0.011l-0.204-0.175l-0.029-0.024L78.761,90.865c-0.88,0.62-1.778,1.209-2.687,1.765 c-1.233,0.755-2.51,1.466-3.813,2.115c-6.699,3.342-14.269,5.222-22.272,5.222v0.007h-0.016v-0.007 c-13.799-0.004-26.296-5.601-35.338-14.645C5.605,76.291,0.016,63.805,0.007,50.021H0v-0.033v-0.016h0.007 c0.004-13.799,5.601-26.296,14.645-35.338C23.683,5.608,36.167,0.016,49.955,0.007V0H49.988L49.988,0z M50.004,11.21v0.007h-0.016 h-0.033V11.21c-10.686,0.007-20.372,4.35-27.384,11.359C15.56,29.578,11.213,39.274,11.21,49.973h0.007v0.016v0.033H11.21 c0.007,10.686,4.347,20.367,11.359,27.381c7.009,7.012,16.705,11.359,27.403,11.361v-0.007h0.016h0.033v0.007 c10.686-0.007,20.368-4.348,27.382-11.359c7.011-7.009,11.358-16.702,11.36-27.4h-0.006v-0.016v-0.033h0.006 c-0.006-10.686-4.35-20.372-11.358-27.384C70.396,15.56,60.703,11.213,50.004,11.21L50.004,11.21z"/></g></svg> | ||||
| Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB | 
| @@ -6,7 +6,7 @@ | ||||
|  | ||||
| <div class="box"> | ||||
|  | ||||
|     <form class="pure-form" action="{{ url_for('form_quick_watch_add', tag=active_tag_uuid) }}" method="POST" id="new-watch-form"> | ||||
|     <form class="pure-form" action="{{ url_for('ui.ui_views.form_quick_watch_add', tag=active_tag_uuid) }}" method="POST" id="new-watch-form"> | ||||
|         <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" > | ||||
|         <fieldset> | ||||
|             <legend>Add a new change detection watch</legend> | ||||
| @@ -25,7 +25,7 @@ | ||||
|         <span style="color:#eee; font-size: 80%;"><img alt="Create a shareable link" style="height: 1em;display:inline-block;" src="{{url_for('static_content', group='images', filename='spread-white.svg')}}" > Tip: You can also add 'shared' watches. <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Sharing-a-Watch">More info</a></span> | ||||
|     </form> | ||||
|  | ||||
|     <form class="pure-form" action="{{ url_for('form_watch_list_checkbox_operations') }}" method="POST" id="watch-list-form"> | ||||
|     <form class="pure-form" action="{{ url_for('ui.form_watch_list_checkbox_operations') }}" method="POST" id="watch-list-form"> | ||||
|     <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" > | ||||
|     <input type="hidden" id="op_extradata" name="op_extradata" value="" > | ||||
|     <div id="checkbox-operations"> | ||||
| @@ -86,7 +86,7 @@ | ||||
|             <tbody> | ||||
|             {% if not watches|length %} | ||||
|             <tr> | ||||
|                 <td colspan="{{ cols_required }}" style="text-wrap: wrap;">No website watches configured, please add a URL in the box above, or <a href="{{ url_for('import_page')}}" >import a list</a>.</td> | ||||
|                 <td colspan="{{ cols_required }}" style="text-wrap: wrap;">No website watches configured, please add a URL in the box above, or <a href="{{ url_for('imports.import_page')}}" >import a list</a>.</td> | ||||
|             </tr> | ||||
|             {% endif %} | ||||
|             {% for watch in (watches|sort(attribute=sort_attribute, reverse=sort_order == 'asc'))|pagination_slice(skip=pagination.skip) %} | ||||
| @@ -113,7 +113,7 @@ | ||||
|                 </td> | ||||
|                 <td class="title-col inline">{{watch.title if watch.title is not none and watch.title|length > 0 else watch.url}} | ||||
|                     <a class="external" target="_blank" rel="noopener" href="{{ watch.link.replace('source:','') }}"></a> | ||||
|                     <a class="link-spread" href="{{url_for('form_share_put_watch', uuid=watch.uuid)}}"><img src="{{url_for('static_content', group='images', filename='spread.svg')}}" class="status-icon icon icon-spread" title="Create a link to share watch config with others" ></a> | ||||
|                     <a class="link-spread" href="{{url_for('ui.form_share_put_watch', uuid=watch.uuid)}}"><img src="{{url_for('static_content', group='images', filename='spread.svg')}}" class="status-icon icon icon-spread" title="Create a link to share watch config with others" ></a> | ||||
|  | ||||
|                     {% if watch.get_fetch_backend == "html_webdriver" | ||||
|                          or (  watch.get_fetch_backend == "system" and system_default_fetcher == 'html_webdriver'  ) | ||||
| @@ -129,9 +129,9 @@ | ||||
|  | ||||
|                         {% if '403' in watch.last_error %} | ||||
|                             {% if has_proxies %} | ||||
|                                 <a href="{{ url_for('settings_page', uuid=watch.uuid) }}#proxies">Try other proxies/location</a>  | ||||
|                                 <a href="{{ url_for('settings.settings_page', uuid=watch.uuid) }}#proxies">Try other proxies/location</a>  | ||||
|                             {% endif %} | ||||
|                             <a href="{{ url_for('settings_page', uuid=watch.uuid) }}#proxies">Try adding external proxies/locations</a> | ||||
|                             <a href="{{ url_for('settings.settings_page', uuid=watch.uuid) }}#proxies">Try adding external proxies/locations</a> | ||||
|                          | ||||
|                         {% endif %} | ||||
|                         {% if 'empty result or contain only an image' in watch.last_error %} | ||||
| @@ -140,7 +140,7 @@ | ||||
|                     </div> | ||||
|                     {% endif %} | ||||
|                     {% if watch.last_notification_error is defined and watch.last_notification_error != False %} | ||||
|                     <div class="fetch-error notification-error"><a href="{{url_for('notification_logs')}}">{{ watch.last_notification_error }}</a></div> | ||||
|                     <div class="fetch-error notification-error"><a href="{{url_for('settings.notification_logs')}}">{{ watch.last_notification_error }}</a></div> | ||||
|                     {% endif %} | ||||
|  | ||||
|                     {% if watch['processor'] == 'text_json_diff'  %} | ||||
| @@ -186,20 +186,20 @@ | ||||
|                     {% endif %} | ||||
|                 </td> | ||||
|                 <td> | ||||
|                     <a {% if watch.uuid in queued_uuids %}disabled="true"{% endif %} href="{{ url_for('form_watch_checknow', uuid=watch.uuid, tag=request.args.get('tag')) }}" | ||||
|                     <a {% if watch.uuid in queued_uuids %}disabled="true"{% endif %} href="{{ url_for('ui.form_watch_checknow', uuid=watch.uuid, tag=request.args.get('tag')) }}" | ||||
|                        class="recheck pure-button pure-button-primary">{% if watch.uuid in queued_uuids %}Queued{% else %}Recheck{% endif %}</a> | ||||
|                     <a href="{{ url_for('edit_page', uuid=watch.uuid, tag=active_tag_uuid)}}#general" class="pure-button pure-button-primary">Edit</a> | ||||
|                     <a href="{{ url_for('ui.ui_edit.edit_page', uuid=watch.uuid, tag=active_tag_uuid)}}#general" class="pure-button pure-button-primary">Edit</a> | ||||
|                     {% if watch.history_n >= 2 %} | ||||
|  | ||||
|                         {%  if is_unviewed %} | ||||
|                            <a href="{{ url_for('diff_history_page', uuid=watch.uuid, from_version=watch.get_from_version_based_on_last_viewed) }}" target="{{watch.uuid}}" class="pure-button pure-button-primary diff-link">History</a> | ||||
|                            <a href="{{ url_for('ui.ui_views.diff_history_page', uuid=watch.uuid, from_version=watch.get_from_version_based_on_last_viewed) }}" target="{{watch.uuid}}" class="pure-button pure-button-primary diff-link">History</a> | ||||
|                         {% else %} | ||||
|                            <a href="{{ url_for('diff_history_page', uuid=watch.uuid)}}" target="{{watch.uuid}}" class="pure-button pure-button-primary diff-link">History</a> | ||||
|                            <a href="{{ url_for('ui.ui_views.diff_history_page', uuid=watch.uuid)}}" target="{{watch.uuid}}" class="pure-button pure-button-primary diff-link">History</a> | ||||
|                         {% endif %} | ||||
|  | ||||
|                     {% else %} | ||||
|                         {% if watch.history_n == 1 or (watch.history_n ==0 and watch.error_text_ctime )%} | ||||
|                             <a href="{{ url_for('preview_page', uuid=watch.uuid)}}" target="{{watch.uuid}}" class="pure-button pure-button-primary">Preview</a> | ||||
|                             <a href="{{ url_for('ui.ui_views.preview_page', uuid=watch.uuid)}}" target="{{watch.uuid}}" class="pure-button pure-button-primary">Preview</a> | ||||
|                         {% endif %} | ||||
|                     {% endif %} | ||||
|                 </td> | ||||
| @@ -215,15 +215,15 @@ | ||||
|             {% endif %} | ||||
|             {% if has_unviewed %} | ||||
|             <li> | ||||
|                 <a href="{{url_for('mark_all_viewed',with_errors=request.args.get('with_errors',0)) }}" class="pure-button button-tag ">Mark all viewed</a> | ||||
|                 <a href="{{url_for('ui.mark_all_viewed',with_errors=request.args.get('with_errors',0)) }}" class="pure-button button-tag ">Mark all viewed</a> | ||||
|             </li> | ||||
|             {% endif %} | ||||
|             <li> | ||||
|                <a href="{{ url_for('form_watch_checknow', tag=active_tag_uuid, with_errors=request.args.get('with_errors',0)) }}" class="pure-button button-tag ">Recheck | ||||
|                <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 ">Recheck | ||||
|                 all {% if active_tag_uuid %} in "{{active_tag.title}}"{%endif%}</a> | ||||
|             </li> | ||||
|             <li> | ||||
|                 <a href="{{ url_for('rss', tag=active_tag_uuid, token=app_rss_token)}}"><img alt="RSS Feed" id="feed-icon" src="{{url_for('static_content', group='images', filename='Generic_Feed-icon.svg')}}" height="15"></a> | ||||
|                 <a href="{{ url_for('rss.feed', tag=active_tag_uuid, token=app_rss_token)}}"><img alt="RSS Feed" id="feed-icon" src="{{url_for('static_content', group='images', filename='Generic_Feed-icon.svg')}}" height="15"></a> | ||||
|             </li> | ||||
|         </ul> | ||||
|         {{ pagination.links }} | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| #!/usr/bin/env python3 | ||||
| import resource | ||||
| import psutil | ||||
| import time | ||||
| from threading import Thread | ||||
|  | ||||
| @@ -28,9 +28,10 @@ def reportlog(pytestconfig): | ||||
|  | ||||
|  | ||||
| def track_memory(memory_usage, ): | ||||
|     process = psutil.Process(os.getpid()) | ||||
|     while not memory_usage["stop"]: | ||||
|         max_rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss | ||||
|         memory_usage["peak"] = max(memory_usage["peak"], max_rss) | ||||
|         current_rss = process.memory_info().rss | ||||
|         memory_usage["peak"] = max(memory_usage["peak"], current_rss) | ||||
|         time.sleep(0.01)  # Adjust the sleep time as needed | ||||
|  | ||||
| @pytest.fixture(scope='function') | ||||
|   | ||||
| @@ -16,7 +16,7 @@ def do_test(client, live_server, make_test_use_extra_browser=False): | ||||
|  | ||||
|     ##################### | ||||
|     res = client.post( | ||||
|         url_for("settings_page"), | ||||
|         url_for("settings.settings_page"), | ||||
|         data={"application-empty_pages_are_a_change": "", | ||||
|               "requests-time_between_check-minutes": 180, | ||||
|               'application-fetch_backend': "html_webdriver", | ||||
| @@ -30,7 +30,7 @@ def do_test(client, live_server, make_test_use_extra_browser=False): | ||||
|  | ||||
|     # Add our URL to the import page | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -42,13 +42,13 @@ def do_test(client, live_server, make_test_use_extra_browser=False): | ||||
|  | ||||
|         # So the name should appear in the edit page under "Request" > "Fetch Method" | ||||
|         res = client.get( | ||||
|             url_for("edit_page", uuid="first"), | ||||
|             url_for("ui.ui_edit.edit_page", uuid="first"), | ||||
|             follow_redirects=True | ||||
|         ) | ||||
|         assert b'custom browser URL' in res.data | ||||
|  | ||||
|         res = client.post( | ||||
|             url_for("edit_page", uuid="first"), | ||||
|             url_for("ui.ui_edit.edit_page", uuid="first"), | ||||
|             data={ | ||||
|                 # 'run_customer_browser_url_tests.sh' will search for this string to know if we hit the right browser container or not | ||||
|                   "url": f"https://changedetection.io/ci-test.html?custom-browser-search-string=1", | ||||
| @@ -64,13 +64,13 @@ def do_test(client, live_server, make_test_use_extra_browser=False): | ||||
|         wait_for_all_checks(client) | ||||
|  | ||||
|     # Force recheck | ||||
|     res = client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     assert b'1 watches queued for rechecking.' in res.data | ||||
|     res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     assert b'Queued 1 watch for rechecking.' in res.data | ||||
|  | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     res = client.get( | ||||
|         url_for("preview_page", uuid="first"), | ||||
|         url_for("ui.ui_views.preview_page", uuid="first"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b'cool it works' in res.data | ||||
|   | ||||
| @@ -11,7 +11,7 @@ def test_fetch_webdriver_content(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     ##################### | ||||
|     res = client.post( | ||||
|         url_for("settings_page"), | ||||
|         url_for("settings.settings_page"), | ||||
|         data={"application-empty_pages_are_a_change": "", | ||||
|               "requests-time_between_check-minutes": 180, | ||||
|               'application-fetch_backend': "html_webdriver"}, | ||||
| @@ -22,7 +22,7 @@ def test_fetch_webdriver_content(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     # Add our URL to the import page | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": "https://changedetection.io/ci-test.html"}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -32,7 +32,7 @@ def test_fetch_webdriver_content(client, live_server, measure_memory_usage): | ||||
|  | ||||
|  | ||||
|     res = client.get( | ||||
|         url_for("preview_page", uuid="first"), | ||||
|         url_for("ui.ui_views.preview_page", uuid="first"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     logging.getLogger().info("Looking for correct fetched HTML (text) from server") | ||||
|   | ||||
| @@ -13,7 +13,7 @@ def test_execute_custom_js(client, live_server, measure_memory_usage): | ||||
|     test_url = test_url.replace('localhost', 'cdio') | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("form_quick_watch_add"), | ||||
|         url_for("ui.ui_views.form_quick_watch_add"), | ||||
|         data={"url": test_url, "tags": '', 'edit_and_watch_submit_button': 'Edit > Watch'}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -21,7 +21,7 @@ def test_execute_custom_js(client, live_server, measure_memory_usage): | ||||
|     assert b"Watch added in Paused state, saving will unpause" in res.data | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first", unpause_on_save=1), | ||||
|         url_for("ui.ui_edit.edit_page", uuid="first", unpause_on_save=1), | ||||
|         data={ | ||||
|             "url": test_url, | ||||
|             "tags": "", | ||||
| @@ -41,7 +41,7 @@ def test_execute_custom_js(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     # Check HTML conversion detected and workd | ||||
|     res = client.get( | ||||
|         url_for("preview_page", uuid=uuid), | ||||
|         url_for("ui.ui_views.preview_page", uuid=uuid), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"This text should be removed" not in res.data | ||||
| @@ -51,6 +51,6 @@ def test_execute_custom_js(client, live_server, measure_memory_usage): | ||||
|     assert b"user-agent: mycustomagent" in res.data | ||||
|  | ||||
|     client.get( | ||||
|         url_for("form_delete", uuid="all"), | ||||
|         url_for("ui.form_delete", uuid="all"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -11,7 +11,7 @@ def test_preferred_proxy(client, live_server, measure_memory_usage): | ||||
|  | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("form_quick_watch_add"), | ||||
|         url_for("ui.ui_views.form_quick_watch_add"), | ||||
|         data={"url": url, "tags": '', 'edit_and_watch_submit_button': 'Edit > Watch'}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -19,7 +19,7 @@ def test_preferred_proxy(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     wait_for_all_checks(client) | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first", unpause_on_save=1), | ||||
|         url_for("ui.ui_edit.edit_page", uuid="first", unpause_on_save=1), | ||||
|         data={ | ||||
|                 "include_filters": "", | ||||
|                 "fetch_backend": 'html_webdriver' if os.getenv('PLAYWRIGHT_DRIVER_URL') else 'html_requests', | ||||
|   | ||||
| @@ -13,12 +13,12 @@ def test_noproxy_option(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     # Should only be available when a proxy is setup | ||||
|     res = client.get( | ||||
|         url_for("edit_page", uuid="first", unpause_on_save=1)) | ||||
|         url_for("ui.ui_edit.edit_page", uuid="first", unpause_on_save=1)) | ||||
|     assert b'No proxy' not in res.data | ||||
|  | ||||
|     # Setup a proxy | ||||
|     res = client.post( | ||||
|         url_for("settings_page"), | ||||
|         url_for("settings.settings_page"), | ||||
|         data={ | ||||
|             "requests-time_between_check-minutes": 180, | ||||
|             "application-ignore_whitespace": "y", | ||||
| @@ -37,24 +37,24 @@ def test_noproxy_option(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     # Should be available as an option | ||||
|     res = client.get( | ||||
|         url_for("settings_page", unpause_on_save=1)) | ||||
|         url_for("settings.settings_page", unpause_on_save=1)) | ||||
|     assert b'No proxy' in res.data | ||||
|  | ||||
|  | ||||
|     # This will add it paused | ||||
|     res = client.post( | ||||
|         url_for("form_quick_watch_add"), | ||||
|         url_for("ui.ui_views.form_quick_watch_add"), | ||||
|         data={"url": url, "tags": '', 'edit_and_watch_submit_button': 'Edit > Watch'}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"Watch added in Paused state, saving will unpause" in res.data | ||||
|     uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) | ||||
|     res = client.get( | ||||
|         url_for("edit_page", uuid=uuid, unpause_on_save=1)) | ||||
|         url_for("ui.ui_edit.edit_page", uuid=uuid, unpause_on_save=1)) | ||||
|     assert b'No proxy' in res.data | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid=uuid, unpause_on_save=1), | ||||
|         url_for("ui.ui_edit.edit_page", uuid=uuid, unpause_on_save=1), | ||||
|         data={ | ||||
|                 "include_filters": "", | ||||
|                 "fetch_backend": "html_requests", | ||||
| @@ -67,7 +67,7 @@ def test_noproxy_option(client, live_server, measure_memory_usage): | ||||
|     ) | ||||
|     assert b"unpaused" in res.data | ||||
|     wait_for_all_checks(client) | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|     # Now the request should NOT appear in the second-squid logs (handled by the run_test_proxies.sh script) | ||||
|  | ||||
|   | ||||
| @@ -8,7 +8,7 @@ from ..util import live_server_setup, wait_for_all_checks, extract_UUID_from_cli | ||||
| def test_check_basic_change_detection_functionality(client, live_server, measure_memory_usage): | ||||
|     live_server_setup(live_server) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         # Because a URL wont show in squid/proxy logs due it being SSLed | ||||
|         # Use plain HTTP or a specific domain-name here | ||||
|         data={"urls": "http://one.changedetection.io"}, | ||||
|   | ||||
| @@ -11,7 +11,7 @@ def test_select_custom(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     # Goto settings, add our custom one | ||||
|     res = client.post( | ||||
|         url_for("settings_page"), | ||||
|         url_for("settings.settings_page"), | ||||
|         data={ | ||||
|             "requests-time_between_check-minutes": 180, | ||||
|             "application-ignore_whitespace": "y", | ||||
| @@ -26,7 +26,7 @@ def test_select_custom(client, live_server, measure_memory_usage): | ||||
|     assert b"Settings updated." in res.data | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         # Because a URL wont show in squid/proxy logs due it being SSLed | ||||
|         # Use plain HTTP or a specific domain-name here | ||||
|         data={"urls": "https://changedetection.io/CHANGELOG.txt"}, | ||||
| @@ -40,7 +40,7 @@ def test_select_custom(client, live_server, measure_memory_usage): | ||||
|     assert b'Proxy Authentication Required' not in res.data | ||||
|  | ||||
|     res = client.get( | ||||
|         url_for("preview_page", uuid="first"), | ||||
|         url_for("ui.ui_views.preview_page", uuid="first"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     # We should see something via proxy | ||||
|   | ||||
| @@ -25,7 +25,7 @@ def test_socks5(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     # Setup a proxy | ||||
|     res = client.post( | ||||
|         url_for("settings_page"), | ||||
|         url_for("settings.settings_page"), | ||||
|         data={ | ||||
|             "requests-time_between_check-minutes": 180, | ||||
|             "application-ignore_whitespace": "y", | ||||
| @@ -45,20 +45,20 @@ def test_socks5(client, live_server, measure_memory_usage): | ||||
|     test_url = test_url.replace('localhost', 'cdio') | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("form_quick_watch_add"), | ||||
|         url_for("ui.ui_views.form_quick_watch_add"), | ||||
|         data={"url": test_url, "tags": '', 'edit_and_watch_submit_button': 'Edit > Watch'}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"Watch added in Paused state, saving will unpause" in res.data | ||||
|  | ||||
|     res = client.get( | ||||
|         url_for("edit_page", uuid="first", unpause_on_save=1), | ||||
|         url_for("ui.ui_edit.edit_page", uuid="first", unpause_on_save=1), | ||||
|     ) | ||||
|     # check the proxy is offered as expected | ||||
|     assert b'ui-0socks5proxy' in res.data | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first", unpause_on_save=1), | ||||
|         url_for("ui.ui_edit.edit_page", uuid="first", unpause_on_save=1), | ||||
|         data={ | ||||
|             "include_filters": "", | ||||
|             "fetch_backend": 'html_webdriver' if os.getenv('PLAYWRIGHT_DRIVER_URL') else 'html_requests', | ||||
| @@ -73,7 +73,7 @@ def test_socks5(client, live_server, measure_memory_usage): | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     res = client.get( | ||||
|         url_for("preview_page", uuid="first"), | ||||
|         url_for("ui.ui_views.preview_page", uuid="first"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
| @@ -97,6 +97,6 @@ def test_socks5(client, live_server, measure_memory_usage): | ||||
|     ) | ||||
|     assert b"OK" in res.data | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
|   | ||||
| @@ -28,24 +28,24 @@ def test_socks5_from_proxiesjson_file(client, live_server, measure_memory_usage) | ||||
|     test_url = test_url.replace('localhost.localdomain', 'cdio') | ||||
|     test_url = test_url.replace('localhost', 'cdio') | ||||
|  | ||||
|     res = client.get(url_for("settings_page")) | ||||
|     res = client.get(url_for("settings.settings_page")) | ||||
|     assert b'name="requests-proxy" type="radio" value="socks5proxy"' in res.data | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("form_quick_watch_add"), | ||||
|         url_for("ui.ui_views.form_quick_watch_add"), | ||||
|         data={"url": test_url, "tags": '', 'edit_and_watch_submit_button': 'Edit > Watch'}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"Watch added in Paused state, saving will unpause" in res.data | ||||
|  | ||||
|     res = client.get( | ||||
|         url_for("edit_page", uuid="first", unpause_on_save=1), | ||||
|         url_for("ui.ui_edit.edit_page", uuid="first", unpause_on_save=1), | ||||
|     ) | ||||
|     # check the proxy is offered as expected | ||||
|     assert b'name="proxy" type="radio" value="socks5proxy"' in res.data | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first", unpause_on_save=1), | ||||
|         url_for("ui.ui_edit.edit_page", uuid="first", unpause_on_save=1), | ||||
|         data={ | ||||
|             "include_filters": "", | ||||
|             "fetch_backend": 'html_webdriver' if os.getenv('PLAYWRIGHT_DRIVER_URL') else 'html_requests', | ||||
| @@ -60,7 +60,7 @@ def test_socks5_from_proxiesjson_file(client, live_server, measure_memory_usage) | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     res = client.get( | ||||
|         url_for("preview_page", uuid="first"), | ||||
|         url_for("ui.ui_views.preview_page", uuid="first"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|   | ||||
| @@ -62,7 +62,7 @@ def test_restock_detection(client, live_server, measure_memory_usage): | ||||
|     ##################### | ||||
|     # Set this up for when we remove the notification from the watch, it should fallback with these details | ||||
|     res = client.post( | ||||
|         url_for("settings_page"), | ||||
|         url_for("settings.settings_page"), | ||||
|         data={"application-notification_urls": notification_url, | ||||
|               "application-notification_title": "fallback-title "+default_notification_title, | ||||
|               "application-notification_body": "fallback-body "+default_notification_body, | ||||
| @@ -76,7 +76,7 @@ def test_restock_detection(client, live_server, measure_memory_usage): | ||||
|  | ||||
|  | ||||
|     client.post( | ||||
|         url_for("form_quick_watch_add"), | ||||
|         url_for("ui.ui_views.form_quick_watch_add"), | ||||
|         data={"url": test_url, "tags": '', 'processor': 'restock_diff'}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -88,7 +88,7 @@ def test_restock_detection(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     # Is it correctly shown as in stock | ||||
|     set_back_in_stock_response() | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'not-in-stock' not in res.data | ||||
| @@ -101,7 +101,7 @@ def test_restock_detection(client, live_server, measure_memory_usage): | ||||
|     # Default behaviour is to only fire notification when it goes OUT OF STOCK -> IN STOCK | ||||
|     # So here there should be no file, because we go IN STOCK -> OUT OF STOCK | ||||
|     set_original_response() | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|     time.sleep(5) | ||||
|     assert not os.path.isfile("test-datastore/notification.txt"), "No notification should have fired when it went OUT OF STOCK by default" | ||||
|   | ||||
| @@ -50,7 +50,7 @@ def test_check_notification_email_formats_default_HTML(client, live_server, meas | ||||
|     ##################### | ||||
|     # Set this up for when we remove the notification from the watch, it should fallback with these details | ||||
|     res = client.post( | ||||
|         url_for("settings_page"), | ||||
|         url_for("settings.settings_page"), | ||||
|         data={"application-notification_urls": notification_url, | ||||
|               "application-notification_title": "fallback-title " + default_notification_title, | ||||
|               "application-notification_body": "fallback-body<br> " + default_notification_body, | ||||
| @@ -64,7 +64,7 @@ def test_check_notification_email_formats_default_HTML(client, live_server, meas | ||||
|     # Add a watch and trigger a HTTP POST | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
|         url_for("form_quick_watch_add"), | ||||
|         url_for("ui.ui_views.form_quick_watch_add"), | ||||
|         data={"url": test_url, "tags": 'nice one'}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -75,7 +75,7 @@ def test_check_notification_email_formats_default_HTML(client, live_server, meas | ||||
|     set_longer_modified_response() | ||||
|     time.sleep(2) | ||||
|  | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     time.sleep(3) | ||||
| @@ -88,7 +88,7 @@ def test_check_notification_email_formats_default_HTML(client, live_server, meas | ||||
|     assert '(added) So let\'s see what happens.\r\n' in msg  # The plaintext part with \r\n | ||||
|     assert 'Content-Type: text/html' in msg | ||||
|     assert '(added) So let\'s see what happens.<br>' in msg  # the html part | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
|  | ||||
| @@ -116,7 +116,7 @@ def test_check_notification_email_formats_default_Text_override_HTML(client, liv | ||||
|     ##################### | ||||
|     # Set this up for when we remove the notification from the watch, it should fallback with these details | ||||
|     res = client.post( | ||||
|         url_for("settings_page"), | ||||
|         url_for("settings.settings_page"), | ||||
|         data={"application-notification_urls": notification_url, | ||||
|               "application-notification_title": "fallback-title " + default_notification_title, | ||||
|               "application-notification_body": notification_body, | ||||
| @@ -130,7 +130,7 @@ def test_check_notification_email_formats_default_Text_override_HTML(client, liv | ||||
|     # Add a watch and trigger a HTTP POST | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
|         url_for("form_quick_watch_add"), | ||||
|         url_for("ui.ui_views.form_quick_watch_add"), | ||||
|         data={"url": test_url, "tags": 'nice one'}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -140,7 +140,7 @@ def test_check_notification_email_formats_default_Text_override_HTML(client, liv | ||||
|     wait_for_all_checks(client) | ||||
|     set_longer_modified_response() | ||||
|     time.sleep(2) | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     time.sleep(3) | ||||
| @@ -157,7 +157,7 @@ def test_check_notification_email_formats_default_Text_override_HTML(client, liv | ||||
|     set_original_response() | ||||
|     # Now override as HTML format | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         url_for("ui.ui_edit.edit_page", uuid="first"), | ||||
|         data={ | ||||
|             "url": test_url, | ||||
|             "notification_format": 'HTML', | ||||
| @@ -182,5 +182,5 @@ def test_check_notification_email_formats_default_Text_override_HTML(client, liv | ||||
|     assert '<' not in msg | ||||
|     assert 'Content-Type: text/html' in msg | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| from .util import live_server_setup, extract_UUID_from_client, wait_for_all_checks | ||||
| from .util import live_server_setup | ||||
| from flask import url_for | ||||
| import time | ||||
|  | ||||
| @@ -8,12 +8,12 @@ def test_check_access_control(app, client, live_server): | ||||
|  | ||||
|     with app.test_client(use_cookies=True) as c: | ||||
|         # Check we don't have any password protection enabled yet. | ||||
|         res = c.get(url_for("settings_page")) | ||||
|         res = c.get(url_for("settings.settings_page")) | ||||
|         assert b"Remove password" not in res.data | ||||
|  | ||||
|         # add something that we can hit via diff page later | ||||
|         res = c.post( | ||||
|             url_for("import_page"), | ||||
|             url_for("imports.import_page"), | ||||
|             data={"urls": url_for('test_random_content_endpoint', _external=True)}, | ||||
|             follow_redirects=True | ||||
|         ) | ||||
| @@ -23,8 +23,9 @@ def test_check_access_control(app, client, live_server): | ||||
|         # causes a 'Popped wrong request context.' error when client. is accessed? | ||||
|         #wait_for_all_checks(client) | ||||
|  | ||||
|         res = c.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|         assert b'1 watches queued for rechecking.' in res.data | ||||
|         res = c.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|         assert b'Queued 1 watch for rechecking.' in res.data | ||||
|  | ||||
|         time.sleep(3) | ||||
|         # causes a 'Popped wrong request context.' error when client. is accessed? | ||||
|         #wait_for_all_checks(client) | ||||
| @@ -32,7 +33,7 @@ def test_check_access_control(app, client, live_server): | ||||
|  | ||||
|         # Enable password check and diff page access bypass | ||||
|         res = c.post( | ||||
|             url_for("settings_page"), | ||||
|             url_for("settings.settings_page"), | ||||
|             data={"application-password": "foobar", | ||||
|                   "application-shared_diff_access": "True", | ||||
|                   "requests-time_between_check-minutes": 180, | ||||
| @@ -48,7 +49,7 @@ def test_check_access_control(app, client, live_server): | ||||
|         assert b"Login" in res.data | ||||
|  | ||||
|         # The diff page should return something valid when logged out | ||||
|         res = c.get(url_for("diff_history_page", uuid="first")) | ||||
|         res = c.get(url_for("ui.ui_views.diff_history_page", uuid="first")) | ||||
|         assert b'Random content' in res.data | ||||
|  | ||||
|         # Check wrong password does not let us in | ||||
| @@ -79,7 +80,7 @@ def test_check_access_control(app, client, live_server): | ||||
|  | ||||
|         # 598 - Password should be set and not accidently removed | ||||
|         res = c.post( | ||||
|             url_for("settings_page"), | ||||
|             url_for("settings.settings_page"), | ||||
|             data={ | ||||
|                   "requests-time_between_check-minutes": 180, | ||||
|                   'application-fetch_backend': "html_requests"}, | ||||
| @@ -91,7 +92,7 @@ def test_check_access_control(app, client, live_server): | ||||
|  | ||||
|         assert b"Login" in res.data | ||||
|  | ||||
|         res = c.get(url_for("settings_page"), | ||||
|         res = c.get(url_for("settings.settings_page"), | ||||
|             follow_redirects=True) | ||||
|  | ||||
|  | ||||
| @@ -110,7 +111,7 @@ def test_check_access_control(app, client, live_server): | ||||
|         # Yes we are correctly logged in | ||||
|         assert b"LOG OUT" in res.data | ||||
|  | ||||
|         res = c.get(url_for("settings_page")) | ||||
|         res = c.get(url_for("settings.settings_page")) | ||||
|  | ||||
|         # Menu should be available now | ||||
|         assert b"SETTINGS" in res.data | ||||
| @@ -124,7 +125,7 @@ def test_check_access_control(app, client, live_server): | ||||
|         # Remove password button, and check that it worked | ||||
|         ################################################## | ||||
|         res = c.post( | ||||
|             url_for("settings_page"), | ||||
|             url_for("settings.settings_page"), | ||||
|             data={ | ||||
|                 "requests-time_between_check-minutes": 180, | ||||
|                 "application-fetch_backend": "html_webdriver", | ||||
| @@ -139,7 +140,7 @@ def test_check_access_control(app, client, live_server): | ||||
|         # Be sure a blank password doesnt setup password protection | ||||
|         ############################################################ | ||||
|         res = c.post( | ||||
|             url_for("settings_page"), | ||||
|             url_for("settings.settings_page"), | ||||
|             data={"application-password": "", | ||||
|                   "requests-time_between_check-minutes": 180, | ||||
|                   'application-fetch_backend': "html_requests"}, | ||||
| @@ -151,7 +152,7 @@ def test_check_access_control(app, client, live_server): | ||||
|         # Now checking the diff access | ||||
|         # Enable password check and diff page access bypass | ||||
|         res = c.post( | ||||
|             url_for("settings_page"), | ||||
|             url_for("settings.settings_page"), | ||||
|             data={"application-password": "foobar", | ||||
|                   # Should be disabled | ||||
| #                  "application-shared_diff_access": "True", | ||||
| @@ -168,5 +169,5 @@ def test_check_access_control(app, client, live_server): | ||||
|         assert b"Login" in res.data | ||||
|  | ||||
|         # The diff page should return something valid when logged out | ||||
|         res = c.get(url_for("diff_history_page", uuid="first")) | ||||
|         res = c.get(url_for("ui.ui_views.diff_history_page", uuid="first")) | ||||
|         assert b'Random content' not in res.data | ||||
|   | ||||
| @@ -45,7 +45,7 @@ def test_check_removed_line_contains_trigger(client, live_server, measure_memory | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -57,7 +57,7 @@ def test_check_removed_line_contains_trigger(client, live_server, measure_memory | ||||
|     # Goto the edit page, add our ignore text | ||||
|     # Add our URL to the import page | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         url_for("ui.ui_edit.edit_page", uuid="first"), | ||||
|         data={"trigger_text": 'The golden line', | ||||
|               "url": test_url, | ||||
|               'fetch_backend': "html_requests", | ||||
| @@ -69,8 +69,8 @@ def test_check_removed_line_contains_trigger(client, live_server, measure_memory | ||||
|     set_original(excluding='Something irrelevant') | ||||
|  | ||||
|     # A line thats not the trigger should not trigger anything | ||||
|     res = client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     assert b'1 watches queued for rechecking.' in res.data | ||||
|     res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     assert b'Queued 1 watch for rechecking.' in res.data | ||||
|     wait_for_all_checks(client) | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' not in res.data | ||||
| @@ -79,28 +79,28 @@ def test_check_removed_line_contains_trigger(client, live_server, measure_memory | ||||
|     set_original(excluding='The golden line') | ||||
|  | ||||
|     # Check in the processor here what's going on, its triggering empty-reply and no change. | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' in res.data | ||||
|  | ||||
|  | ||||
|     # Now add it back, and we should not get a trigger | ||||
|     client.get(url_for("mark_all_viewed"), follow_redirects=True) | ||||
|     client.get(url_for("ui.mark_all_viewed"), follow_redirects=True) | ||||
|     set_original(excluding=None) | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' not in res.data | ||||
|  | ||||
|     # Remove it again, and we should get a trigger | ||||
|     set_original(excluding='The golden line') | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' in res.data | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
|  | ||||
| @@ -111,7 +111,7 @@ def test_check_add_line_contains_trigger(client, live_server, measure_memory_usa | ||||
|     test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://') + "?xxx={{ watch_url }}" | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("settings_page"), | ||||
|         url_for("settings.settings_page"), | ||||
|         data={"application-notification_title": "New ChangeDetection.io Notification - {{ watch_url }}", | ||||
|               # triggered_text will contain multiple lines | ||||
|               "application-notification_body": 'triggered text was -{{triggered_text}}- ### 网站监测 内容更新了 ####', | ||||
| @@ -128,7 +128,7 @@ def test_check_add_line_contains_trigger(client, live_server, measure_memory_usa | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -139,7 +139,7 @@ def test_check_add_line_contains_trigger(client, live_server, measure_memory_usa | ||||
|     # Goto the edit page, add our ignore text | ||||
|     # Add our URL to the import page | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         url_for("ui.ui_edit.edit_page", uuid="first"), | ||||
|         data={"trigger_text": 'Oh yes please', | ||||
|               "url": test_url, | ||||
|               'processor': 'text_json_diff', | ||||
| @@ -153,8 +153,8 @@ def test_check_add_line_contains_trigger(client, live_server, measure_memory_usa | ||||
|     set_original(excluding='Something irrelevant') | ||||
|  | ||||
|     # A line thats not the trigger should not trigger anything | ||||
|     res = client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     assert b'1 watches queued for rechecking.' in res.data | ||||
|     res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     assert b'Queued 1 watch for rechecking.' in res.data | ||||
|  | ||||
|     wait_for_all_checks(client) | ||||
|     res = client.get(url_for("index")) | ||||
| @@ -162,9 +162,10 @@ def test_check_add_line_contains_trigger(client, live_server, measure_memory_usa | ||||
|  | ||||
|     # The trigger line is ADDED,  this should trigger | ||||
|     set_original(add_line='<p>Oh yes please</p>') | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|     res = client.get(url_for("index")) | ||||
|  | ||||
|     assert b'unviewed' in res.data | ||||
|  | ||||
|     # Takes a moment for apprise to fire | ||||
| @@ -175,5 +176,5 @@ def test_check_add_line_contains_trigger(client, live_server, measure_memory_usa | ||||
|         assert b'-Oh yes please' in response | ||||
|         assert '网站监测 内容更新了'.encode('utf-8') in response | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|   | ||||
| @@ -2,7 +2,7 @@ | ||||
|  | ||||
| import time | ||||
| from flask import url_for | ||||
| from .util import live_server_setup, extract_api_key_from_UI, wait_for_all_checks | ||||
| from .util import live_server_setup, wait_for_all_checks | ||||
|  | ||||
| import json | ||||
| import uuid | ||||
| @@ -57,16 +57,15 @@ def test_setup(client, live_server, measure_memory_usage): | ||||
|  | ||||
|  | ||||
| def test_api_simple(client, live_server, measure_memory_usage): | ||||
| #    live_server_setup(live_server) | ||||
|     #live_server_setup(live_server) | ||||
|  | ||||
|     api_key = extract_api_key_from_UI(client) | ||||
|     api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token') | ||||
|  | ||||
|     # Create a watch | ||||
|     set_original_response() | ||||
|  | ||||
|     # Validate bad URL | ||||
|     test_url = url_for('test_endpoint', _external=True, | ||||
|                        headers={'x-api-key': api_key}, ) | ||||
|     test_url = url_for('test_endpoint', _external=True ) | ||||
|     res = client.post( | ||||
|         url_for("createwatch"), | ||||
|         data=json.dumps({"url": "h://xxxxxxxxxom"}), | ||||
| @@ -173,7 +172,7 @@ def test_api_simple(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     assert watch.get('viewed') == False | ||||
|     # Loading the most recent snapshot should force viewed to become true | ||||
|     client.get(url_for("diff_history_page", uuid="first"), follow_redirects=True) | ||||
|     client.get(url_for("ui.ui_views.diff_history_page", uuid="first"), follow_redirects=True) | ||||
|  | ||||
|     time.sleep(3) | ||||
|     # Fetch the whole watch again, viewed should be true | ||||
| @@ -259,7 +258,7 @@ def test_access_denied(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     # Disable config_api_token_enabled and it should work | ||||
|     res = client.post( | ||||
|         url_for("settings_page"), | ||||
|         url_for("settings.settings_page"), | ||||
|         data={ | ||||
|             "requests-time_between_check-minutes": 180, | ||||
|             "application-fetch_backend": "html_requests", | ||||
| @@ -276,11 +275,11 @@ def test_access_denied(client, live_server, measure_memory_usage): | ||||
|     assert res.status_code == 200 | ||||
|  | ||||
|     # Cleanup everything | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("settings_page"), | ||||
|         url_for("settings.settings_page"), | ||||
|         data={ | ||||
|             "requests-time_between_check-minutes": 180, | ||||
|             "application-fetch_backend": "html_requests", | ||||
| @@ -293,12 +292,11 @@ def test_access_denied(client, live_server, measure_memory_usage): | ||||
| def test_api_watch_PUT_update(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     #live_server_setup(live_server) | ||||
|     api_key = extract_api_key_from_UI(client) | ||||
|     api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token') | ||||
|  | ||||
|     # Create a watch | ||||
|     set_original_response() | ||||
|     test_url = url_for('test_endpoint', _external=True, | ||||
|                        headers={'x-api-key': api_key}, ) | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|  | ||||
|     # Create new | ||||
|     res = client.post( | ||||
| @@ -321,7 +319,7 @@ def test_api_watch_PUT_update(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     # Check in the edit page just to be sure | ||||
|     res = client.get( | ||||
|         url_for("edit_page", uuid=watch_uuid), | ||||
|         url_for("ui.ui_edit.edit_page", uuid=watch_uuid), | ||||
|     ) | ||||
|     assert b"cookie: yum" in res.data, "'cookie: yum' found in 'headers' section" | ||||
|     assert b"One" in res.data, "Tag 'One' was found" | ||||
| @@ -344,7 +342,7 @@ def test_api_watch_PUT_update(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     # Check in the edit page just to be sure | ||||
|     res = client.get( | ||||
|         url_for("edit_page", uuid=watch_uuid), | ||||
|         url_for("ui.ui_edit.edit_page", uuid=watch_uuid), | ||||
|     ) | ||||
|     assert b"new title" in res.data, "new title found in edit page" | ||||
|     assert b"552" in res.data, "552 minutes found in edit page" | ||||
| @@ -368,12 +366,13 @@ def test_api_watch_PUT_update(client, live_server, measure_memory_usage): | ||||
|     assert b'Additional properties are not allowed' in res.data | ||||
|  | ||||
|     # Cleanup everything | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
|  | ||||
| def test_api_import(client, live_server, measure_memory_usage): | ||||
|     api_key = extract_api_key_from_UI(client) | ||||
|     #live_server_setup(live_server) | ||||
|     api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token') | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("import") + "?tag=import-test", | ||||
| @@ -391,4 +390,48 @@ def test_api_import(client, live_server, measure_memory_usage): | ||||
|     # Should see the new tag in the tag/groups list | ||||
|     res = client.get(url_for('tags.tags_overview_page')) | ||||
|     assert b'import-test' in res.data | ||||
|      | ||||
|  | ||||
| def test_api_conflict_UI_password(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     #live_server_setup(live_server) | ||||
|     api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token') | ||||
|  | ||||
|     # Enable password check and diff page access bypass | ||||
|     res = client.post( | ||||
|         url_for("settings.settings_page"), | ||||
|         data={"application-password": "foobar", # password is now set! API should still work! | ||||
|               "application-api_access_token_enabled": "y", | ||||
|               "requests-time_between_check-minutes": 180, | ||||
|               'application-fetch_backend': "html_requests"}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|     assert b"Password protection enabled." in res.data | ||||
|  | ||||
|     # Create a watch | ||||
|     set_original_response() | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|  | ||||
|     # Create new | ||||
|     res = client.post( | ||||
|         url_for("createwatch"), | ||||
|         data=json.dumps({"url": test_url, "title": "My test URL" }), | ||||
|         headers={'content-type': 'application/json', 'x-api-key': api_key}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|     assert res.status_code == 201 | ||||
|  | ||||
|  | ||||
|     wait_for_all_checks(client) | ||||
|     url = url_for("createwatch") | ||||
|     # Get a listing, it will be the first one | ||||
|     res = client.get( | ||||
|         url, | ||||
|         headers={'x-api-key': api_key} | ||||
|     ) | ||||
|     assert res.status_code == 200 | ||||
|  | ||||
|     assert len(res.json) | ||||
|  | ||||
|  | ||||
|   | ||||
							
								
								
									
										143
									
								
								changedetectionio/tests/test_api_tags.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,143 @@ | ||||
| #!/usr/bin/env python3 | ||||
|  | ||||
| from flask import url_for | ||||
| from .util import live_server_setup, wait_for_all_checks | ||||
| import json | ||||
|  | ||||
| def test_api_tags_listing(client, live_server, measure_memory_usage): | ||||
|     live_server_setup(live_server) | ||||
|     api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token') | ||||
|     tag_title = 'Test Tag' | ||||
|  | ||||
|     # Get a listing | ||||
|     res = client.get( | ||||
|         url_for("tags"), | ||||
|         headers={'x-api-key': api_key} | ||||
|     ) | ||||
|     assert res.text.strip() == "{}", "Should be empty list" | ||||
|     assert res.status_code == 200 | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("tag"), | ||||
|         data=json.dumps({"title": tag_title}), | ||||
|         headers={'content-type': 'application/json', 'x-api-key': api_key} | ||||
|     ) | ||||
|     assert res.status_code == 201 | ||||
|  | ||||
|     new_tag_uuid = res.json.get('uuid') | ||||
|  | ||||
|     # List tags - should include our new tag | ||||
|     res = client.get( | ||||
|         url_for("tags"), | ||||
|         headers={'x-api-key': api_key} | ||||
|     ) | ||||
|     assert res.status_code == 200 | ||||
|     assert new_tag_uuid in res.text | ||||
|     assert res.json[new_tag_uuid]['title'] == tag_title | ||||
|     assert res.json[new_tag_uuid]['notification_muted'] == False | ||||
|  | ||||
|     # Get single tag | ||||
|     res = client.get( | ||||
|         url_for("tag", uuid=new_tag_uuid), | ||||
|         headers={'x-api-key': api_key} | ||||
|     ) | ||||
|     assert res.status_code == 200 | ||||
|     assert res.json['title'] == tag_title | ||||
|  | ||||
|     # Update tag | ||||
|     res = client.put( | ||||
|         url_for("tag", uuid=new_tag_uuid), | ||||
|         data=json.dumps({"title": "Updated Tag"}), | ||||
|         headers={'content-type': 'application/json', 'x-api-key': api_key} | ||||
|     ) | ||||
|     assert res.status_code == 200 | ||||
|     assert b'OK' in res.data | ||||
|  | ||||
|     # Verify update worked | ||||
|     res = client.get( | ||||
|         url_for("tag", uuid=new_tag_uuid), | ||||
|         headers={'x-api-key': api_key} | ||||
|     ) | ||||
|     assert res.status_code == 200 | ||||
|     assert res.json['title'] == 'Updated Tag' | ||||
|  | ||||
|     # Mute tag notifications | ||||
|     res = client.get( | ||||
|         url_for("tag", uuid=new_tag_uuid) + "?muted=muted", | ||||
|         headers={'x-api-key': api_key} | ||||
|     ) | ||||
|     assert res.status_code == 200 | ||||
|     assert b'OK' in res.data | ||||
|  | ||||
|     # Verify muted status | ||||
|     res = client.get( | ||||
|         url_for("tag", uuid=new_tag_uuid), | ||||
|         headers={'x-api-key': api_key} | ||||
|     ) | ||||
|     assert res.status_code == 200 | ||||
|     assert res.json['notification_muted'] == True | ||||
|  | ||||
|     # Unmute tag | ||||
|     res = client.get( | ||||
|         url_for("tag", uuid=new_tag_uuid) + "?muted=unmuted", | ||||
|         headers={'x-api-key': api_key} | ||||
|     ) | ||||
|     assert res.status_code == 200 | ||||
|     assert b'OK' in res.data | ||||
|  | ||||
|     # Verify unmuted status | ||||
|     res = client.get( | ||||
|         url_for("tag", uuid=new_tag_uuid), | ||||
|         headers={'x-api-key': api_key} | ||||
|     ) | ||||
|     assert res.status_code == 200 | ||||
|     assert res.json['notification_muted'] == False | ||||
|  | ||||
|     # Create a watch with the tag and check it matches UUID | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
|         url_for("createwatch"), | ||||
|         data=json.dumps({"url": test_url, "tag": "Updated Tag", "title": "Watch with tag"}), | ||||
|         headers={'content-type': 'application/json', 'x-api-key': api_key}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert res.status_code == 201 | ||||
|     watch_uuid = res.json.get('uuid') | ||||
|  | ||||
|     # Verify tag is associated with watch by name if need be | ||||
|     res = client.get( | ||||
|         url_for("watch", uuid=watch_uuid), | ||||
|         headers={'x-api-key': api_key} | ||||
|     ) | ||||
|     assert res.status_code == 200 | ||||
|     assert new_tag_uuid in res.json.get('tags', []) | ||||
|  | ||||
|     # Delete tag | ||||
|     res = client.delete( | ||||
|         url_for("tag", uuid=new_tag_uuid), | ||||
|         headers={'x-api-key': api_key} | ||||
|     ) | ||||
|     assert res.status_code == 204 | ||||
|  | ||||
|     # Verify tag is gone | ||||
|     res = client.get( | ||||
|         url_for("tags"), | ||||
|         headers={'x-api-key': api_key} | ||||
|     ) | ||||
|     assert res.status_code == 200 | ||||
|     assert new_tag_uuid not in res.text | ||||
|  | ||||
|     # Verify tag was removed from watch | ||||
|     res = client.get( | ||||
|         url_for("watch", uuid=watch_uuid), | ||||
|         headers={'x-api-key': api_key} | ||||
|     ) | ||||
|     assert res.status_code == 200 | ||||
|     assert new_tag_uuid not in res.json.get('tags', []) | ||||
|  | ||||
|     # Delete the watch | ||||
|     res = client.delete( | ||||
|         url_for("watch", uuid=watch_uuid), | ||||
|         headers={'x-api-key': api_key}, | ||||
|     ) | ||||
|     assert res.status_code == 204 | ||||
| @@ -13,7 +13,7 @@ def test_basic_auth(client, live_server, measure_memory_usage): | ||||
|     test_url = url_for('test_basicauth_method', _external=True).replace("//","//myuser:mypass@") | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -22,7 +22,7 @@ def test_basic_auth(client, live_server, measure_memory_usage): | ||||
|     time.sleep(1) | ||||
|     # Check form validation | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         url_for("ui.ui_edit.edit_page", uuid="first"), | ||||
|         data={"include_filters": "", "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests"}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -30,7 +30,7 @@ def test_basic_auth(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     wait_for_all_checks(client) | ||||
|     res = client.get( | ||||
|         url_for("preview_page", uuid="first"), | ||||
|         url_for("ui.ui_views.preview_page", uuid="first"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|   | ||||
| @@ -2,7 +2,7 @@ | ||||
|  | ||||
| import time | ||||
| from flask import url_for | ||||
| from .util import live_server_setup, extract_UUID_from_client, extract_api_key_from_UI, wait_for_all_checks | ||||
| from .util import live_server_setup, extract_UUID_from_client, wait_for_all_checks | ||||
|  | ||||
|  | ||||
| def set_response_with_ldjson(): | ||||
| @@ -87,7 +87,7 @@ def test_check_ldjson_price_autodetect(client, live_server, measure_memory_usage | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -102,7 +102,7 @@ def test_check_ldjson_price_autodetect(client, live_server, measure_memory_usage | ||||
|     uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) | ||||
|     #time.sleep(1) | ||||
|     client.get(url_for('price_data_follower.accept', uuid=uuid, follow_redirects=True)) | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|     # Offer should be gone | ||||
|     res = client.get(url_for("index")) | ||||
| @@ -110,7 +110,7 @@ def test_check_ldjson_price_autodetect(client, live_server, measure_memory_usage | ||||
|     assert b'tracking-ldjson-price-data' in res.data | ||||
|  | ||||
|     # and last snapshop (via API) should be just the price | ||||
|     api_key = extract_api_key_from_UI(client) | ||||
|     api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token') | ||||
|     res = client.get( | ||||
|         url_for("watchsinglehistory", uuid=uuid, timestamp='latest'), | ||||
|         headers={'x-api-key': api_key}, | ||||
| @@ -121,7 +121,7 @@ def test_check_ldjson_price_autodetect(client, live_server, measure_memory_usage | ||||
|     # And not this cause its not the ld-json | ||||
|     assert b"So let's see what happens" not in res.data | ||||
|  | ||||
|     client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|  | ||||
|     ########################################################################################## | ||||
|     # And we shouldnt see the offer | ||||
| @@ -130,7 +130,7 @@ def test_check_ldjson_price_autodetect(client, live_server, measure_memory_usage | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -140,14 +140,14 @@ def test_check_ldjson_price_autodetect(client, live_server, measure_memory_usage | ||||
|     assert b'ldjson-price-track-offer' not in res.data | ||||
|      | ||||
|     ########################################################################################## | ||||
|     client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|  | ||||
|  | ||||
| def _test_runner_check_bad_format_ignored(live_server, client, has_ldjson_price_data): | ||||
|  | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -160,7 +160,7 @@ def _test_runner_check_bad_format_ignored(live_server, client, has_ldjson_price_ | ||||
|  | ||||
|  | ||||
|     ########################################################################################## | ||||
|     client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|  | ||||
|  | ||||
| def test_bad_ldjson_is_correctly_ignored(client, live_server, measure_memory_usage): | ||||
|   | ||||
| @@ -22,7 +22,7 @@ def test_check_basic_change_detection_functionality(client, live_server, measure | ||||
|  | ||||
|     # Add our URL to the import page | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": url_for('test_endpoint', _external=True)}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -33,7 +33,7 @@ def test_check_basic_change_detection_functionality(client, live_server, measure | ||||
|  | ||||
|     # Do this a few times.. ensures we dont accidently set the status | ||||
|     for n in range(3): | ||||
|         client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|         client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|  | ||||
|         # Give the thread time to pick it up | ||||
|         wait_for_all_checks(client) | ||||
| @@ -53,7 +53,7 @@ def test_check_basic_change_detection_functionality(client, live_server, measure | ||||
|  | ||||
|     # Check HTML conversion detected and workd | ||||
|     res = client.get( | ||||
|         url_for("preview_page", uuid="first"), | ||||
|         url_for("ui.ui_views.preview_page", uuid="first"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     # Check this class does not appear (that we didnt see the actual source) | ||||
| @@ -63,15 +63,15 @@ def test_check_basic_change_detection_functionality(client, live_server, measure | ||||
|     set_modified_response() | ||||
|  | ||||
|     # Force recheck | ||||
|     res = client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     assert b'1 watches queued for rechecking.' in res.data | ||||
|     res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     assert b'Queued 1 watch for rechecking.' in res.data | ||||
|  | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) | ||||
|  | ||||
|     # Check the 'get latest snapshot works' | ||||
|     res = client.get(url_for("watch_get_latest_html", uuid=uuid)) | ||||
|     res = client.get(url_for("ui.ui_edit.watch_get_latest_html", uuid=uuid)) | ||||
|     assert b'which has this one new line' in res.data | ||||
|  | ||||
|     # Now something should be ready, indicated by having a 'unviewed' class | ||||
| @@ -80,7 +80,7 @@ def test_check_basic_change_detection_functionality(client, live_server, measure | ||||
|  | ||||
|     # #75, and it should be in the RSS feed | ||||
|     rss_token = extract_rss_token_from_UI(client) | ||||
|     res = client.get(url_for("rss", token=rss_token, _external=True)) | ||||
|     res = client.get(url_for("rss.feed", token=rss_token, _external=True)) | ||||
|     expected_url = url_for('test_endpoint', _external=True) | ||||
|     assert b'<rss' in res.data | ||||
|  | ||||
| @@ -91,12 +91,12 @@ def test_check_basic_change_detection_functionality(client, live_server, measure | ||||
|     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("diff_history_page", uuid=uuid)) | ||||
|     res = client.get(url_for("ui.ui_views.diff_history_page", uuid=uuid)) | ||||
|     assert b'selected=""' in res.data, "Confirm diff history page loaded" | ||||
|  | ||||
|     # Check the [preview] pulls the right one | ||||
|     res = client.get( | ||||
|         url_for("preview_page", uuid="first"), | ||||
|         url_for("ui.ui_views.preview_page", uuid="first"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b'which has this one new line' in res.data | ||||
| @@ -106,7 +106,7 @@ def test_check_basic_change_detection_functionality(client, live_server, measure | ||||
|  | ||||
|     # Do this a few times.. ensures we dont accidently set the status | ||||
|     for n in range(2): | ||||
|         client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|         client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|  | ||||
|         # Give the thread time to pick it up | ||||
|         wait_for_all_checks(client) | ||||
| @@ -122,13 +122,13 @@ def test_check_basic_change_detection_functionality(client, live_server, measure | ||||
|  | ||||
|     # Enable auto pickup of <title> in settings | ||||
|     res = client.post( | ||||
|         url_for("settings_page"), | ||||
|         url_for("settings.settings_page"), | ||||
|         data={"application-extract_title_as_title": "1", "requests-time_between_check-minutes": 180, | ||||
|               'application-fetch_backend': "html_requests"}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     res = client.get(url_for("index")) | ||||
| @@ -142,19 +142,19 @@ def test_check_basic_change_detection_functionality(client, live_server, measure | ||||
|     time.sleep(1) | ||||
|  | ||||
|     # hit the mark all viewed link | ||||
|     res = client.get(url_for("mark_all_viewed"), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.mark_all_viewed"), follow_redirects=True) | ||||
|  | ||||
|     assert b'Mark all viewed' not in res.data | ||||
|     assert b'unviewed' not in res.data | ||||
|  | ||||
|     # #2458 "clear history" should make the Watch object update its status correctly when the first snapshot lands again | ||||
|     client.get(url_for("clear_watch_history", uuid=uuid)) | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.clear_watch_history", uuid=uuid)) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'preview/' in res.data | ||||
|  | ||||
|     # | ||||
|     # Cleanup everything | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|   | ||||
| @@ -18,7 +18,7 @@ def test_backup(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     # Add our URL to the import page | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": url_for('test_endpoint', _external=True)+"?somechar=őőőőőőőő"}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|   | ||||
| @@ -71,7 +71,7 @@ def test_check_block_changedetection_text_NOT_present(client, live_server, measu | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -83,7 +83,7 @@ def test_check_block_changedetection_text_NOT_present(client, live_server, measu | ||||
|     # Goto the edit page, add our ignore text | ||||
|     # Add our URL to the import page | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         url_for("ui.ui_edit.edit_page", uuid="first"), | ||||
|         data={"text_should_not_be_present": ignore_text, | ||||
|               "url": test_url, | ||||
|               'fetch_backend': "html_requests" | ||||
| @@ -96,12 +96,12 @@ def test_check_block_changedetection_text_NOT_present(client, live_server, measu | ||||
|     wait_for_all_checks(client) | ||||
|     # Check it saved | ||||
|     res = client.get( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         url_for("ui.ui_edit.edit_page", uuid="first"), | ||||
|     ) | ||||
|     assert bytes(ignore_text.encode('utf-8')) in res.data | ||||
|  | ||||
|     # Trigger a check | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|  | ||||
|     # Give the thread time to pick it up | ||||
|     wait_for_all_checks(client) | ||||
| @@ -115,7 +115,7 @@ def test_check_block_changedetection_text_NOT_present(client, live_server, measu | ||||
|     set_modified_original_ignore_response() | ||||
|  | ||||
|     # Trigger a check | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     # Give the thread time to pick it up | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
| @@ -127,7 +127,7 @@ def test_check_block_changedetection_text_NOT_present(client, live_server, measu | ||||
|     # 2548 | ||||
|     # Going back to the ORIGINAL should NOT trigger a change | ||||
|     set_original_ignore_response() | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' not in res.data | ||||
| @@ -135,7 +135,7 @@ def test_check_block_changedetection_text_NOT_present(client, live_server, measu | ||||
|  | ||||
|     # Now we set a change where the text is gone AND its different content, it should now trigger | ||||
|     set_modified_response_minus_block_text() | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' in res.data | ||||
| @@ -143,5 +143,5 @@ def test_check_block_changedetection_text_NOT_present(client, live_server, measu | ||||
|  | ||||
|  | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|   | ||||
| @@ -15,7 +15,7 @@ def test_trigger_functionality(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     # Add our URL to the import page | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": "https://changedetection.io"}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -23,7 +23,7 @@ def test_trigger_functionality(client, live_server, measure_memory_usage): | ||||
|  | ||||
|  | ||||
|     res = client.get( | ||||
|         url_for("form_clone", uuid="first"), | ||||
|         url_for("ui.form_clone", uuid="first"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|   | ||||
							
								
								
									
										196
									
								
								changedetectionio/tests/test_conditions.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,196 @@ | ||||
| #!/usr/bin/env python3 | ||||
| import json | ||||
| import urllib | ||||
|  | ||||
| from flask import url_for | ||||
| from .util import live_server_setup, wait_for_all_checks | ||||
|  | ||||
| def set_original_response(number="50"): | ||||
|     test_return_data = f"""<html> | ||||
|        <body> | ||||
|      <h1>Test Page for Conditions</h1> | ||||
|      <p>This page contains a number that will be tested with conditions.</p> | ||||
|      <div class="number-container">Current value: {number}</div> | ||||
|      </body> | ||||
|      </html> | ||||
|     """ | ||||
|  | ||||
|     with open("test-datastore/endpoint-content.txt", "w") as f: | ||||
|         f.write(test_return_data) | ||||
|  | ||||
| def set_number_in_range_response(number="75"): | ||||
|     test_return_data = f"""<html> | ||||
|        <body> | ||||
|      <h1>Test Page for Conditions</h1> | ||||
|      <p>This page contains a number that will be tested with conditions.</p> | ||||
|      <div class="number-container">Current value: {number}</div> | ||||
|      </body> | ||||
|      </html> | ||||
|     """ | ||||
|  | ||||
|     with open("test-datastore/endpoint-content.txt", "w") as f: | ||||
|         f.write(test_return_data) | ||||
|  | ||||
| def set_number_out_of_range_response(number="150"): | ||||
|     test_return_data = f"""<html> | ||||
|        <body> | ||||
|      <h1>Test Page for Conditions</h1> | ||||
|      <p>This page contains a number that will be tested with conditions.</p> | ||||
|      <div class="number-container">Current value: {number}</div> | ||||
|      </body> | ||||
|      </html> | ||||
|     """ | ||||
|  | ||||
|     with open("test-datastore/endpoint-content.txt", "w") as f: | ||||
|         f.write(test_return_data) | ||||
|  | ||||
|  | ||||
| def test_conditions_with_text_and_number(client, live_server): | ||||
|     """Test that both text and number conditions work together with AND logic.""" | ||||
|      | ||||
|     set_original_response("50") | ||||
|     live_server_setup(live_server) | ||||
|  | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|  | ||||
|     # Add our URL to the import page | ||||
|     res = client.post( | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"1 Imported" in res.data | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     # Configure the watch with two conditions connected with AND: | ||||
|     # 1. The page filtered text must contain "5" (first digit of value) | ||||
|     # 2. The extracted number should be >= 20 and <= 100 | ||||
|     res = client.post( | ||||
|         url_for("ui.ui_edit.edit_page", uuid="first"), | ||||
|         data={ | ||||
|             "url": test_url, | ||||
|             "fetch_backend": "html_requests", | ||||
|             "include_filters": ".number-container", | ||||
|             "title": "Number AND Text Condition Test", | ||||
|             "conditions_match_logic": "ALL",  # ALL = AND logic | ||||
|             "conditions-0-operator": "in", | ||||
|             "conditions-0-field": "page_filtered_text", | ||||
|             "conditions-0-value": "5", | ||||
|  | ||||
|             "conditions-1-operator": ">=", | ||||
|             "conditions-1-field": "extracted_number", | ||||
|             "conditions-1-value": "20", | ||||
|  | ||||
|             "conditions-2-operator": "<=", | ||||
|             "conditions-2-field": "extracted_number", | ||||
|             "conditions-2-value": "100", | ||||
|  | ||||
|             # So that 'operations' from pluggy discovery are tested | ||||
|             "conditions-3-operator": "length_min", | ||||
|             "conditions-3-field": "page_filtered_text", | ||||
|             "conditions-3-value": "1", | ||||
|  | ||||
|             # So that 'operations' from pluggy discovery are tested | ||||
|             "conditions-4-operator": "length_max", | ||||
|             "conditions-4-field": "page_filtered_text", | ||||
|             "conditions-4-value": "100", | ||||
|  | ||||
|             # So that 'operations' from pluggy discovery are tested | ||||
|             "conditions-5-operator": "contains_regex", | ||||
|             "conditions-5-field": "page_filtered_text", | ||||
|             "conditions-5-value": "\d", | ||||
|         }, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"Updated watch." in res.data | ||||
|  | ||||
|     wait_for_all_checks(client) | ||||
|     client.get(url_for("ui.mark_all_viewed"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     # Case 1 | ||||
|     set_number_in_range_response("70.5") | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     # 75 is > 20 and < 100 and contains "5" | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' in res.data | ||||
|  | ||||
|  | ||||
|     # Case 2: Change with one condition violated | ||||
|     # Number out of range (150) but contains '5' | ||||
|     client.get(url_for("ui.mark_all_viewed"), follow_redirects=True) | ||||
|     set_number_out_of_range_response("150.5") | ||||
|  | ||||
|  | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     # Should NOT be marked as having changes since not all conditions are met | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' not in res.data | ||||
|  | ||||
|     res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
| # The 'validate' button next to each rule row | ||||
| def test_condition_validate_rule_row(client, live_server): | ||||
|  | ||||
|     set_original_response("50") | ||||
|  | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|  | ||||
|     # Add our URL to the import page | ||||
|     res = client.post( | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"1 Imported" in res.data | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) | ||||
|  | ||||
|     # the front end submits the current form state which should override the watch in a temporary copy | ||||
|     res = client.post( | ||||
|         url_for("conditions.verify_condition_single_rule", watch_uuid=uuid),  # Base URL | ||||
|         query_string={"rule": json.dumps({"field": "extracted_number", "operator": "==", "value": "50"})}, | ||||
|         data={'include_filter': ""}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert res.status_code == 200 | ||||
|     assert b'success' in res.data | ||||
|  | ||||
|     # Now a number that does not equal what is found in the last fetch | ||||
|     res = client.post( | ||||
|         url_for("conditions.verify_condition_single_rule", watch_uuid=uuid),  # Base URL | ||||
|         query_string={"rule": json.dumps({"field": "extracted_number", "operator": "==", "value": "111111"})}, | ||||
|         data={'include_filter': ""}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert res.status_code == 200 | ||||
|     assert b'false' in res.data | ||||
|  | ||||
|     # Now custom filter that exists | ||||
|     res = client.post( | ||||
|         url_for("conditions.verify_condition_single_rule", watch_uuid=uuid),  # Base URL | ||||
|         query_string={"rule": json.dumps({"field": "extracted_number", "operator": "==", "value": "50"})}, | ||||
|         data={'include_filter': ".number-container"}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert res.status_code == 200 | ||||
|     assert b'success' in res.data | ||||
|  | ||||
|     # Now custom filter that DOES NOT exists | ||||
|     res = client.post( | ||||
|         url_for("conditions.verify_condition_single_rule", watch_uuid=uuid),  # Base URL | ||||
|         query_string={"rule": json.dumps({"field": "extracted_number", "operator": "==", "value": "50"})}, | ||||
|         data={'include_filters': ".NOT-container"}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert res.status_code == 200 | ||||
|     assert b'false' in res.data | ||||
|  | ||||
|  | ||||
|  | ||||
| @@ -83,7 +83,7 @@ def test_check_markup_include_filters_restriction(client, live_server, measure_m | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -95,7 +95,7 @@ def test_check_markup_include_filters_restriction(client, live_server, measure_m | ||||
|     # Goto the edit page, add our ignore text | ||||
|     # Add our URL to the import page | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         url_for("ui.ui_edit.edit_page", uuid="first"), | ||||
|         data={"include_filters": include_filters, "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests"}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -103,7 +103,7 @@ def test_check_markup_include_filters_restriction(client, live_server, measure_m | ||||
|     time.sleep(1) | ||||
|     # Check it saved | ||||
|     res = client.get( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         url_for("ui.ui_edit.edit_page", uuid="first"), | ||||
|     ) | ||||
|     assert bytes(include_filters.encode('utf-8')) in res.data | ||||
|  | ||||
| @@ -113,7 +113,7 @@ def test_check_markup_include_filters_restriction(client, live_server, measure_m | ||||
|     set_modified_response() | ||||
|  | ||||
|     # Trigger a check | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     # Give the thread time to pick it up | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|  | ||||
| @@ -140,7 +140,7 @@ def test_check_multiple_filters(client, live_server, measure_memory_usage): | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -150,7 +150,7 @@ def test_check_multiple_filters(client, live_server, measure_memory_usage): | ||||
|     # Goto the edit page, add our ignore text | ||||
|     # Add our URL to the import page | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         url_for("ui.ui_edit.edit_page", uuid="first"), | ||||
|         data={"include_filters": include_filters, | ||||
|               "url": test_url, | ||||
|               "tags": "", | ||||
| @@ -164,7 +164,7 @@ def test_check_multiple_filters(client, live_server, measure_memory_usage): | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     res = client.get( | ||||
|         url_for("preview_page", uuid="first"), | ||||
|         url_for("ui.ui_views.preview_page", uuid="first"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
| @@ -194,7 +194,7 @@ def test_filter_is_empty_help_suggestion(client, live_server, measure_memory_usa | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -204,7 +204,7 @@ def test_filter_is_empty_help_suggestion(client, live_server, measure_memory_usa | ||||
|     # Goto the edit page, add our ignore text | ||||
|     # Add our URL to the import page | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         url_for("ui.ui_edit.edit_page", uuid="first"), | ||||
|         data={"include_filters": include_filters, | ||||
|               "url": test_url, | ||||
|               "tags": "", | ||||
| @@ -236,7 +236,7 @@ def test_filter_is_empty_help_suggestion(client, live_server, measure_memory_usa | ||||
|          </html> | ||||
|         """) | ||||
|  | ||||
|     res = client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     res = client.get( | ||||
|   | ||||
| @@ -156,7 +156,7 @@ def test_element_removal_full(client, live_server, measure_memory_usage): | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for("test_endpoint", _external=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), data={"urls": test_url}, follow_redirects=True | ||||
|         url_for("imports.import_page"), data={"urls": test_url}, follow_redirects=True | ||||
|     ) | ||||
|     assert b"1 Imported" in res.data | ||||
|     wait_for_all_checks(client) | ||||
| @@ -165,7 +165,7 @@ def test_element_removal_full(client, live_server, measure_memory_usage): | ||||
|     # Not sure why \r needs to be added - absent of the #changetext this is not necessary | ||||
|     subtractive_selectors_data = "header\r\nfooter\r\nnav\r\n#changetext" | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         url_for("ui.ui_edit.edit_page", uuid="first"), | ||||
|         data={ | ||||
|             "subtractive_selectors": subtractive_selectors_data, | ||||
|             "url": test_url, | ||||
| @@ -180,25 +180,25 @@ def test_element_removal_full(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     # Check it saved | ||||
|     res = client.get( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         url_for("ui.ui_edit.edit_page", uuid="first"), | ||||
|     ) | ||||
|     assert bytes(subtractive_selectors_data.encode("utf-8")) in res.data | ||||
|  | ||||
|     # Trigger a check | ||||
|     res = client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     assert b'1 watches queued for rechecking.' in res.data | ||||
|     res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     assert b'Queued 1 watch for rechecking.' in res.data | ||||
|  | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     # so that we set the state to 'unviewed' after all the edits | ||||
|     client.get(url_for("diff_history_page", uuid="first")) | ||||
|     client.get(url_for("ui.ui_views.diff_history_page", uuid="first")) | ||||
|  | ||||
|     #  Make a change to header/footer/nav | ||||
|     set_modified_response() | ||||
|  | ||||
|     # Trigger a check | ||||
|     res = client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     assert b'1 watches queued for rechecking.' in res.data | ||||
|     res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     assert b'Queued 1 watch for rechecking.' in res.data | ||||
|  | ||||
|     # Give the thread time to pick it up | ||||
|     wait_for_all_checks(client) | ||||
| @@ -228,19 +228,19 @@ body > table > tr:nth-child(3) > td:nth-child(3)""", | ||||
|  | ||||
|     for selector_list in subtractive_selectors_data: | ||||
|  | ||||
|         res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|         res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|         assert b'Deleted' in res.data | ||||
|  | ||||
|         # Add our URL to the import page | ||||
|         test_url = url_for("test_endpoint", _external=True) | ||||
|         res = client.post( | ||||
|             url_for("import_page"), data={"urls": test_url}, follow_redirects=True | ||||
|             url_for("imports.import_page"), data={"urls": test_url}, follow_redirects=True | ||||
|         ) | ||||
|         assert b"1 Imported" in res.data | ||||
|         wait_for_all_checks(client) | ||||
|  | ||||
|         res = client.post( | ||||
|             url_for("edit_page", uuid="first"), | ||||
|             url_for("ui.ui_edit.edit_page", uuid="first"), | ||||
|             data={ | ||||
|                 "subtractive_selectors": selector_list, | ||||
|                 "url": test_url, | ||||
| @@ -253,7 +253,7 @@ body > table > tr:nth-child(3) > td:nth-child(3)""", | ||||
|         wait_for_all_checks(client) | ||||
|  | ||||
|         res = client.get( | ||||
|             url_for("preview_page", uuid="first"), | ||||
|             url_for("ui.ui_views.preview_page", uuid="first"), | ||||
|             follow_redirects=True | ||||
|         ) | ||||
|  | ||||
|   | ||||
| @@ -30,7 +30,7 @@ def test_check_encoding_detection(client, live_server, measure_memory_usage): | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', content_type="text/html", _external=True) | ||||
|     client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -44,7 +44,7 @@ def test_check_encoding_detection(client, live_server, measure_memory_usage): | ||||
|     assert live_server.app.config['DATASTORE'].data['watching'][uuid]['content-type'] == "text/html" | ||||
|  | ||||
|     res = client.get( | ||||
|         url_for("preview_page", uuid="first"), | ||||
|         url_for("ui.ui_views.preview_page", uuid="first"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
| @@ -61,7 +61,7 @@ def test_check_encoding_detection_missing_content_type_header(client, live_serve | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -69,7 +69,7 @@ def test_check_encoding_detection_missing_content_type_header(client, live_serve | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     res = client.get( | ||||
|         url_for("preview_page", uuid="first"), | ||||
|         url_for("ui.ui_views.preview_page", uuid="first"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|   | ||||
| @@ -23,7 +23,7 @@ def _runner_test_http_errors(client, live_server, http_code, expected_text): | ||||
|                        _external=True) | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -40,7 +40,7 @@ def _runner_test_http_errors(client, live_server, http_code, expected_text): | ||||
|  | ||||
|     # Error viewing tabs should appear | ||||
|     res = client.get( | ||||
|         url_for("preview_page", uuid="first"), | ||||
|         url_for("ui.ui_views.preview_page", uuid="first"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
| @@ -50,7 +50,7 @@ def _runner_test_http_errors(client, live_server, http_code, expected_text): | ||||
|     #assert b'Error Screenshot' in res.data | ||||
|  | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
|  | ||||
| @@ -59,7 +59,7 @@ def test_http_error_handler(client, live_server, measure_memory_usage): | ||||
|     _runner_test_http_errors(client, live_server, 404, 'Page not found') | ||||
|     _runner_test_http_errors(client, live_server, 500, '(Internal server error) received') | ||||
|     _runner_test_http_errors(client, live_server, 400, 'Error - Request returned a HTTP error code 400') | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
| # Just to be sure error text is properly handled | ||||
| @@ -69,7 +69,7 @@ def test_DNS_errors(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     # Add our URL to the import page | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": "https://errorfuldomainthatnevereallyexists12356.com"}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -83,7 +83,7 @@ def test_DNS_errors(client, live_server, measure_memory_usage): | ||||
|     assert found_name_resolution_error | ||||
|     # Should always record that we tried | ||||
|     assert bytes("just now".encode('utf-8')) in res.data | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
| # Re 1513 | ||||
| @@ -99,7 +99,7 @@ def test_low_level_errors_clear_correctly(client, live_server, measure_memory_us | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": "https://dfkjasdkfjaidjfsdajfksdajfksdjfDOESNTEXIST.com"}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -113,7 +113,7 @@ def test_low_level_errors_clear_correctly(client, live_server, measure_memory_us | ||||
|  | ||||
|     # Update with what should work | ||||
|     client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         url_for("ui.ui_edit.edit_page", uuid="first"), | ||||
|         data={ | ||||
|             "url": test_url, | ||||
|             "fetch_backend": "html_requests"}, | ||||
| @@ -126,5 +126,5 @@ def test_low_level_errors_clear_correctly(client, live_server, measure_memory_us | ||||
|     found_name_resolution_error = b"Temporary failure in name resolution" in res.data or b"Name or service not known" in res.data | ||||
|     assert not found_name_resolution_error | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|   | ||||
| @@ -18,7 +18,7 @@ def test_check_extract_text_from_diff(client, live_server, measure_memory_usage) | ||||
|  | ||||
|     # Add our URL to the import page | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": url_for('test_endpoint', _external=True)}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -36,11 +36,11 @@ def test_check_extract_text_from_diff(client, live_server, measure_memory_usage) | ||||
|         with open("test-datastore/endpoint-content.txt", "w") as f: | ||||
|             f.write("Now it's {} seconds since epoch, time flies!".format(last_date)) | ||||
|  | ||||
|         client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|         client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|         wait_for_all_checks(client) | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("diff_history_page", uuid="first"), | ||||
|         url_for("ui.ui_views.diff_history_page", uuid="first"), | ||||
|         data={"extract_regex": "Now it's ([0-9\.]+)", | ||||
|               "extract_submit_button": "Extract as CSV"}, | ||||
|         follow_redirects=False | ||||
|   | ||||
| @@ -77,7 +77,7 @@ def test_check_filter_multiline(client, live_server, measure_memory_usage): | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -88,7 +88,7 @@ def test_check_filter_multiline(client, live_server, measure_memory_usage): | ||||
|     # Goto the edit page, add our ignore text | ||||
|     # Add our URL to the import page | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         url_for("ui.ui_edit.edit_page", uuid="first"), | ||||
|         data={"include_filters": '', | ||||
|               # Test a regex and a plaintext | ||||
|               'extract_text': '/something.+?6 billion.+?lines/si\r\nand this should be', | ||||
| @@ -109,7 +109,7 @@ def test_check_filter_multiline(client, live_server, measure_memory_usage): | ||||
|     assert b'not at the start of the expression' not in res.data | ||||
|  | ||||
|     res = client.get( | ||||
|         url_for("preview_page", uuid="first"), | ||||
|         url_for("ui.ui_views.preview_page", uuid="first"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     # Plaintext that doesnt look like a regex should match also | ||||
| @@ -131,7 +131,7 @@ def test_check_filter_and_regex_extract(client, live_server, measure_memory_usag | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -143,7 +143,7 @@ def test_check_filter_and_regex_extract(client, live_server, measure_memory_usag | ||||
|     # Goto the edit page, add our ignore text | ||||
|     # Add our URL to the import page | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         url_for("ui.ui_edit.edit_page", uuid="first"), | ||||
|         data={"include_filters": include_filters, | ||||
|               'extract_text': '/\d+ online/\r\n/\d+ guests/\r\n/somecase insensitive \d+/i\r\n/somecase insensitive (345\d)/i\r\n/issue1828.+?2022/i', | ||||
|               "url": test_url, | ||||
| @@ -168,7 +168,7 @@ def test_check_filter_and_regex_extract(client, live_server, measure_memory_usag | ||||
|     set_modified_response() | ||||
|  | ||||
|     # Trigger a check | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     # Give the thread time to pick it up | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
| @@ -179,7 +179,7 @@ def test_check_filter_and_regex_extract(client, live_server, measure_memory_usag | ||||
|  | ||||
|     # Check HTML conversion detected and workd | ||||
|     res = client.get( | ||||
|         url_for("preview_page", uuid="first"), | ||||
|         url_for("ui.ui_views.preview_page", uuid="first"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
| @@ -211,7 +211,7 @@ def test_regex_error_handling(client, live_server, measure_memory_usage): | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -219,7 +219,7 @@ def test_regex_error_handling(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     ### test regex error handling | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         url_for("ui.ui_edit.edit_page", uuid="first"), | ||||
|         data={"extract_text": '/something bad\d{3/XYZ', | ||||
|               "url": test_url, | ||||
|               "fetch_backend": "html_requests"}, | ||||
| @@ -228,5 +228,5 @@ def test_regex_error_handling(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     assert b'is not a valid regular expression.' in res.data | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|   | ||||
| @@ -55,7 +55,7 @@ def test_filter_doesnt_exist_then_exists_should_get_notification(client, live_se | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
|         url_for("form_quick_watch_add"), | ||||
|         url_for("ui.ui_views.form_quick_watch_add"), | ||||
|         data={"url": test_url, "tags": 'cinema'}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -97,7 +97,7 @@ def test_filter_doesnt_exist_then_exists_should_get_notification(client, live_se | ||||
|         "fetch_backend": "html_requests"}) | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         url_for("ui.ui_edit.edit_page", uuid="first"), | ||||
|         data=notification_form_data, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -108,7 +108,7 @@ def test_filter_doesnt_exist_then_exists_should_get_notification(client, live_se | ||||
|     assert not os.path.isfile("test-datastore/notification.txt") | ||||
|     # Now the filter should exist | ||||
|     set_response_with_filter() | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|  | ||||
|     wait_for_notification_endpoint_output() | ||||
|  | ||||
|   | ||||
| @@ -36,14 +36,14 @@ def run_filter_test(client, live_server, content_filter): | ||||
|  | ||||
|     # cleanup for the next | ||||
|     client.get( | ||||
|         url_for("form_delete", uuid="all"), | ||||
|         url_for("ui.form_delete", uuid="all"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     if os.path.isfile("test-datastore/notification.txt"): | ||||
|         os.unlink("test-datastore/notification.txt") | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -80,7 +80,7 @@ def run_filter_test(client, live_server, content_filter): | ||||
|                   } | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid=uuid), | ||||
|         url_for("ui.ui_edit.edit_page", uuid=uuid), | ||||
|         data=watch_data, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -91,7 +91,7 @@ def run_filter_test(client, live_server, content_filter): | ||||
|     # Now add a filter, because recheck hours == 5, ONLY pressing of the [edit] or [recheck all] should trigger | ||||
|     watch_data['include_filters'] = content_filter | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid=uuid), | ||||
|         url_for("ui.ui_edit.edit_page", uuid=uuid), | ||||
|         data=watch_data, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -111,7 +111,7 @@ def run_filter_test(client, live_server, content_filter): | ||||
|     ATTEMPT_THRESHOLD_SETTING = live_server.app.config['DATASTORE'].data['settings']['application'].get('filter_failure_notification_threshold_attempts', 0) | ||||
|     for i in range(0, ATTEMPT_THRESHOLD_SETTING - 2): | ||||
|         checked += 1 | ||||
|         client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|         client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|         wait_for_all_checks(client) | ||||
|         res = client.get(url_for("index")) | ||||
|         assert b'Warning, no filters were found' in res.data | ||||
| @@ -122,7 +122,7 @@ def run_filter_test(client, live_server, content_filter): | ||||
|  | ||||
|     time.sleep(2) | ||||
|     # One more check should trigger the _FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT threshold | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|     wait_for_notification_endpoint_output() | ||||
|  | ||||
| @@ -141,7 +141,7 @@ def run_filter_test(client, live_server, content_filter): | ||||
|  | ||||
|     # Try several times, it should NOT have 'filter not found' | ||||
|     for i in range(0, ATTEMPT_THRESHOLD_SETTING + 2): | ||||
|         client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|         client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|         wait_for_all_checks(client) | ||||
|  | ||||
|     wait_for_notification_endpoint_output() | ||||
| @@ -157,7 +157,7 @@ def run_filter_test(client, live_server, content_filter): | ||||
|  | ||||
|     # cleanup for the next | ||||
|     client.get( | ||||
|         url_for("form_delete", uuid="all"), | ||||
|         url_for("ui.form_delete", uuid="all"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     os.unlink("test-datastore/notification.txt") | ||||
|   | ||||
| @@ -71,7 +71,7 @@ def test_setup_group_tag(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": test_url + "?first-imported=1 test-tag, extra-import-tag"}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -94,14 +94,14 @@ def test_setup_group_tag(client, live_server, measure_memory_usage): | ||||
|     assert b'Warning, no filters were found' not in res.data | ||||
|  | ||||
|     res = client.get( | ||||
|         url_for("preview_page", uuid="first"), | ||||
|         url_for("ui.ui_views.preview_page", uuid="first"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b'Should be only this' in res.data | ||||
|     assert b'And never this' not in res.data | ||||
|  | ||||
|     res = client.get( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         url_for("ui.ui_edit.edit_page", uuid="first"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     # 2307 the UI notice should appear in the placeholder | ||||
| @@ -110,24 +110,24 @@ def test_setup_group_tag(client, live_server, measure_memory_usage): | ||||
|     # RSS Group tag filter | ||||
|     # An extra one that should be excluded | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": test_url + "?should-be-excluded=1 some-tag"}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"1 Imported" in res.data | ||||
|     wait_for_all_checks(client) | ||||
|     set_modified_response() | ||||
|     res = client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|     rss_token = extract_rss_token_from_UI(client) | ||||
|     res = client.get( | ||||
|         url_for("rss", token=rss_token, tag="extra-import-tag", _external=True), | ||||
|         url_for("rss.feed", token=rss_token, tag="extra-import-tag", _external=True), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"should-be-excluded" not in res.data | ||||
|     assert res.status_code == 200 | ||||
|     assert b"first-imported=1" in res.data | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
| def test_tag_import_singular(client, live_server, measure_memory_usage): | ||||
| @@ -135,7 +135,7 @@ def test_tag_import_singular(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": test_url + " test-tag, test-tag\r\n"+ test_url + "?x=1 test-tag, test-tag\r\n"}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -147,7 +147,7 @@ def test_tag_import_singular(client, live_server, measure_memory_usage): | ||||
|     ) | ||||
|     # Should be only 1 tag because they both had the same | ||||
|     assert res.data.count(b'test-tag') == 1 | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
| def test_tag_add_in_ui(client, live_server, measure_memory_usage): | ||||
| @@ -164,7 +164,7 @@ def test_tag_add_in_ui(client, live_server, measure_memory_usage): | ||||
|     res = client.get(url_for("tags.delete_all"), follow_redirects=True) | ||||
|     assert b'All tags deleted' in res.data | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
| def test_group_tag_notification(client, live_server, measure_memory_usage): | ||||
| @@ -173,7 +173,7 @@ def test_group_tag_notification(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
|         url_for("form_quick_watch_add"), | ||||
|         url_for("ui.ui_views.form_quick_watch_add"), | ||||
|         data={"url": test_url, "tags": 'test-tag, other-tag'}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -211,7 +211,7 @@ def test_group_tag_notification(client, live_server, measure_memory_usage): | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     set_modified_response() | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     time.sleep(3) | ||||
|  | ||||
|     assert os.path.isfile("test-datastore/notification.txt") | ||||
| @@ -232,7 +232,7 @@ def test_group_tag_notification(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     #@todo Test that multiple notifications fired | ||||
|     #@todo Test that each of multiple notifications with different settings | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
| def test_limit_tag_ui(client, live_server, measure_memory_usage): | ||||
| @@ -248,7 +248,7 @@ def test_limit_tag_ui(client, live_server, measure_memory_usage): | ||||
|         urls.append(test_url+"?non-grouped="+str(i)) | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": "\r\n".join(urls)}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -269,7 +269,7 @@ def test_limit_tag_ui(client, live_server, measure_memory_usage): | ||||
|     assert b'test-tag' in res.data | ||||
|     assert res.data.count(b'processor-text_json_diff') == 20 | ||||
|     assert b"object at" not in res.data | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|     res = client.get(url_for("tags.delete_all"), follow_redirects=True) | ||||
|     assert b'All tags deleted' in res.data | ||||
| @@ -277,7 +277,7 @@ def test_clone_tag_on_import(client, live_server, measure_memory_usage): | ||||
|     #live_server_setup(live_server) | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": test_url + " test-tag, another-tag\r\n"}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -289,13 +289,13 @@ def test_clone_tag_on_import(client, live_server, measure_memory_usage): | ||||
|     assert b'another-tag' in res.data | ||||
|  | ||||
|     watch_uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) | ||||
|     res = client.get(url_for("form_clone", uuid=watch_uuid), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_clone", uuid=watch_uuid), follow_redirects=True) | ||||
|  | ||||
|     assert b'Cloned' in res.data | ||||
|     # 2 times plus the top link to tag | ||||
|     assert res.data.count(b'test-tag') == 3 | ||||
|     assert res.data.count(b'another-tag') == 3 | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
| def test_clone_tag_on_quickwatchform_add(client, live_server, measure_memory_usage): | ||||
| @@ -304,7 +304,7 @@ def test_clone_tag_on_quickwatchform_add(client, live_server, measure_memory_usa | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("form_quick_watch_add"), | ||||
|         url_for("ui.ui_views.form_quick_watch_add"), | ||||
|         data={"url": test_url, "tags": ' test-tag, another-tag      '}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -316,13 +316,13 @@ def test_clone_tag_on_quickwatchform_add(client, live_server, measure_memory_usa | ||||
|     assert b'another-tag' in res.data | ||||
|  | ||||
|     watch_uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) | ||||
|     res = client.get(url_for("form_clone", uuid=watch_uuid), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_clone", uuid=watch_uuid), follow_redirects=True) | ||||
|  | ||||
|     assert b'Cloned' in res.data | ||||
|     # 2 times plus the top link to tag | ||||
|     assert res.data.count(b'test-tag') == 3 | ||||
|     assert res.data.count(b'another-tag') == 3 | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
|     res = client.get(url_for("tags.delete_all"), follow_redirects=True) | ||||
| @@ -387,7 +387,7 @@ def test_order_of_filters_tag_filter_and_watch_filter(client, live_server, measu | ||||
|  | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -414,7 +414,7 @@ def test_order_of_filters_tag_filter_and_watch_filter(client, live_server, measu | ||||
|             ] | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         url_for("ui.ui_edit.edit_page", uuid="first"), | ||||
|         data={"include_filters": '\n'.join(filters), | ||||
|             "url": test_url, | ||||
|             "tags": "test-tag-keep-order", | ||||
| @@ -426,7 +426,7 @@ def test_order_of_filters_tag_filter_and_watch_filter(client, live_server, measu | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     res = client.get( | ||||
|         url_for("preview_page", uuid="first"), | ||||
|         url_for("ui.ui_views.preview_page", uuid="first"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
| @@ -476,5 +476,5 @@ the {test} appeared before. {test in res.data[:n]=} | ||||
|         """ | ||||
|         n += t_index + len(test) | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|   | ||||
| @@ -16,7 +16,7 @@ def test_consistent_history(client, live_server, measure_memory_usage): | ||||
|     for one in r: | ||||
|         test_url = url_for('test_endpoint', content_type="text/html", content=str(one), _external=True) | ||||
|         res = client.post( | ||||
|             url_for("import_page"), | ||||
|             url_for("imports.import_page"), | ||||
|             data={"urls": test_url}, | ||||
|             follow_redirects=True | ||||
|         ) | ||||
| @@ -27,7 +27,7 @@ def test_consistent_history(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     # Essentially just triggers the DB write/update | ||||
|     res = client.post( | ||||
|         url_for("settings_page"), | ||||
|         url_for("settings.settings_page"), | ||||
|         data={"application-empty_pages_are_a_change": "", | ||||
|               "requests-time_between_check-minutes": 180, | ||||
|               'application-fetch_backend': "html_requests"}, | ||||
|   | ||||
| @@ -28,7 +28,7 @@ def test_ignore(client, live_server, measure_memory_usage): | ||||
|     set_original_ignore_response() | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -39,12 +39,12 @@ def test_ignore(client, live_server, measure_memory_usage): | ||||
|     uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) | ||||
|     # use the highlighter endpoint | ||||
|     res = client.post( | ||||
|         url_for("highlight_submit_ignore_url", uuid=uuid), | ||||
|         url_for("ui.ui_edit.highlight_submit_ignore_url", uuid=uuid), | ||||
|         data={"mode": 'digit-regex', 'selection': 'oh yeah 123'}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|     res = client.get(url_for("edit_page", uuid=uuid)) | ||||
|     res = client.get(url_for("ui.ui_edit.edit_page", uuid=uuid)) | ||||
|     # should be a regex now | ||||
|     assert b'/oh\ yeah\ \d+/' in res.data | ||||
|  | ||||
| @@ -52,7 +52,7 @@ def test_ignore(client, live_server, measure_memory_usage): | ||||
|     assert b'href' in res.data | ||||
|  | ||||
|     # It should not be in the preview anymore | ||||
|     res = client.get(url_for("preview_page", uuid=uuid)) | ||||
|     res = client.get(url_for("ui.ui_views.preview_page", uuid=uuid)) | ||||
|     assert b'<div class="ignored">oh yeah 456' not in res.data | ||||
|  | ||||
|     # Should be in base.html | ||||
|   | ||||
| @@ -96,7 +96,7 @@ def test_check_ignore_text_functionality(client, live_server, measure_memory_usa | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -108,7 +108,7 @@ def test_check_ignore_text_functionality(client, live_server, measure_memory_usa | ||||
|     # Goto the edit page, add our ignore text | ||||
|     # Add our URL to the import page | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         url_for("ui.ui_edit.edit_page", uuid="first"), | ||||
|         data={"ignore_text": ignore_text, "url": test_url, 'fetch_backend': "html_requests"}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -116,12 +116,12 @@ def test_check_ignore_text_functionality(client, live_server, measure_memory_usa | ||||
|  | ||||
|     # Check it saved | ||||
|     res = client.get( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         url_for("ui.ui_edit.edit_page", uuid="first"), | ||||
|     ) | ||||
|     assert bytes(ignore_text.encode('utf-8')) in res.data | ||||
|  | ||||
|     # Trigger a check | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|  | ||||
|     # Give the thread time to pick it up | ||||
|     wait_for_all_checks(client) | ||||
| @@ -135,7 +135,7 @@ def test_check_ignore_text_functionality(client, live_server, measure_memory_usa | ||||
|     set_modified_ignore_response() | ||||
|  | ||||
|     # Trigger a check | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     # Give the thread time to pick it up | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
| @@ -148,20 +148,20 @@ def test_check_ignore_text_functionality(client, live_server, measure_memory_usa | ||||
|  | ||||
|     # Just to be sure.. set a regular modified change.. | ||||
|     set_modified_original_ignore_response() | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' in res.data | ||||
|  | ||||
|     res = client.get(url_for("preview_page", uuid="first")) | ||||
|     res = client.get(url_for("ui.ui_views.preview_page", uuid="first")) | ||||
|  | ||||
|     # SHOULD BE be in the preview, it was added in set_modified_original_ignore_response() | ||||
|     # and we have "new ignore stuff" in ignore_text | ||||
|     # it is only ignored, it is not removed (it will be highlighted too) | ||||
|     assert b'new ignore stuff' in res.data | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
| # When adding some ignore text, it should not trigger a change, even if something else on that line changes | ||||
| @@ -172,7 +172,7 @@ def test_check_global_ignore_text_functionality(client, live_server, measure_mem | ||||
|  | ||||
|     # Goto the settings page, add our ignore text | ||||
|     res = client.post( | ||||
|         url_for("settings_page"), | ||||
|         url_for("settings.settings_page"), | ||||
|         data={ | ||||
|             "requests-time_between_check-minutes": 180, | ||||
|             "application-ignore_whitespace": "y", | ||||
| @@ -187,7 +187,7 @@ def test_check_global_ignore_text_functionality(client, live_server, measure_mem | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -198,7 +198,7 @@ def test_check_global_ignore_text_functionality(client, live_server, measure_mem | ||||
|  | ||||
|     #Adding some ignore text should not trigger a change | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         url_for("ui.ui_edit.edit_page", uuid="first"), | ||||
|         data={"ignore_text": "something irrelevent but just to check", "url": test_url, 'fetch_backend': "html_requests"}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -206,12 +206,12 @@ def test_check_global_ignore_text_functionality(client, live_server, measure_mem | ||||
|  | ||||
|     # Check it saved | ||||
|     res = client.get( | ||||
|         url_for("settings_page"), | ||||
|         url_for("settings.settings_page"), | ||||
|     ) | ||||
|     assert bytes(ignore_text.encode('utf-8')) in res.data | ||||
|  | ||||
|     # Trigger a check | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|     # It should report nothing found (no new 'unviewed' class), adding random ignore text should not cause a change | ||||
|     res = client.get(url_for("index")) | ||||
| @@ -224,7 +224,7 @@ def test_check_global_ignore_text_functionality(client, live_server, measure_mem | ||||
|     set_modified_ignore_response() | ||||
|  | ||||
|     # Trigger a check | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     # Give the thread time to pick it up | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
| @@ -236,10 +236,10 @@ def test_check_global_ignore_text_functionality(client, live_server, measure_mem | ||||
|  | ||||
|     # Just to be sure.. set a regular modified change that will trigger it | ||||
|     set_modified_original_ignore_response() | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' in res.data | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|   | ||||
| @@ -53,7 +53,7 @@ def test_render_anchor_tag_content_true(client, live_server, measure_memory_usag | ||||
|  | ||||
|     # Goto the settings page, choose to ignore links (dont select/send "application-render_anchor_tag_content") | ||||
|     res = client.post( | ||||
|         url_for("settings_page"), | ||||
|         url_for("settings.settings_page"), | ||||
|         data={ | ||||
|             "requests-time_between_check-minutes": 180, | ||||
|             "application-fetch_backend": "html_requests", | ||||
| @@ -65,32 +65,32 @@ def test_render_anchor_tag_content_true(client, live_server, measure_memory_usag | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for("test_endpoint", _external=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), data={"urls": test_url}, | ||||
|         url_for("imports.import_page"), data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"1 Imported" in res.data | ||||
|  | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|     # Trigger a check | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|  | ||||
|     # set a new html text with a modified link | ||||
|     set_modified_ignore_response() | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|  | ||||
|     # Trigger a check | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|  | ||||
|     # Give the thread time to pick it up | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|  | ||||
|     # We should not see the rendered anchor tag | ||||
|     res = client.get(url_for("preview_page", uuid="first")) | ||||
|     res = client.get(url_for("ui.ui_views.preview_page", uuid="first")) | ||||
|     assert '(/modified_link)' not in res.data.decode() | ||||
|  | ||||
|     # Goto the settings page, ENABLE render anchor tag | ||||
|     res = client.post( | ||||
|         url_for("settings_page"), | ||||
|         url_for("settings.settings_page"), | ||||
|         data={ | ||||
|             "requests-time_between_check-minutes": 180, | ||||
|             "application-render_anchor_tag_content": "true", | ||||
| @@ -101,7 +101,7 @@ def test_render_anchor_tag_content_true(client, live_server, measure_memory_usag | ||||
|     assert b"Settings updated." in res.data | ||||
|  | ||||
|     # Trigger a check | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|  | ||||
|     # Give the thread time to pick it up | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
| @@ -109,7 +109,7 @@ def test_render_anchor_tag_content_true(client, live_server, measure_memory_usag | ||||
|  | ||||
|  | ||||
|     # check that the anchor tag content is rendered | ||||
|     res = client.get(url_for("preview_page", uuid="first")) | ||||
|     res = client.get(url_for("ui.ui_views.preview_page", uuid="first")) | ||||
|     assert '(/modified_link)' in res.data.decode() | ||||
|  | ||||
|     # since the link has changed, and we chose to render anchor tag content, | ||||
| @@ -119,7 +119,7 @@ def test_render_anchor_tag_content_true(client, live_server, measure_memory_usag | ||||
|     assert b"/test-endpoint" in res.data | ||||
|  | ||||
|     # Cleanup everything | ||||
|     res = client.get(url_for("form_delete", uuid="all"), | ||||
|     res = client.get(url_for("ui.form_delete", uuid="all"), | ||||
|                      follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
|   | ||||
| @@ -49,7 +49,7 @@ def test_normal_page_check_works_with_ignore_status_code(client, live_server, me | ||||
|  | ||||
|     # Goto the settings page, add our ignore text | ||||
|     res = client.post( | ||||
|         url_for("settings_page"), | ||||
|         url_for("settings.settings_page"), | ||||
|         data={ | ||||
|             "requests-time_between_check-minutes": 180, | ||||
|             "application-ignore_status_codes": "y", | ||||
| @@ -62,7 +62,7 @@ def test_normal_page_check_works_with_ignore_status_code(client, live_server, me | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -73,7 +73,7 @@ def test_normal_page_check_works_with_ignore_status_code(client, live_server, me | ||||
|     set_some_changed_response() | ||||
|     wait_for_all_checks(client) | ||||
|     # Trigger a check | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|  | ||||
|     # Give the thread time to pick it up | ||||
|     wait_for_all_checks(client) | ||||
| @@ -96,7 +96,7 @@ def test_403_page_check_works_with_ignore_status_code(client, live_server, measu | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', status_code=403, _external=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -108,7 +108,7 @@ def test_403_page_check_works_with_ignore_status_code(client, live_server, measu | ||||
|     # Goto the edit page, check our ignore option | ||||
|     # Add our URL to the import page | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         url_for("ui.ui_edit.edit_page", uuid="first"), | ||||
|         data={"ignore_status_codes": "y", "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests"}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -121,7 +121,7 @@ def test_403_page_check_works_with_ignore_status_code(client, live_server, measu | ||||
|     set_some_changed_response() | ||||
|  | ||||
|     # Trigger a check | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     # Give the thread time to pick it up | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|   | ||||
| @@ -59,7 +59,7 @@ def test_check_ignore_whitespace(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     # Goto the settings page, add our ignore text | ||||
|     res = client.post( | ||||
|         url_for("settings_page"), | ||||
|         url_for("settings.settings_page"), | ||||
|         data={ | ||||
|             "requests-time_between_check-minutes": 180, | ||||
|             "application-ignore_whitespace": "y", | ||||
| @@ -72,7 +72,7 @@ def test_check_ignore_whitespace(client, live_server, measure_memory_usage): | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -80,12 +80,12 @@ def test_check_ignore_whitespace(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|     # Trigger a check | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|  | ||||
|     set_original_ignore_response_but_with_whitespace() | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|     # Trigger a check | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|  | ||||
|     # Give the thread time to pick it up | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|   | ||||
| @@ -16,7 +16,7 @@ def test_import(client, live_server, measure_memory_usage): | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={ | ||||
|             "distill-io": "", | ||||
|             "urls": """https://example.com | ||||
| @@ -28,7 +28,7 @@ https://example.com tag1, other tag""" | ||||
|     assert b"3 Imported" in res.data | ||||
|     assert b"tag1" in res.data | ||||
|     assert b"other tag" in res.data | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|  | ||||
|     # Clear flask alerts | ||||
|     res = client.get( url_for("index")) | ||||
| @@ -41,7 +41,7 @@ def xtest_import_skip_url(client, live_server, measure_memory_usage): | ||||
|     time.sleep(1) | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={ | ||||
|             "distill-io": "", | ||||
|             "urls": """https://example.com | ||||
| @@ -53,7 +53,7 @@ def xtest_import_skip_url(client, live_server, measure_memory_usage): | ||||
|     assert b"1 Imported" in res.data | ||||
|     assert b"ht000000broken" in res.data | ||||
|     assert b"1 Skipped" in res.data | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|     # Clear flask alerts | ||||
|     res = client.get( url_for("index")) | ||||
|  | ||||
| @@ -82,9 +82,9 @@ def test_import_distillio(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     # Give the endpoint time to spin up | ||||
|     time.sleep(1) | ||||
|     client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={ | ||||
|             "distill-io": distill_data, | ||||
|             "urls" : '' | ||||
| @@ -96,7 +96,7 @@ def test_import_distillio(client, live_server, measure_memory_usage): | ||||
|     assert b"Unable to read JSON file, was it broken?" not in res.data | ||||
|     assert b"1 Imported from Distill.io" in res.data | ||||
|  | ||||
|     res = client.get( url_for("edit_page", uuid="first")) | ||||
|     res = client.get( url_for("ui.ui_edit.edit_page", uuid="first")) | ||||
|  | ||||
|     assert b"https://unraid.net/blog" in res.data | ||||
|     assert b"Unraid | News" in res.data | ||||
| @@ -119,7 +119,7 @@ def test_import_distillio(client, live_server, measure_memory_usage): | ||||
|     assert b"nice stuff" in res.data | ||||
|     assert b"nerd-news" in res.data | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|     # Clear flask alerts | ||||
|     res = client.get(url_for("index")) | ||||
|  | ||||
| @@ -146,7 +146,7 @@ def test_import_custom_xlsx(client, live_server, measure_memory_usage): | ||||
|         } | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data=data, | ||||
|         follow_redirects=True, | ||||
|     ) | ||||
| @@ -169,7 +169,7 @@ def test_import_custom_xlsx(client, live_server, measure_memory_usage): | ||||
|             assert filters[0] == '/html[1]/body[1]/div[4]/div[1]/div[1]/div[1]||//*[@id=\'content\']/div[3]/div[1]/div[1]||//*[@id=\'content\']/div[1]' | ||||
|             assert watch.get('time_between_check') == {'weeks': 0, 'days': 1, 'hours': 6, 'minutes': 24, 'seconds': 0} | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
| def test_import_watchete_xlsx(client, live_server, measure_memory_usage): | ||||
| @@ -186,7 +186,7 @@ def test_import_watchete_xlsx(client, live_server, measure_memory_usage): | ||||
|         } | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data=data, | ||||
|         follow_redirects=True, | ||||
|     ) | ||||
| @@ -214,5 +214,5 @@ def test_import_watchete_xlsx(client, live_server, measure_memory_usage): | ||||
|         if watch.get('title') == 'system default website': | ||||
|             assert watch.get('fetch_backend') == 'system' # uses default if blank | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|   | ||||
| @@ -19,7 +19,7 @@ def test_jinja2_in_url_query(client, live_server, measure_memory_usage): | ||||
|     full_url = "{}?{}".format(test_url, | ||||
|                               "date={% now 'Europe/Berlin', '%Y' %}.{% now 'Europe/Berlin', '%m' %}.{% now 'Europe/Berlin', '%d' %}", ) | ||||
|     res = client.post( | ||||
|         url_for("form_quick_watch_add"), | ||||
|         url_for("ui.ui_views.form_quick_watch_add"), | ||||
|         data={"url": full_url, "tags": "test"}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -28,7 +28,7 @@ def test_jinja2_in_url_query(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     # It should report nothing found (no new 'unviewed' class) | ||||
|     res = client.get( | ||||
|         url_for("preview_page", uuid="first"), | ||||
|         url_for("ui.ui_views.preview_page", uuid="first"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b'date=2' in res.data | ||||
| @@ -44,7 +44,7 @@ def test_jinja2_security_url_query(client, live_server, measure_memory_usage): | ||||
|     full_url = "{}?{}".format(test_url, | ||||
|                               "date={{ ''.__class__.__mro__[1].__subclasses__()}}", ) | ||||
|     res = client.post( | ||||
|         url_for("form_quick_watch_add"), | ||||
|         url_for("ui.ui_views.form_quick_watch_add"), | ||||
|         data={"url": full_url, "tags": "test"}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|   | ||||
| @@ -212,7 +212,7 @@ def test_check_json_without_filter(client, live_server, measure_memory_usage): | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', content_type="application/json", _external=True) | ||||
|     client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -221,7 +221,7 @@ def test_check_json_without_filter(client, live_server, measure_memory_usage): | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     res = client.get( | ||||
|         url_for("preview_page", uuid="first"), | ||||
|         url_for("ui.ui_views.preview_page", uuid="first"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
| @@ -229,7 +229,7 @@ def test_check_json_without_filter(client, live_server, measure_memory_usage): | ||||
|     assert b'"html": "<b>"' in res.data | ||||
|     assert res.data.count(b'{') >= 2 | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
| def check_json_filter(json_filter, client, live_server): | ||||
| @@ -241,7 +241,7 @@ def check_json_filter(json_filter, client, live_server): | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', content_type="application/json", _external=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -253,7 +253,7 @@ def check_json_filter(json_filter, client, live_server): | ||||
|     # Goto the edit page, add our ignore text | ||||
|     # Add our URL to the import page | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         url_for("ui.ui_edit.edit_page", uuid="first"), | ||||
|         data={"include_filters": json_filter, | ||||
|               "url": test_url, | ||||
|               "tags": "", | ||||
| @@ -266,7 +266,7 @@ def check_json_filter(json_filter, client, live_server): | ||||
|  | ||||
|     # Check it saved | ||||
|     res = client.get( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         url_for("ui.ui_edit.edit_page", uuid="first"), | ||||
|     ) | ||||
|     assert bytes(escape(json_filter).encode('utf-8')) in res.data | ||||
|  | ||||
| @@ -276,7 +276,7 @@ def check_json_filter(json_filter, client, live_server): | ||||
|     set_modified_response() | ||||
|  | ||||
|     # Trigger a check | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     # Give the thread time to pick it up | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
| @@ -285,13 +285,13 @@ def check_json_filter(json_filter, client, live_server): | ||||
|     assert b'unviewed' in res.data | ||||
|  | ||||
|     # Should not see this, because its not in the JSONPath we entered | ||||
|     res = client.get(url_for("diff_history_page", uuid="first")) | ||||
|     res = client.get(url_for("ui.ui_views.diff_history_page", uuid="first")) | ||||
|  | ||||
|     # But the change should be there, tho its hard to test the change was detected because it will show old and new versions | ||||
|     # And #462 - check we see the proper utf-8 string there | ||||
|     assert "Örnsköldsvik".encode('utf-8') in res.data | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
| def test_check_jsonpath_filter(client, live_server, measure_memory_usage): | ||||
| @@ -314,7 +314,7 @@ def check_json_filter_bool_val(json_filter, client, live_server): | ||||
|     test_url = url_for('test_endpoint', content_type="application/json", _external=True) | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -324,7 +324,7 @@ def check_json_filter_bool_val(json_filter, client, live_server): | ||||
|     # Goto the edit page, add our ignore text | ||||
|     # Add our URL to the import page | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         url_for("ui.ui_edit.edit_page", uuid="first"), | ||||
|         data={"include_filters": json_filter, | ||||
|               "url": test_url, | ||||
|               "tags": "", | ||||
| @@ -341,15 +341,15 @@ def check_json_filter_bool_val(json_filter, client, live_server): | ||||
|     set_modified_response() | ||||
|  | ||||
|     # Trigger a check | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     # Give the thread time to pick it up | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     res = client.get(url_for("diff_history_page", uuid="first")) | ||||
|     res = client.get(url_for("ui.ui_views.diff_history_page", uuid="first")) | ||||
|     # But the change should be there, tho its hard to test the change was detected because it will show old and new versions | ||||
|     assert b'false' in res.data | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
| def test_check_jsonpath_filter_bool_val(client, live_server, measure_memory_usage): | ||||
| @@ -377,7 +377,7 @@ def check_json_ext_filter(json_filter, client, live_server): | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', content_type="application/json", _external=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -389,7 +389,7 @@ def check_json_ext_filter(json_filter, client, live_server): | ||||
|     # Goto the edit page, add our ignore text | ||||
|     # Add our URL to the import page | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         url_for("ui.ui_edit.edit_page", uuid="first"), | ||||
|         data={"include_filters": json_filter, | ||||
|               "url": test_url, | ||||
|               "tags": "", | ||||
| @@ -402,7 +402,7 @@ def check_json_ext_filter(json_filter, client, live_server): | ||||
|  | ||||
|     # Check it saved | ||||
|     res = client.get( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         url_for("ui.ui_edit.edit_page", uuid="first"), | ||||
|     ) | ||||
|     assert bytes(escape(json_filter).encode('utf-8')) in res.data | ||||
|  | ||||
| @@ -412,7 +412,7 @@ def check_json_ext_filter(json_filter, client, live_server): | ||||
|     set_modified_ext_response() | ||||
|  | ||||
|     # Trigger a check | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     # Give the thread time to pick it up | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
| @@ -420,14 +420,14 @@ def check_json_ext_filter(json_filter, client, live_server): | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' in res.data | ||||
|  | ||||
|     res = client.get(url_for("diff_history_page", uuid="first")) | ||||
|     res = client.get(url_for("ui.ui_views.diff_history_page", uuid="first")) | ||||
|  | ||||
|     # We should never see 'ForSale' because we are selecting on 'Sold' in the rule, | ||||
|     # But we should know it triggered ('unviewed' assert above) | ||||
|     assert b'ForSale' not in res.data | ||||
|     assert b'Sold' in res.data | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
| def test_ignore_json_order(client, live_server, measure_memory_usage): | ||||
| @@ -440,7 +440,7 @@ def test_ignore_json_order(client, live_server, measure_memory_usage): | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', content_type="application/json", _external=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -452,7 +452,7 @@ def test_ignore_json_order(client, live_server, measure_memory_usage): | ||||
|         f.write('{"world" : 123, "hello": 123}') | ||||
|  | ||||
|     # Trigger a check | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     res = client.get(url_for("index")) | ||||
| @@ -463,13 +463,13 @@ def test_ignore_json_order(client, live_server, measure_memory_usage): | ||||
|         f.write('{"world" : 123, "hello": 124}') | ||||
|  | ||||
|     # Trigger a check | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' in res.data | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
| def test_correct_header_detect(client, live_server, measure_memory_usage): | ||||
| @@ -482,7 +482,7 @@ def test_correct_header_detect(client, live_server, measure_memory_usage): | ||||
|     # Check weird casing is cleaned up and detected also | ||||
|     test_url = url_for('test_endpoint', content_type="aPPlication/JSon", uppercase_headers=True, _external=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         url_for("imports.import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
| @@ -494,14 +494,14 @@ def test_correct_header_detect(client, live_server, measure_memory_usage): | ||||
|     assert b'No parsable JSON found in this document' not in res.data | ||||
|  | ||||
|     res = client.get( | ||||
|         url_for("preview_page", uuid="first"), | ||||
|         url_for("ui.ui_views.preview_page", uuid="first"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|     assert b'"hello": 123,' in res.data | ||||
|     assert b'"world": 123' in res.data | ||||
|  | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
| def test_check_jsonpath_ext_filter(client, live_server, measure_memory_usage): | ||||
|   | ||||