mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2025-12-20 06:55:59 +00:00
WIP
This commit is contained in:
@@ -96,6 +96,25 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
|||||||
form.datastore = datastore
|
form.datastore = datastore
|
||||||
form.watch = default
|
form.watch = default
|
||||||
|
|
||||||
|
# Load processor-specific config from JSON file for GET requests
|
||||||
|
if request.method == 'GET' and processor_name:
|
||||||
|
try:
|
||||||
|
# Create a processor instance to access config methods
|
||||||
|
processor_instance = processors.difference_detection_processor(datastore, uuid)
|
||||||
|
# Use processor name as filename so each processor keeps its own config
|
||||||
|
config_filename = f'{processor_name}.json'
|
||||||
|
processor_config = processor_instance.get_extra_watch_config(config_filename)
|
||||||
|
|
||||||
|
if processor_config:
|
||||||
|
# Populate processor-config-* fields from JSON
|
||||||
|
for config_key, config_value in processor_config.items():
|
||||||
|
field_name = f'processor_config_{config_key}'
|
||||||
|
if hasattr(form, field_name):
|
||||||
|
getattr(form, field_name).data = config_value
|
||||||
|
logger.debug(f"Loaded processor config from {config_filename}: {field_name} = {config_value}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to load processor config: {e}")
|
||||||
|
|
||||||
for p in datastore.extra_browsers:
|
for p in datastore.extra_browsers:
|
||||||
form.fetch_backend.choices.append(p)
|
form.fetch_backend.choices.append(p)
|
||||||
|
|
||||||
@@ -129,7 +148,34 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
|||||||
|
|
||||||
extra_update_obj['time_between_check'] = form.time_between_check.data
|
extra_update_obj['time_between_check'] = form.time_between_check.data
|
||||||
|
|
||||||
# Ignore text
|
# Handle processor-config-* fields separately (save to JSON, not datastore)
|
||||||
|
processor_config_data = {}
|
||||||
|
fields_to_remove = []
|
||||||
|
for field_name, field_value in form.data.items():
|
||||||
|
if field_name.startswith('processor_config_'):
|
||||||
|
config_key = field_name.replace('processor_config_', '')
|
||||||
|
if field_value: # Only save non-empty values
|
||||||
|
processor_config_data[config_key] = field_value
|
||||||
|
fields_to_remove.append(field_name)
|
||||||
|
|
||||||
|
# Save processor config to JSON file if any config data exists
|
||||||
|
if processor_config_data:
|
||||||
|
try:
|
||||||
|
processor_name = form.data.get('processor')
|
||||||
|
# Create a processor instance to access config methods
|
||||||
|
processor_instance = processors.difference_detection_processor(datastore, uuid)
|
||||||
|
# Use processor name as filename so each processor keeps its own config
|
||||||
|
config_filename = f'{processor_name}.json'
|
||||||
|
processor_instance.update_extra_watch_config(config_filename, processor_config_data)
|
||||||
|
logger.debug(f"Saved processor config to {config_filename}: {processor_config_data}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to save processor config: {e}")
|
||||||
|
|
||||||
|
# Remove processor-config-* fields from form.data before updating datastore
|
||||||
|
for field_name in fields_to_remove:
|
||||||
|
form.data.pop(field_name, None)
|
||||||
|
|
||||||
|
# Ignore text
|
||||||
form_ignore_text = form.ignore_text.data
|
form_ignore_text = form.ignore_text.data
|
||||||
datastore.data['watching'][uuid]['ignore_text'] = form_ignore_text
|
datastore.data['watching'][uuid]['ignore_text'] = form_ignore_text
|
||||||
|
|
||||||
|
|||||||
@@ -387,6 +387,22 @@ Math: {{ 1 + 1 }}") }}
|
|||||||
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.
|
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>
|
</span>
|
||||||
|
|
||||||
|
{% if watch['processor'] == 'image_ssim_diff' %}
|
||||||
|
<div id="selection-mode-controls" style="margin: 10px 0; padding: 10px; background: var(--color-background-tab); border-radius: 5px;">
|
||||||
|
<label style="font-weight: 600; margin-right: 15px;">Selection Mode:</label>
|
||||||
|
<label style="margin-right: 15px;">
|
||||||
|
<input type="radio" name="selector-mode" value="element" style="margin-right: 5px;">
|
||||||
|
Select by element
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="selector-mode" value="draw" checked style="margin-right: 5px;">
|
||||||
|
Draw area
|
||||||
|
</label>
|
||||||
|
{{ render_field(form.processor_config_bounding_box) }}
|
||||||
|
{{ render_field(form.processor_config_selection_mode) }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div id="selector-header">
|
<div id="selector-header">
|
||||||
<a id="clear-selector" class="pure-button button-secondary button-xsmall" style="font-size: 70%">Clear selection</a>
|
<a id="clear-selector" class="pure-button button-secondary button-xsmall" style="font-size: 70%">Clear selection</a>
|
||||||
<!-- visual selector IMG will try to load, it will either replace this or on error replace it with some handy text -->
|
<!-- visual selector IMG will try to load, it will either replace this or on error replace it with some handy text -->
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from flask_login import current_user
|
|||||||
from flask_paginate import Pagination, get_page_parameter
|
from flask_paginate import Pagination, get_page_parameter
|
||||||
|
|
||||||
from changedetectionio import forms
|
from changedetectionio import forms
|
||||||
|
from changedetectionio import processors
|
||||||
from changedetectionio.store import ChangeDetectionStore
|
from changedetectionio.store import ChangeDetectionStore
|
||||||
from changedetectionio.auth_decorator import login_optionally_required
|
from changedetectionio.auth_decorator import login_optionally_required
|
||||||
|
|
||||||
@@ -90,6 +91,8 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
|||||||
hosted_sticky=os.getenv("SALTED_PASS", False) == False,
|
hosted_sticky=os.getenv("SALTED_PASS", False) == False,
|
||||||
now_time_server=round(time.time()),
|
now_time_server=round(time.time()),
|
||||||
pagination=pagination,
|
pagination=pagination,
|
||||||
|
processor_badge_texts=processors.get_processor_badge_texts(),
|
||||||
|
processor_descriptions=processors.get_processor_descriptions(),
|
||||||
queued_uuids=[q_uuid.item['uuid'] for q_uuid in update_q.queue],
|
queued_uuids=[q_uuid.item['uuid'] for q_uuid in update_q.queue],
|
||||||
search_q=request.args.get('q', '').strip(),
|
search_q=request.args.get('q', '').strip(),
|
||||||
sort_attribute=request.args.get('sort') if request.args.get('sort') else request.cookies.get('sort'),
|
sort_attribute=request.args.get('sort') if request.args.get('sort') else request.cookies.get('sort'),
|
||||||
|
|||||||
@@ -173,8 +173,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
<div class="ldjson-price-track-offer">Switch to Restock & Price watch mode? <a href="{{url_for('price_data_follower.accept', uuid=watch.uuid)}}" class="pure-button button-xsmall">Yes</a> <a href="{{url_for('price_data_follower.reject', uuid=watch.uuid)}}" class="">No</a></div>
|
<div class="ldjson-price-track-offer">Switch to Restock & Price watch mode? <a href="{{url_for('price_data_follower.accept', uuid=watch.uuid)}}" class="pure-button button-xsmall">Yes</a> <a href="{{url_for('price_data_follower.reject', uuid=watch.uuid)}}" class="">No</a></div>
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
{%- if watch['processor'] == 'restock_diff' -%}
|
{%- if watch['processor'] and watch['processor'] in processor_badge_texts -%}
|
||||||
<span class="tracking-ldjson-price-data" title="Automatically following embedded price information"><img src="{{url_for('static_content', group='images', filename='price-tag-icon.svg')}}" class="status-icon price-follow-tag-icon" > Price</span>
|
<span class="processor-badge processor-badge-{{ watch['processor'] }}" title="{{ processor_descriptions.get(watch['processor'], watch['processor']) }}">{{ processor_badge_texts[watch['processor']] }}</span>
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
{%- for watch_tag_uuid, watch_tag in datastore.get_all_tags_for_watch(watch['uuid']).items() -%}
|
{%- for watch_tag_uuid, watch_tag in datastore.get_all_tags_for_watch(watch['uuid']).items() -%}
|
||||||
<span class="watch-tag-list">{{ watch_tag.title }}</span>
|
<span class="watch-tag-list">{{ watch_tag.title }}</span>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from abc import abstractmethod
|
|||||||
from changedetectionio.content_fetchers.base import Fetcher
|
from changedetectionio.content_fetchers.base import Fetcher
|
||||||
from changedetectionio.strtobool import strtobool
|
from changedetectionio.strtobool import strtobool
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
|
from functools import lru_cache
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
import hashlib
|
import hashlib
|
||||||
import importlib
|
import importlib
|
||||||
@@ -205,13 +206,14 @@ class difference_detection_processor():
|
|||||||
logger.warning(f"Failed to read extra watch config {filename}: {e}")
|
logger.warning(f"Failed to read extra watch config {filename}: {e}")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def update_extra_watch_config(self, filename, data):
|
def update_extra_watch_config(self, filename, data, merge=True):
|
||||||
"""
|
"""
|
||||||
Write processor-specific JSON config file to watch data directory.
|
Write processor-specific JSON config file to watch data directory.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
filename: Name of JSON file (e.g., "visual_ssim_score.json")
|
filename: Name of JSON file (e.g., "visual_ssim_score.json")
|
||||||
data: Dictionary to serialize as JSON
|
data: Dictionary to serialize as JSON
|
||||||
|
merge: If True, merge with existing data; if False, overwrite completely
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -229,8 +231,25 @@ class difference_detection_processor():
|
|||||||
filepath = os.path.join(watch_data_dir, filename)
|
filepath = os.path.join(watch_data_dir, filename)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# If merge is enabled, read existing data first
|
||||||
|
existing_data = {}
|
||||||
|
if merge and os.path.isfile(filepath):
|
||||||
|
try:
|
||||||
|
with open(filepath, 'r', encoding='utf-8') as f:
|
||||||
|
existing_data = json.load(f)
|
||||||
|
except (json.JSONDecodeError, IOError) as e:
|
||||||
|
logger.warning(f"Failed to read existing config for merge: {e}")
|
||||||
|
|
||||||
|
# Merge new data with existing
|
||||||
|
if merge:
|
||||||
|
existing_data.update(data)
|
||||||
|
data_to_save = existing_data
|
||||||
|
else:
|
||||||
|
data_to_save = data
|
||||||
|
|
||||||
|
# Write the data
|
||||||
with open(filepath, 'w', encoding='utf-8') as f:
|
with open(filepath, 'w', encoding='utf-8') as f:
|
||||||
json.dump(data, f, indent=2)
|
json.dump(data_to_save, f, indent=2)
|
||||||
except IOError as e:
|
except IOError as e:
|
||||||
logger.error(f"Failed to write extra watch config {filename}: {e}")
|
logger.error(f"Failed to write extra watch config {filename}: {e}")
|
||||||
|
|
||||||
@@ -365,3 +384,58 @@ def available_processors():
|
|||||||
# Return as tuples without weight (for backwards compatibility)
|
# Return as tuples without weight (for backwards compatibility)
|
||||||
return [(name, desc) for name, desc, weight in available]
|
return [(name, desc) for name, desc, weight in available]
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def get_processor_badge_texts():
|
||||||
|
"""
|
||||||
|
Get a dictionary mapping processor names to their list_badge_text values.
|
||||||
|
Cached to avoid repeated lookups.
|
||||||
|
|
||||||
|
:return: A dict mapping processor name to badge text (e.g., {'text_json_diff': 'Text', 'restock_diff': 'Restock'})
|
||||||
|
"""
|
||||||
|
processor_classes = find_processors()
|
||||||
|
badge_texts = {}
|
||||||
|
|
||||||
|
for module, sub_package_name in processor_classes:
|
||||||
|
# Try to get the 'list_badge_text' attribute from the processor module
|
||||||
|
if hasattr(module, 'list_badge_text'):
|
||||||
|
badge_texts[sub_package_name] = module.list_badge_text
|
||||||
|
else:
|
||||||
|
# Fall back to parent module's __init__.py
|
||||||
|
parent_module = get_parent_module(module)
|
||||||
|
if parent_module and hasattr(parent_module, 'list_badge_text'):
|
||||||
|
badge_texts[sub_package_name] = parent_module.list_badge_text
|
||||||
|
|
||||||
|
return badge_texts
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def get_processor_descriptions():
|
||||||
|
"""
|
||||||
|
Get a dictionary mapping processor names to their description/name values.
|
||||||
|
Cached to avoid repeated lookups.
|
||||||
|
|
||||||
|
:return: A dict mapping processor name to description (e.g., {'text_json_diff': 'Webpage Text/HTML, JSON and PDF changes'})
|
||||||
|
"""
|
||||||
|
processor_classes = find_processors()
|
||||||
|
descriptions = {}
|
||||||
|
|
||||||
|
for module, sub_package_name in processor_classes:
|
||||||
|
# Try to get the 'name' or 'description' attribute from the processor module first
|
||||||
|
if hasattr(module, 'name'):
|
||||||
|
descriptions[sub_package_name] = module.name
|
||||||
|
elif hasattr(module, 'description'):
|
||||||
|
descriptions[sub_package_name] = module.description
|
||||||
|
else:
|
||||||
|
# Fall back to parent module's __init__.py
|
||||||
|
parent_module = get_parent_module(module)
|
||||||
|
if parent_module and hasattr(parent_module, 'processor_description'):
|
||||||
|
descriptions[sub_package_name] = parent_module.processor_description
|
||||||
|
elif parent_module and hasattr(parent_module, 'name'):
|
||||||
|
descriptions[sub_package_name] = parent_module.name
|
||||||
|
else:
|
||||||
|
# Final fallback to a readable name
|
||||||
|
descriptions[sub_package_name] = sub_package_name.replace('_', ' ').title()
|
||||||
|
|
||||||
|
return descriptions
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,39 @@
|
|||||||
Configuration forms for fast screenshot comparison processor.
|
Configuration forms for fast screenshot comparison processor.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from wtforms import SelectField, validators
|
from wtforms import SelectField, StringField, validators, ValidationError
|
||||||
from changedetectionio.forms import processor_text_json_diff_form
|
from changedetectionio.forms import processor_text_json_diff_form
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def validate_bounding_box(form, field):
|
||||||
|
"""Validate bounding box format: x,y,width,height with integers."""
|
||||||
|
if not field.data:
|
||||||
|
return # Optional field
|
||||||
|
|
||||||
|
if len(field.data) > 100:
|
||||||
|
raise ValidationError('Bounding box value is too long')
|
||||||
|
|
||||||
|
# Should be comma-separated integers
|
||||||
|
if not re.match(r'^\d+,\d+,\d+,\d+$', field.data):
|
||||||
|
raise ValidationError('Bounding box must be in format: x,y,width,height (integers only)')
|
||||||
|
|
||||||
|
# Validate values are reasonable (not negative, not ridiculously large)
|
||||||
|
parts = [int(p) for p in field.data.split(',')]
|
||||||
|
for part in parts:
|
||||||
|
if part < 0:
|
||||||
|
raise ValidationError('Bounding box values must be non-negative')
|
||||||
|
if part > 10000: # Reasonable max screen dimension
|
||||||
|
raise ValidationError('Bounding box values are too large')
|
||||||
|
|
||||||
|
|
||||||
|
def validate_selection_mode(form, field):
|
||||||
|
"""Validate selection mode value."""
|
||||||
|
if not field.data:
|
||||||
|
return # Optional field
|
||||||
|
|
||||||
|
if field.data not in ['element', 'draw']:
|
||||||
|
raise ValidationError('Selection mode must be either "element" or "draw"')
|
||||||
|
|
||||||
|
|
||||||
class processor_settings_form(processor_text_json_diff_form):
|
class processor_settings_form(processor_text_json_diff_form):
|
||||||
@@ -23,6 +54,27 @@ class processor_settings_form(processor_text_json_diff_form):
|
|||||||
default=''
|
default=''
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Processor-specific config fields (stored in separate JSON file)
|
||||||
|
processor_config_bounding_box = StringField(
|
||||||
|
'Bounding Box',
|
||||||
|
validators=[
|
||||||
|
validators.Optional(),
|
||||||
|
validators.Length(max=100, message='Bounding box value is too long'),
|
||||||
|
validate_bounding_box
|
||||||
|
],
|
||||||
|
render_kw={"style": "display: none;", "id": "bounding_box"}
|
||||||
|
)
|
||||||
|
|
||||||
|
processor_config_selection_mode = StringField(
|
||||||
|
'Selection Mode',
|
||||||
|
validators=[
|
||||||
|
validators.Optional(),
|
||||||
|
validators.Length(max=20, message='Selection mode value is too long'),
|
||||||
|
validate_selection_mode
|
||||||
|
],
|
||||||
|
render_kw={"style": "display: none;", "id": "selection_mode"}
|
||||||
|
)
|
||||||
|
|
||||||
def extra_tab_content(self):
|
def extra_tab_content(self):
|
||||||
"""Tab label for processor-specific settings."""
|
"""Tab label for processor-specific settings."""
|
||||||
return 'Screenshot Comparison'
|
return 'Screenshot Comparison'
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ from changedetectionio.processors import difference_detection_processor, SCREENS
|
|||||||
from changedetectionio.processors.exceptions import ProcessorException
|
from changedetectionio.processors.exceptions import ProcessorException
|
||||||
from . import DEFAULT_COMPARISON_METHOD, DEFAULT_COMPARISON_THRESHOLD_OPENCV, DEFAULT_COMPARISON_THRESHOLD_PIXELMATCH
|
from . import DEFAULT_COMPARISON_METHOD, DEFAULT_COMPARISON_THRESHOLD_OPENCV, DEFAULT_COMPARISON_THRESHOLD_PIXELMATCH
|
||||||
|
|
||||||
name = 'Visual/Screenshot change detection (Fast)'
|
name = 'Visual/Image screenshot change detection'
|
||||||
description = 'Compares screenshots using fast algorithms (OpenCV or pixelmatch), 10-100x faster than SSIM'
|
description = 'Compares screenshots using fast algorithms (OpenCV or pixelmatch), 10-100x faster than SSIM'
|
||||||
processor_weight = 2
|
processor_weight = 2
|
||||||
|
list_badge_text = "Visual"
|
||||||
|
|
||||||
class perform_site_check(difference_detection_processor):
|
class perform_site_check(difference_detection_processor):
|
||||||
"""Fast screenshot comparison processor."""
|
"""Fast screenshot comparison processor."""
|
||||||
@@ -84,43 +84,73 @@ class perform_site_check(difference_detection_processor):
|
|||||||
url=watch.get('url')
|
url=watch.get('url')
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if visual selector (include_filters) is set for region-based comparison
|
# Check if bounding box is set (for drawn area mode)
|
||||||
include_filters = watch.get('include_filters', [])
|
# Read from processor-specific config JSON file (named after processor)
|
||||||
crop_region = None
|
crop_region = None
|
||||||
|
# Automatically use the processor name from watch config as filename
|
||||||
|
processor_name = watch.get('processor', 'default')
|
||||||
|
config_filename = f'{processor_name}.json'
|
||||||
|
processor_config = self.get_extra_watch_config(config_filename)
|
||||||
|
bounding_box = processor_config.get('bounding_box') if processor_config else None
|
||||||
|
|
||||||
if include_filters and len(include_filters) > 0:
|
if bounding_box:
|
||||||
# Get the first filter to use for cropping
|
try:
|
||||||
first_filter = include_filters[0].strip()
|
# Parse bounding box: "x,y,width,height"
|
||||||
|
parts = [int(p.strip()) for p in bounding_box.split(',')]
|
||||||
|
if len(parts) == 4:
|
||||||
|
x, y, width, height = parts
|
||||||
|
|
||||||
if first_filter and self.xpath_data:
|
# PIL crop uses (left, top, right, bottom)
|
||||||
try:
|
crop_region = (
|
||||||
import json
|
max(0, x),
|
||||||
# xpath_data is JSON string from browser
|
max(0, y),
|
||||||
xpath_data_obj = json.loads(self.xpath_data) if isinstance(self.xpath_data, str) else self.xpath_data
|
min(current_img.width, x + width),
|
||||||
|
min(current_img.height, y + height)
|
||||||
|
)
|
||||||
|
|
||||||
# Find the bounding box for the first filter
|
logger.info(f"Bounding box enabled: cropping to region {crop_region} (x={x}, y={y}, w={width}, h={height})")
|
||||||
for element in xpath_data_obj.get('size_pos', []):
|
else:
|
||||||
# Match the filter with the element's xpath
|
logger.warning(f"Invalid bounding box format: {bounding_box} (expected 4 values)")
|
||||||
if element.get('xpath') == first_filter and element.get('highlight_as_custom_filter'):
|
except Exception as e:
|
||||||
# Found the element - extract crop coordinates
|
logger.warning(f"Failed to parse bounding box '{bounding_box}': {e}")
|
||||||
left = element.get('left', 0)
|
|
||||||
top = element.get('top', 0)
|
|
||||||
width = element.get('width', 0)
|
|
||||||
height = element.get('height', 0)
|
|
||||||
|
|
||||||
# PIL crop uses (left, top, right, bottom)
|
# If no bounding box, check if visual selector (include_filters) is set for region-based comparison
|
||||||
crop_region = (
|
if not crop_region:
|
||||||
max(0, left),
|
include_filters = watch.get('include_filters', [])
|
||||||
max(0, top),
|
|
||||||
min(current_img.width, left + width),
|
|
||||||
min(current_img.height, top + height)
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"Visual selector enabled: cropping to region {crop_region} for filter: {first_filter}")
|
if include_filters and len(include_filters) > 0:
|
||||||
break
|
# Get the first filter to use for cropping
|
||||||
|
first_filter = include_filters[0].strip()
|
||||||
|
|
||||||
except Exception as e:
|
if first_filter and self.xpath_data:
|
||||||
logger.warning(f"Failed to parse xpath_data for visual selector: {e}")
|
try:
|
||||||
|
import json
|
||||||
|
# xpath_data is JSON string from browser
|
||||||
|
xpath_data_obj = json.loads(self.xpath_data) if isinstance(self.xpath_data, str) else self.xpath_data
|
||||||
|
|
||||||
|
# Find the bounding box for the first filter
|
||||||
|
for element in xpath_data_obj.get('size_pos', []):
|
||||||
|
# Match the filter with the element's xpath
|
||||||
|
if element.get('xpath') == first_filter and element.get('highlight_as_custom_filter'):
|
||||||
|
# Found the element - extract crop coordinates
|
||||||
|
left = element.get('left', 0)
|
||||||
|
top = element.get('top', 0)
|
||||||
|
width = element.get('width', 0)
|
||||||
|
height = element.get('height', 0)
|
||||||
|
|
||||||
|
# PIL crop uses (left, top, right, bottom)
|
||||||
|
crop_region = (
|
||||||
|
max(0, left),
|
||||||
|
max(0, top),
|
||||||
|
min(current_img.width, left + width),
|
||||||
|
min(current_img.height, top + height)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Visual selector enabled: cropping to region {crop_region} for filter: {first_filter}")
|
||||||
|
break
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to parse xpath_data for visual selector: {e}")
|
||||||
|
|
||||||
# Crop the current image if region was found
|
# Crop the current image if region was found
|
||||||
if crop_region:
|
if crop_region:
|
||||||
@@ -130,7 +160,7 @@ class perform_site_check(difference_detection_processor):
|
|||||||
# Update self.screenshot to the cropped version for history storage
|
# Update self.screenshot to the cropped version for history storage
|
||||||
crop_buffer = io.BytesIO()
|
crop_buffer = io.BytesIO()
|
||||||
current_img.save(crop_buffer, format='PNG')
|
current_img.save(crop_buffer, format='PNG')
|
||||||
self.screenshot = crop_buffer.getvalue()
|
self.screenshot = self.fetcher.screenshot = crop_buffer.getvalue()
|
||||||
|
|
||||||
logger.debug(f"Cropped screenshot to {current_img.size} (region: {crop_region})")
|
logger.debug(f"Cropped screenshot to {current_img.size} (region: {crop_region})")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|||||||
name = 'Re-stock & Price detection for pages with a SINGLE product'
|
name = 'Re-stock & Price detection for pages with a SINGLE product'
|
||||||
description = 'Detects if the product goes back to in-stock'
|
description = 'Detects if the product goes back to in-stock'
|
||||||
processor_weight = 1
|
processor_weight = 1
|
||||||
|
list_badge_text = "Restock"
|
||||||
|
|
||||||
class UnableToExtractRestockData(Exception):
|
class UnableToExtractRestockData(Exception):
|
||||||
def __init__(self, status_code):
|
def __init__(self, status_code):
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|||||||
name = 'Webpage Text/HTML, JSON and PDF changes'
|
name = 'Webpage Text/HTML, JSON and PDF changes'
|
||||||
description = 'Detects all text changes where possible'
|
description = 'Detects all text changes where possible'
|
||||||
processor_weight = -100
|
processor_weight = -100
|
||||||
|
list_badge_text = "Text"
|
||||||
|
|
||||||
JSON_FILTER_PREFIXES = ['json:', 'jq:', 'jqraw:']
|
JSON_FILTER_PREFIXES = ['json:', 'jq:', 'jqraw:']
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,18 @@ $(document).ready(() => {
|
|||||||
let c, xctx, ctx;
|
let c, xctx, ctx;
|
||||||
let xScale = 1, yScale = 1;
|
let xScale = 1, yScale = 1;
|
||||||
let selectorImage, selectorImageRect, selectorData;
|
let selectorImage, selectorImageRect, selectorData;
|
||||||
|
let elementHandlers = {}; // Store references to element selection handlers (needed for draw mode toggling)
|
||||||
|
|
||||||
|
// Box drawing mode variables (for image_ssim_diff processor)
|
||||||
|
let drawMode = false;
|
||||||
|
let isDrawing = false;
|
||||||
|
let isDragging = false;
|
||||||
|
let drawStartX, drawStartY;
|
||||||
|
let dragOffsetX, dragOffsetY;
|
||||||
|
let drawnBox = null;
|
||||||
|
let resizeHandle = null;
|
||||||
|
const HANDLE_SIZE = 8;
|
||||||
|
const isImageProcessor = $('input[value="image_ssim_diff"]').is(':checked');
|
||||||
|
|
||||||
|
|
||||||
// Global jQuery selectors with "Elem" appended
|
// Global jQuery selectors with "Elem" appended
|
||||||
@@ -141,6 +153,10 @@ $(document).ready(() => {
|
|||||||
|
|
||||||
setScale();
|
setScale();
|
||||||
reflowSelector();
|
reflowSelector();
|
||||||
|
|
||||||
|
// Initialize draw mode after everything is set up
|
||||||
|
initializeDrawMode();
|
||||||
|
|
||||||
$fetchingUpdateNoticeElem.fadeOut();
|
$fetchingUpdateNoticeElem.fadeOut();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -201,9 +217,14 @@ $(document).ready(() => {
|
|||||||
highlightCurrentSelected();
|
highlightCurrentSelected();
|
||||||
updateFiltersText();
|
updateFiltersText();
|
||||||
|
|
||||||
$selectorCanvasElem.bind('mousemove', handleMouseMove.debounce(5));
|
// Store handler references for later use
|
||||||
$selectorCanvasElem.bind('mousedown', handleMouseDown.debounce(5));
|
elementHandlers.handleMouseMove = handleMouseMove.debounce(5);
|
||||||
$selectorCanvasElem.bind('mouseleave', highlightCurrentSelected.debounce(5));
|
elementHandlers.handleMouseDown = handleMouseDown.debounce(5);
|
||||||
|
elementHandlers.handleMouseLeave = highlightCurrentSelected.debounce(5);
|
||||||
|
|
||||||
|
$selectorCanvasElem.bind('mousemove', elementHandlers.handleMouseMove);
|
||||||
|
$selectorCanvasElem.bind('mousedown', elementHandlers.handleMouseDown);
|
||||||
|
$selectorCanvasElem.bind('mouseleave', elementHandlers.handleMouseLeave);
|
||||||
|
|
||||||
function handleMouseMove(e) {
|
function handleMouseMove(e) {
|
||||||
if (!e.offsetX && !e.offsetY) {
|
if (!e.offsetX && !e.offsetY) {
|
||||||
@@ -257,4 +278,372 @@ $(document).ready(() => {
|
|||||||
xctx.strokeRect(sel.left * xScale, sel.top * yScale, sel.width * xScale, sel.height * yScale);
|
xctx.strokeRect(sel.left * xScale, sel.top * yScale, sel.width * xScale, sel.height * yScale);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============= BOX DRAWING MODE (for image_ssim_diff processor) =============
|
||||||
|
|
||||||
|
function initializeDrawMode() {
|
||||||
|
if (!isImageProcessor || !c) return;
|
||||||
|
|
||||||
|
const $selectorModeRadios = $('input[name="selector-mode"]');
|
||||||
|
const $boundingBoxField = $('#bounding_box');
|
||||||
|
const $selectionModeField = $('#selection_mode');
|
||||||
|
|
||||||
|
// Load existing selection mode if present
|
||||||
|
const savedMode = $selectionModeField.val();
|
||||||
|
if (savedMode && (savedMode === 'element' || savedMode === 'draw')) {
|
||||||
|
$selectorModeRadios.filter(`[value="${savedMode}"]`).prop('checked', true);
|
||||||
|
console.log('Loaded saved mode:', savedMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load existing bounding box if present
|
||||||
|
const existingBox = $boundingBoxField.val();
|
||||||
|
if (existingBox) {
|
||||||
|
try {
|
||||||
|
const parts = existingBox.split(',').map(p => parseFloat(p));
|
||||||
|
if (parts.length === 4) {
|
||||||
|
drawnBox = {
|
||||||
|
x: parts[0] * xScale,
|
||||||
|
y: parts[1] * yScale,
|
||||||
|
width: parts[2] * xScale,
|
||||||
|
height: parts[3] * yScale
|
||||||
|
};
|
||||||
|
console.log('Loaded saved bounding box:', existingBox);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse existing bounding box:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update mode when radio changes
|
||||||
|
$selectorModeRadios.off('change').on('change', function() {
|
||||||
|
const newMode = $(this).val();
|
||||||
|
drawMode = newMode === 'draw';
|
||||||
|
console.log('Mode changed to:', newMode);
|
||||||
|
|
||||||
|
// Save the mode to the hidden field
|
||||||
|
$selectionModeField.val(newMode);
|
||||||
|
|
||||||
|
if (drawMode) {
|
||||||
|
enableDrawMode();
|
||||||
|
} else {
|
||||||
|
disableDrawMode();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set initial mode based on which radio is checked
|
||||||
|
drawMode = $selectorModeRadios.filter(':checked').val() === 'draw';
|
||||||
|
console.log('Initial mode:', drawMode ? 'draw' : 'element');
|
||||||
|
|
||||||
|
// Save initial mode
|
||||||
|
$selectionModeField.val(drawMode ? 'draw' : 'element');
|
||||||
|
|
||||||
|
if (drawMode) {
|
||||||
|
enableDrawMode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function enableDrawMode() {
|
||||||
|
console.log('Enabling draw mode...');
|
||||||
|
|
||||||
|
// Unbind element selection handlers
|
||||||
|
$selectorCanvasElem.unbind('mousemove mousedown mouseleave');
|
||||||
|
|
||||||
|
// Set cursor to crosshair
|
||||||
|
$selectorCanvasElem.css('cursor', 'crosshair');
|
||||||
|
|
||||||
|
// Bind draw mode handlers
|
||||||
|
$selectorCanvasElem.on('mousedown', handleDrawMouseDown);
|
||||||
|
$selectorCanvasElem.on('mousemove', handleDrawMouseMove);
|
||||||
|
$selectorCanvasElem.on('mouseup', handleDrawMouseUp);
|
||||||
|
$selectorCanvasElem.on('mouseleave', handleDrawMouseUp);
|
||||||
|
|
||||||
|
// Clear element selections and xpath display
|
||||||
|
currentSelections = [];
|
||||||
|
$includeFiltersElem.val('');
|
||||||
|
$selectorCurrentXpathElem.html('Draw mode - click and drag to select an area');
|
||||||
|
|
||||||
|
// Clear the canvas
|
||||||
|
if (ctx && xctx) {
|
||||||
|
ctx.clearRect(0, 0, c.width, c.height);
|
||||||
|
xctx.clearRect(0, 0, c.width, c.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redraw if we have an existing box
|
||||||
|
if (drawnBox) {
|
||||||
|
drawBox();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function disableDrawMode() {
|
||||||
|
console.log('Disabling draw mode, switching to element mode...');
|
||||||
|
|
||||||
|
// Unbind draw handlers
|
||||||
|
$selectorCanvasElem.unbind('mousedown mousemove mouseup mouseleave');
|
||||||
|
|
||||||
|
// Reset cursor
|
||||||
|
$selectorCanvasElem.css('cursor', 'default');
|
||||||
|
|
||||||
|
// Clear drawn box
|
||||||
|
drawnBox = null;
|
||||||
|
$('#bounding_box').val('');
|
||||||
|
|
||||||
|
// Clear the canvases
|
||||||
|
if (ctx && xctx) {
|
||||||
|
ctx.clearRect(0, 0, c.width, c.height);
|
||||||
|
xctx.clearRect(0, 0, c.width, c.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore element selections from include_filters
|
||||||
|
currentSelections = [];
|
||||||
|
if (selectorData && selectorData['size_pos']) {
|
||||||
|
let existingFilters = splitToList($includeFiltersElem.val());
|
||||||
|
|
||||||
|
selectorData['size_pos'].forEach(sel => {
|
||||||
|
if ((!runInClearMode && sel.highlight_as_custom_filter) || existingFilters.includes(sel.xpath)) {
|
||||||
|
console.log("Restoring selection: " + sel.xpath);
|
||||||
|
currentSelections.push(sel);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-enable element selection handlers using stored references
|
||||||
|
if (elementHandlers.handleMouseMove) {
|
||||||
|
$selectorCanvasElem.bind('mousemove', elementHandlers.handleMouseMove);
|
||||||
|
$selectorCanvasElem.bind('mousedown', elementHandlers.handleMouseDown);
|
||||||
|
$selectorCanvasElem.bind('mouseleave', elementHandlers.handleMouseLeave);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore the element selection display
|
||||||
|
$selectorCurrentXpathElem.html('Hover over elements to select');
|
||||||
|
|
||||||
|
// Highlight the restored selections
|
||||||
|
highlightCurrentSelected();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrawMouseDown(e) {
|
||||||
|
const rect = c.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
|
||||||
|
// Check if clicking on a resize handle
|
||||||
|
if (drawnBox) {
|
||||||
|
resizeHandle = getResizeHandle(x, y);
|
||||||
|
if (resizeHandle) {
|
||||||
|
isDrawing = true;
|
||||||
|
drawStartX = x;
|
||||||
|
drawStartY = y;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if clicking inside the box (for dragging)
|
||||||
|
if (isInsideBox(x, y)) {
|
||||||
|
isDragging = true;
|
||||||
|
dragOffsetX = x - drawnBox.x;
|
||||||
|
dragOffsetY = y - drawnBox.y;
|
||||||
|
$selectorCanvasElem.css('cursor', 'move');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start new box
|
||||||
|
isDrawing = true;
|
||||||
|
drawStartX = x;
|
||||||
|
drawStartY = y;
|
||||||
|
drawnBox = { x: x, y: y, width: 0, height: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrawMouseMove(e) {
|
||||||
|
const rect = c.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
|
||||||
|
// Update cursor based on position
|
||||||
|
if (!isDrawing && !isDragging && drawnBox) {
|
||||||
|
const handle = getResizeHandle(x, y);
|
||||||
|
if (handle) {
|
||||||
|
$selectorCanvasElem.css('cursor', getHandleCursor(handle));
|
||||||
|
} else if (isInsideBox(x, y)) {
|
||||||
|
$selectorCanvasElem.css('cursor', 'move');
|
||||||
|
} else {
|
||||||
|
$selectorCanvasElem.css('cursor', 'crosshair');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle dragging the box
|
||||||
|
if (isDragging) {
|
||||||
|
drawnBox.x = x - dragOffsetX;
|
||||||
|
drawnBox.y = y - dragOffsetY;
|
||||||
|
drawBox();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDrawing) return;
|
||||||
|
|
||||||
|
if (resizeHandle) {
|
||||||
|
// Resize existing box
|
||||||
|
resizeBox(x, y);
|
||||||
|
} else {
|
||||||
|
// Draw new box
|
||||||
|
drawnBox.width = x - drawStartX;
|
||||||
|
drawnBox.height = y - drawStartY;
|
||||||
|
}
|
||||||
|
|
||||||
|
drawBox();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrawMouseUp(e) {
|
||||||
|
if (!isDrawing && !isDragging) return;
|
||||||
|
|
||||||
|
isDrawing = false;
|
||||||
|
isDragging = false;
|
||||||
|
resizeHandle = null;
|
||||||
|
|
||||||
|
if (drawnBox) {
|
||||||
|
// Normalize box (handle negative dimensions)
|
||||||
|
if (drawnBox.width < 0) {
|
||||||
|
drawnBox.x += drawnBox.width;
|
||||||
|
drawnBox.width = Math.abs(drawnBox.width);
|
||||||
|
}
|
||||||
|
if (drawnBox.height < 0) {
|
||||||
|
drawnBox.y += drawnBox.height;
|
||||||
|
drawnBox.height = Math.abs(drawnBox.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constrain to canvas bounds
|
||||||
|
drawnBox.x = Math.max(0, Math.min(drawnBox.x, c.width - drawnBox.width));
|
||||||
|
drawnBox.y = Math.max(0, Math.min(drawnBox.y, c.height - drawnBox.height));
|
||||||
|
|
||||||
|
// Save to form field (convert from scaled to natural coordinates)
|
||||||
|
const naturalX = Math.round(drawnBox.x / xScale);
|
||||||
|
const naturalY = Math.round(drawnBox.y / yScale);
|
||||||
|
const naturalWidth = Math.round(drawnBox.width / xScale);
|
||||||
|
const naturalHeight = Math.round(drawnBox.height / yScale);
|
||||||
|
|
||||||
|
$('#bounding_box').val(`${naturalX},${naturalY},${naturalWidth},${naturalHeight}`);
|
||||||
|
|
||||||
|
drawBox();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawBox() {
|
||||||
|
if (!drawnBox) return;
|
||||||
|
|
||||||
|
// Clear and redraw
|
||||||
|
ctx.clearRect(0, 0, c.width, c.height);
|
||||||
|
xctx.clearRect(0, 0, c.width, c.height);
|
||||||
|
|
||||||
|
// Draw box
|
||||||
|
ctx.strokeStyle = STROKE_STYLE_REDLINE;
|
||||||
|
ctx.fillStyle = FILL_STYLE_REDLINE;
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
|
||||||
|
const drawX = drawnBox.width >= 0 ? drawnBox.x : drawnBox.x + drawnBox.width;
|
||||||
|
const drawY = drawnBox.height >= 0 ? drawnBox.y : drawnBox.y + drawnBox.height;
|
||||||
|
const drawW = Math.abs(drawnBox.width);
|
||||||
|
const drawH = Math.abs(drawnBox.height);
|
||||||
|
|
||||||
|
ctx.strokeRect(drawX, drawY, drawW, drawH);
|
||||||
|
ctx.fillRect(drawX, drawY, drawW, drawH);
|
||||||
|
|
||||||
|
// Draw resize handles
|
||||||
|
if (!isDrawing) {
|
||||||
|
drawResizeHandles(drawX, drawY, drawW, drawH);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawResizeHandles(x, y, w, h) {
|
||||||
|
ctx.fillStyle = '#fff';
|
||||||
|
ctx.strokeStyle = '#000';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
|
||||||
|
const handles = [
|
||||||
|
{ x: x, y: y }, // top-left
|
||||||
|
{ x: x + w, y: y }, // top-right
|
||||||
|
{ x: x, y: y + h }, // bottom-left
|
||||||
|
{ x: x + w, y: y + h } // bottom-right
|
||||||
|
];
|
||||||
|
|
||||||
|
handles.forEach(handle => {
|
||||||
|
ctx.fillRect(handle.x - HANDLE_SIZE/2, handle.y - HANDLE_SIZE/2, HANDLE_SIZE, HANDLE_SIZE);
|
||||||
|
ctx.strokeRect(handle.x - HANDLE_SIZE/2, handle.y - HANDLE_SIZE/2, HANDLE_SIZE, HANDLE_SIZE);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInsideBox(x, y) {
|
||||||
|
if (!drawnBox) return false;
|
||||||
|
|
||||||
|
const drawX = drawnBox.width >= 0 ? drawnBox.x : drawnBox.x + drawnBox.width;
|
||||||
|
const drawY = drawnBox.height >= 0 ? drawnBox.y : drawnBox.y + drawnBox.height;
|
||||||
|
const drawW = Math.abs(drawnBox.width);
|
||||||
|
const drawH = Math.abs(drawnBox.height);
|
||||||
|
|
||||||
|
return x >= drawX && x <= drawX + drawW && y >= drawY && y <= drawY + drawH;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResizeHandle(x, y) {
|
||||||
|
if (!drawnBox) return null;
|
||||||
|
|
||||||
|
const drawX = drawnBox.width >= 0 ? drawnBox.x : drawnBox.x + drawnBox.width;
|
||||||
|
const drawY = drawnBox.height >= 0 ? drawnBox.y : drawnBox.y + drawnBox.height;
|
||||||
|
const drawW = Math.abs(drawnBox.width);
|
||||||
|
const drawH = Math.abs(drawnBox.height);
|
||||||
|
|
||||||
|
const handles = {
|
||||||
|
'tl': { x: drawX, y: drawY },
|
||||||
|
'tr': { x: drawX + drawW, y: drawY },
|
||||||
|
'bl': { x: drawX, y: drawY + drawH },
|
||||||
|
'br': { x: drawX + drawW, y: drawY + drawH }
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [key, handle] of Object.entries(handles)) {
|
||||||
|
if (Math.abs(x - handle.x) <= HANDLE_SIZE && Math.abs(y - handle.y) <= HANDLE_SIZE) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHandleCursor(handle) {
|
||||||
|
const cursors = {
|
||||||
|
'tl': 'nw-resize',
|
||||||
|
'tr': 'ne-resize',
|
||||||
|
'bl': 'sw-resize',
|
||||||
|
'br': 'se-resize'
|
||||||
|
};
|
||||||
|
return cursors[handle] || 'crosshair';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeBox(x, y) {
|
||||||
|
const dx = x - drawStartX;
|
||||||
|
const dy = y - drawStartY;
|
||||||
|
|
||||||
|
const originalBox = { ...drawnBox };
|
||||||
|
|
||||||
|
switch (resizeHandle) {
|
||||||
|
case 'tl':
|
||||||
|
drawnBox.x = x;
|
||||||
|
drawnBox.y = y;
|
||||||
|
drawnBox.width = originalBox.x + originalBox.width - x;
|
||||||
|
drawnBox.height = originalBox.y + originalBox.height - y;
|
||||||
|
break;
|
||||||
|
case 'tr':
|
||||||
|
drawnBox.y = y;
|
||||||
|
drawnBox.width = x - originalBox.x;
|
||||||
|
drawnBox.height = originalBox.y + originalBox.height - y;
|
||||||
|
break;
|
||||||
|
case 'bl':
|
||||||
|
drawnBox.x = x;
|
||||||
|
drawnBox.width = originalBox.x + originalBox.width - x;
|
||||||
|
drawnBox.height = y - originalBox.y;
|
||||||
|
break;
|
||||||
|
case 'br':
|
||||||
|
drawnBox.width = x - originalBox.x;
|
||||||
|
drawnBox.height = y - originalBox.y;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
drawStartX = x;
|
||||||
|
drawStartY = y;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
10
changedetectionio/static/styles/scss/parts/_diff_image.scss
Normal file
10
changedetectionio/static/styles/scss/parts/_diff_image.scss
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
body.image_ssim_diff {
|
||||||
|
#edit-text-filter {
|
||||||
|
.text-filtering {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#conditions-tab {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@
|
|||||||
@use "parts/socket";
|
@use "parts/socket";
|
||||||
@use "parts/visualselector";
|
@use "parts/visualselector";
|
||||||
@use "parts/widgets";
|
@use "parts/widgets";
|
||||||
|
@use "parts/diff_image";
|
||||||
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
@@ -1023,6 +1025,46 @@ ul {
|
|||||||
color: var(--color-warning);
|
color: var(--color-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* processor type badges */
|
||||||
|
.processor-badge {
|
||||||
|
@extend .inline-tag;
|
||||||
|
font-size: 0.85em;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.processor-badge-text_json_diff {
|
||||||
|
background-color: rgba(100, 149, 237, 0.4);
|
||||||
|
color: #3a5a8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.processor-badge-restock_diff {
|
||||||
|
background-color: rgba(45, 134, 89, 0.4);
|
||||||
|
color: #1a5c3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.processor-badge-image_ssim_diff {
|
||||||
|
background-color: rgba(139, 92, 246, 0.4);
|
||||||
|
color: #6b4bbd;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark mode: solid colors for better contrast
|
||||||
|
html[data-darkmode="true"] {
|
||||||
|
.processor-badge-text_json_diff {
|
||||||
|
background-color: #4682b4;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.processor-badge-restock_diff {
|
||||||
|
background-color: #2d8659;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.processor-badge-image_ssim_diff {
|
||||||
|
background-color: #8b5cf6;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* automatic price following helpers */
|
/* automatic price following helpers */
|
||||||
.tracking-ldjson-price-data {
|
.tracking-ldjson-price-data {
|
||||||
background-color: var(--color-background-button-green);
|
background-color: var(--color-background-button-green);
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user