This commit is contained in:
dgtlmoon
2025-12-17 16:52:05 +01:00
parent 3b51d03be5
commit fbb019ddf3
13 changed files with 707 additions and 43 deletions

View File

@@ -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

View File

@@ -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 -->

View File

@@ -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'),

View File

@@ -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>

View File

@@ -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

View File

@@ -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'

View File

@@ -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:

View File

@@ -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):

View File

@@ -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:']

View File

@@ -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;
}
}); });

View File

@@ -0,0 +1,10 @@
body.image_ssim_diff {
#edit-text-filter {
.text-filtering {
display: none;
}
}
#conditions-tab {
display: none;
}
}

View File

@@ -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