mirror of
				https://github.com/dgtlmoon/changedetection.io.git
				synced 2025-11-04 08:34:57 +00:00 
			
		
		
		
	Compare commits
	
		
			6 Commits
		
	
	
		
			fix-watch-
			...
			restock-cu
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					41952157eb | ||
| 
						 | 
					274c575f85 | ||
| 
						 | 
					5c39668b40 | ||
| 
						 | 
					254ebc90df | ||
| 
						 | 
					912ab903c9 | ||
| 
						 | 
					d758afe87e | 
@@ -29,7 +29,7 @@
 | 
			
		||||
    const default_system_fetch_backend="{{ settings_application['fetch_backend'] }}";
 | 
			
		||||
</script>
 | 
			
		||||
<script src="{{url_for('static_content', group='js', filename='plugins.js')}}" defer></script>
 | 
			
		||||
<script src="{{url_for('static_content', group='js', filename='watch-settings.js')}}" defer></script>
 | 
			
		||||
<script src="{{url_for('static_content', group='js', filename=watch['processor']+".js")}}" defer></script>
 | 
			
		||||
<script src="{{url_for('static_content', group='js', filename='notifications.js')}}" defer></script>
 | 
			
		||||
<script src="{{url_for('static_content', group='js', filename='visual-selector.js')}}" defer></script>
 | 
			
		||||
{% if playwright_enabled %}
 | 
			
		||||
@@ -50,8 +50,10 @@
 | 
			
		||||
            {% endif %}
 | 
			
		||||
            <li class="tab"><a id="browsersteps-tab" href="#browser-steps">Browser Steps</a></li>
 | 
			
		||||
        <!-- should goto extra forms? -->
 | 
			
		||||
            {% if watch['processor'] == 'text_json_diff' %}
 | 
			
		||||
            {% if watch['processor'] == 'text_json_diff' or watch['processor'] == 'restock_diff' %}
 | 
			
		||||
            <li class="tab"><a id="visualselector-tab" href="#visualselector">Visual Filter Selector</a></li>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
            {% if watch['processor'] == 'text_json_diff' %}
 | 
			
		||||
            <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 %}
 | 
			
		||||
@@ -377,7 +379,7 @@ Math: {{ 1 + 1 }}") }}
 | 
			
		||||
            {{ extra_form_content|safe }}
 | 
			
		||||
            </div>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
            {% if watch['processor'] == 'text_json_diff' %}
 | 
			
		||||
            {% if watch['processor'] == 'text_json_diff' or watch['processor'] == 'restock_diff' %}
 | 
			
		||||
            <div class="tab-pane-inner visual-selector-ui" id="visualselector">
 | 
			
		||||
                <img class="beta-logo" src="{{url_for('static_content', group='images', filename='beta-logo.png')}}" alt="New beta functionality">
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -55,6 +55,7 @@ class watch_base(dict):
 | 
			
		||||
            'previous_md5_before_filters': False,  # Used for skipping changedetection entirely
 | 
			
		||||
            'processor': 'text_json_diff',  # could be restock_diff or others from .processors
 | 
			
		||||
            'price_change_threshold_percent': None,
 | 
			
		||||
            'price_change_custom_include_filters': None, # Like 'include_filter' but for price changes only
 | 
			
		||||
            'proxy': None,  # Preferred proxy connection
 | 
			
		||||
            'remote_server_reply': None,  # From 'server' reply header
 | 
			
		||||
            'sort_text_alphabetically': False,
 | 
			
		||||
 
 | 
			
		||||
@@ -6,8 +6,69 @@ import re
 | 
			
		||||
 | 
			
		||||
class Restock(dict):
 | 
			
		||||
 | 
			
		||||
    def parse_currency(self, raw_value: str) -> Union[float, None]:
 | 
			
		||||
        # Clean and standardize the value (ie 1,400.00 should be 1400.00), even better would be store the whole thing as an integer.
 | 
			
		||||
    def _normalize_currency_code(self, currency: str, normalize_dollar=False) -> str:
 | 
			
		||||
        """
 | 
			
		||||
        Normalize currency symbol or code to ISO 4217 code for consistency.
 | 
			
		||||
        Uses iso4217parse for accurate conversion.
 | 
			
		||||
 | 
			
		||||
        Returns empty string for ambiguous symbols like '$' where we can't determine
 | 
			
		||||
        the specific currency (USD, CAD, AUD, etc.).
 | 
			
		||||
        """
 | 
			
		||||
        if not currency:
 | 
			
		||||
            return currency
 | 
			
		||||
 | 
			
		||||
        # If already a 3-letter code, likely already normalized
 | 
			
		||||
        if len(currency) == 3 and currency.isupper():
 | 
			
		||||
            return currency
 | 
			
		||||
 | 
			
		||||
        # Handle ambiguous dollar sign - can't determine which dollar currency
 | 
			
		||||
        if normalize_dollar and currency == '$':
 | 
			
		||||
            return ''
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            import iso4217parse
 | 
			
		||||
 | 
			
		||||
            # Parse the currency - returns list of possible matches
 | 
			
		||||
            # This handles: € -> EUR, Kč -> CZK, £ -> GBP, ¥ -> JPY, etc.
 | 
			
		||||
            currencies = iso4217parse.parse(currency)
 | 
			
		||||
 | 
			
		||||
            if currencies:
 | 
			
		||||
                # Return first match (iso4217parse handles the mapping)
 | 
			
		||||
                return currencies[0].alpha3
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
        # Fallback: return as-is if can't normalize
 | 
			
		||||
        return currency
 | 
			
		||||
 | 
			
		||||
    def parse_currency(self, raw_value: str, normalize_dollar=False) -> Union[dict, None]:
 | 
			
		||||
        """
 | 
			
		||||
        Parse price and currency from text, handling messy formats with extra text.
 | 
			
		||||
        Returns dict with 'price' and 'currency' keys (ISO 4217 code), or None if parsing fails.
 | 
			
		||||
 | 
			
		||||
        normalize_dollar convert $ to '' on sites that we cant tell what currency the site is in
 | 
			
		||||
        """
 | 
			
		||||
        try:
 | 
			
		||||
            from price_parser import Price
 | 
			
		||||
            # price-parser handles:
 | 
			
		||||
            # - Extra text before/after ("Beginning at", "tax incl.")
 | 
			
		||||
            # - Various number formats (1 099,00 or 1,099.00)
 | 
			
		||||
            # - Currency symbols and codes
 | 
			
		||||
            price_obj = Price.fromstring(raw_value)
 | 
			
		||||
 | 
			
		||||
            if price_obj.amount is not None:
 | 
			
		||||
                result = {'price': float(price_obj.amount)}
 | 
			
		||||
                if price_obj.currency:
 | 
			
		||||
                    # Normalize currency symbol to ISO 4217 code for consistency with metadata
 | 
			
		||||
                    normalized_currency = self._normalize_currency_code(currency=price_obj.currency, normalize_dollar=normalize_dollar)
 | 
			
		||||
                    result['currency'] = normalized_currency
 | 
			
		||||
                return result
 | 
			
		||||
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            from loguru import logger
 | 
			
		||||
            logger.trace(f"price-parser failed on '{raw_value}': {e}, falling back to manual parsing")
 | 
			
		||||
 | 
			
		||||
        # Fallback to existing manual parsing logic
 | 
			
		||||
        standardized_value = raw_value
 | 
			
		||||
 | 
			
		||||
        if ',' in standardized_value and '.' in standardized_value:
 | 
			
		||||
@@ -24,7 +85,7 @@ class Restock(dict):
 | 
			
		||||
 | 
			
		||||
        if standardized_value:
 | 
			
		||||
            # Convert to float
 | 
			
		||||
            return float(parse_decimal(standardized_value, locale='en'))
 | 
			
		||||
            return {'price': float(parse_decimal(standardized_value, locale='en'))}
 | 
			
		||||
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
@@ -51,7 +112,15 @@ class Restock(dict):
 | 
			
		||||
        # Custom logic to handle setting price and original_price
 | 
			
		||||
        if key == 'price' or key == 'original_price':
 | 
			
		||||
            if isinstance(value, str):
 | 
			
		||||
                value = self.parse_currency(raw_value=value)
 | 
			
		||||
                parsed = self.parse_currency(raw_value=value)
 | 
			
		||||
                if parsed:
 | 
			
		||||
                    # Set the price value
 | 
			
		||||
                    value = parsed.get('price')
 | 
			
		||||
                    # Also set currency if found and not already set
 | 
			
		||||
                    if parsed.get('currency') and not self.get('currency'):
 | 
			
		||||
                        super().__setitem__('currency', parsed.get('currency'))
 | 
			
		||||
                else:
 | 
			
		||||
                    value = None
 | 
			
		||||
 | 
			
		||||
        super().__setitem__(key, value)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,13 +1,13 @@
 | 
			
		||||
from wtforms import (
 | 
			
		||||
    BooleanField,
 | 
			
		||||
    validators,
 | 
			
		||||
    FloatField
 | 
			
		||||
    FloatField, StringField
 | 
			
		||||
)
 | 
			
		||||
from wtforms.fields.choices import RadioField
 | 
			
		||||
from wtforms.fields.form import FormField
 | 
			
		||||
from wtforms.form import Form
 | 
			
		||||
 | 
			
		||||
from changedetectionio.forms import processor_text_json_diff_form
 | 
			
		||||
from changedetectionio.forms import processor_text_json_diff_form, ValidateCSSJSONXPATHInput, StringListField
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class RestockSettingsForm(Form):
 | 
			
		||||
@@ -27,6 +27,8 @@ class RestockSettingsForm(Form):
 | 
			
		||||
        validators.NumberRange(min=0, max=100, message="Should be between 0 and 100"),
 | 
			
		||||
    ], render_kw={"placeholder": "0%", "size": "5"})
 | 
			
		||||
 | 
			
		||||
    price_change_custom_include_filters = StringField('Override automatic price detection with this selector', [ValidateCSSJSONXPATHInput()], default='', render_kw={"style": "width: 100%;"})
 | 
			
		||||
 | 
			
		||||
    follow_price_changes = BooleanField('Follow price changes', default=True)
 | 
			
		||||
 | 
			
		||||
class processor_settings_form(processor_text_json_diff_form):
 | 
			
		||||
@@ -74,7 +76,11 @@ class processor_settings_form(processor_text_json_diff_form):
 | 
			
		||||
                    {{ render_field(form.restock_settings.price_change_threshold_percent) }}
 | 
			
		||||
                    <span class="pure-form-message-inline">Price must change more than this % to trigger a change since the first check.</span><br>
 | 
			
		||||
                    <span class="pure-form-message-inline">For example, If the product is $1,000 USD originally, <strong>2%</strong> would mean it has to change more than $20 since the first check.</span><br>
 | 
			
		||||
                </fieldset>                
 | 
			
		||||
                </fieldset>
 | 
			
		||||
                <fieldset class="pure-group price-change-minmax">
 | 
			
		||||
                        {{ render_field(form.restock_settings.price_change_custom_include_filters) }}
 | 
			
		||||
                        <span class="pure-form-message-inline">Override the automatic price metadata reader with this custom select from the <a href="#visualselector">Visual Selector</a>, in the case that the automatic detection was incorrect.</span><br>
 | 
			
		||||
                </fieldset>                                  
 | 
			
		||||
            </div>
 | 
			
		||||
        </fieldset>
 | 
			
		||||
        """
 | 
			
		||||
 
 | 
			
		||||
@@ -6,6 +6,8 @@ from loguru import logger
 | 
			
		||||
import urllib3
 | 
			
		||||
import time
 | 
			
		||||
 | 
			
		||||
from ..text_json_diff.processor import FilterNotFoundInResponse
 | 
			
		||||
 | 
			
		||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
 | 
			
		||||
name = 'Re-stock & Price detection for pages with a SINGLE product'
 | 
			
		||||
description = 'Detects if the product goes back to in-stock'
 | 
			
		||||
@@ -125,6 +127,85 @@ def get_itemprop_availability(html_content) -> Restock:
 | 
			
		||||
    return value
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_price_data_availability_from_filters(html_content, price_change_custom_include_filters) -> Restock:
 | 
			
		||||
    """
 | 
			
		||||
    Extract price using custom CSS/XPath selectors.
 | 
			
		||||
    Reuses apply_include_filters logic from text_json_diff processor.
 | 
			
		||||
 | 
			
		||||
    Args:
 | 
			
		||||
        html_content: The HTML content to parse
 | 
			
		||||
        price_change_custom_include_filters: List of CSS/XPath selectors to extract price
 | 
			
		||||
 | 
			
		||||
    Returns:
 | 
			
		||||
        Restock dict with 'price' key if found
 | 
			
		||||
    """
 | 
			
		||||
    from changedetectionio import html_tools
 | 
			
		||||
    from changedetectionio.processors.magic import guess_stream_type
 | 
			
		||||
 | 
			
		||||
    value = Restock()
 | 
			
		||||
 | 
			
		||||
    if not price_change_custom_include_filters:
 | 
			
		||||
        return value
 | 
			
		||||
 | 
			
		||||
    # Get content type
 | 
			
		||||
    stream_content_type = guess_stream_type(http_content_header='text/html', content=html_content)
 | 
			
		||||
 | 
			
		||||
    # Apply filters to extract price element
 | 
			
		||||
    filtered_content = ""
 | 
			
		||||
 | 
			
		||||
    for filter_rule in price_change_custom_include_filters:
 | 
			
		||||
        # XPath filters
 | 
			
		||||
        if filter_rule[0] == '/' or filter_rule.startswith('xpath:'):
 | 
			
		||||
            filtered_content += html_tools.xpath_filter(
 | 
			
		||||
                xpath_filter=filter_rule.replace('xpath:', ''),
 | 
			
		||||
                html_content=html_content,
 | 
			
		||||
                append_pretty_line_formatting=False,
 | 
			
		||||
                is_rss=stream_content_type.is_rss
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        # XPath1 filters (first match only)
 | 
			
		||||
        elif filter_rule.startswith('xpath1:'):
 | 
			
		||||
            filtered_content += html_tools.xpath1_filter(
 | 
			
		||||
                xpath_filter=filter_rule.replace('xpath1:', ''),
 | 
			
		||||
                html_content=html_content,
 | 
			
		||||
                append_pretty_line_formatting=False,
 | 
			
		||||
                is_rss=stream_content_type.is_rss
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        # CSS selectors, default fallback
 | 
			
		||||
        else:
 | 
			
		||||
            filtered_content += html_tools.include_filters(
 | 
			
		||||
                include_filters=filter_rule,
 | 
			
		||||
                html_content=html_content,
 | 
			
		||||
                append_pretty_line_formatting=False
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
    if filtered_content.strip():
 | 
			
		||||
        # Convert HTML to text
 | 
			
		||||
        import re
 | 
			
		||||
        price_text = re.sub(
 | 
			
		||||
            r'[\r\n\t]+', ' ',
 | 
			
		||||
            html_tools.html_to_text(
 | 
			
		||||
                html_content=filtered_content,
 | 
			
		||||
                render_anchor_tag_content=False,
 | 
			
		||||
                is_rss=False
 | 
			
		||||
            ).strip()
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # Parse the price from text
 | 
			
		||||
        try:
 | 
			
		||||
            parsed_result = value.parse_currency(price_text, normalize_dollar=True)
 | 
			
		||||
            if parsed_result:
 | 
			
		||||
                value['price'] = parsed_result.get('price')
 | 
			
		||||
                if parsed_result.get('currency'):
 | 
			
		||||
                    value['currency'] = parsed_result.get('currency')
 | 
			
		||||
                logger.debug(f"Extracted price from custom selector: {parsed_result.get('price')} {parsed_result.get('currency', '')} (from text: '{price_text}')")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.warning(f"Failed to parse price from '{price_text}': {e}")
 | 
			
		||||
 | 
			
		||||
    return value
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def is_between(number, lower=None, upper=None):
 | 
			
		||||
    """
 | 
			
		||||
    Check if a number is between two values.
 | 
			
		||||
@@ -185,18 +266,32 @@ class perform_site_check(difference_detection_processor):
 | 
			
		||||
                logger.info(f"Watch {watch.get('uuid')} - Tag '{tag.get('title')}' selected for restock settings override")
 | 
			
		||||
                break
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        # if not has custom selector..
 | 
			
		||||
        itemprop_availability = {}
 | 
			
		||||
        try:
 | 
			
		||||
            itemprop_availability = get_itemprop_availability(self.fetcher.content)
 | 
			
		||||
        except MoreThanOnePriceFound as e:
 | 
			
		||||
            # Add the real data
 | 
			
		||||
            raise ProcessorException(message="Cannot run, more than one price detected, this plugin is only for product pages with ONE product, try the content-change detection mode.",
 | 
			
		||||
                                     url=watch.get('url'),
 | 
			
		||||
                                     status_code=self.fetcher.get_last_status_code(),
 | 
			
		||||
                                     screenshot=self.fetcher.screenshot,
 | 
			
		||||
                                     xpath_data=self.fetcher.xpath_data
 | 
			
		||||
                                     )
 | 
			
		||||
        if restock_settings.get('price_change_custom_include_filters'):
 | 
			
		||||
            itemprop_availability = get_price_data_availability_from_filters(html_content=self.fetcher.content,
 | 
			
		||||
                                                                             price_change_custom_include_filters=restock_settings.get(
 | 
			
		||||
                                                                                 'price_change_custom_include_filters')
 | 
			
		||||
                                                                             )
 | 
			
		||||
            if not itemprop_availability or not itemprop_availability.get('price'):
 | 
			
		||||
                raise FilterNotFoundInResponse(
 | 
			
		||||
                    msg=restock_settings.get('price_change_custom_include_filters'),
 | 
			
		||||
                    screenshot=self.fetcher.screenshot,
 | 
			
		||||
                    xpath_data=self.fetcher.xpath_data
 | 
			
		||||
                )
 | 
			
		||||
            
 | 
			
		||||
        else:
 | 
			
		||||
            try:
 | 
			
		||||
                itemprop_availability = get_itemprop_availability(self.fetcher.content)
 | 
			
		||||
            except MoreThanOnePriceFound as e:
 | 
			
		||||
                # Add the real data
 | 
			
		||||
                raise ProcessorException(
 | 
			
		||||
                    message="Cannot run, more than one price detected, this plugin is only for product pages with ONE product, try the content-change detection mode.",
 | 
			
		||||
                    url=watch.get('url'),
 | 
			
		||||
                    status_code=self.fetcher.get_last_status_code(),
 | 
			
		||||
                    screenshot=self.fetcher.screenshot,
 | 
			
		||||
                    xpath_data=self.fetcher.xpath_data
 | 
			
		||||
                    )
 | 
			
		||||
 | 
			
		||||
        # Something valid in get_itemprop_availability() by scraping metadata ?
 | 
			
		||||
        if itemprop_availability.get('price') or itemprop_availability.get('availability'):
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										42
									
								
								changedetectionio/static/js/restock_diff.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								changedetectionio/static/js/restock_diff.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,42 @@
 | 
			
		||||
$(document).ready(function () {
 | 
			
		||||
    // Initialize Visual Selector plugin
 | 
			
		||||
    let visualSelectorAPI = null;
 | 
			
		||||
    if ($('#selector-wrapper').length > 0) {
 | 
			
		||||
        visualSelectorAPI = $('#selector-wrapper').visualSelector({
 | 
			
		||||
            screenshotUrl: screenshot_url,
 | 
			
		||||
            visualSelectorDataUrl: watch_visual_selector_data_url,
 | 
			
		||||
            singleSelectorOnly: true,
 | 
			
		||||
            $includeFiltersElem: $('#restock_settings-price_change_custom_include_filters')
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Function to check and bootstrap visual selector based on hash
 | 
			
		||||
    function checkAndBootstrapVisualSelector() {
 | 
			
		||||
        if (visualSelectorAPI) {
 | 
			
		||||
            if (window.location.hash && window.location.hash.includes('visualselector')) {
 | 
			
		||||
                $('img#selector-background').off('load');
 | 
			
		||||
                visualSelectorAPI.bootstrap();
 | 
			
		||||
            } else {
 | 
			
		||||
                // Shutdown when navigating away from visualselector
 | 
			
		||||
                visualSelectorAPI.shutdown();
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Bootstrap the visual selector when the tab is clicked
 | 
			
		||||
    $('#visualselector-tab').click(function () {
 | 
			
		||||
        if (visualSelectorAPI) {
 | 
			
		||||
            $('img#selector-background').off('load');
 | 
			
		||||
            visualSelectorAPI.bootstrap();
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Check on page load if hash contains 'visualselector'
 | 
			
		||||
    checkAndBootstrapVisualSelector();
 | 
			
		||||
 | 
			
		||||
    // Listen for hash changes (when anchor changes)
 | 
			
		||||
    $(window).on('hashchange', function() {
 | 
			
		||||
        checkAndBootstrapVisualSelector();
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@@ -46,6 +46,44 @@ function request_textpreview_update() {
 | 
			
		||||
 | 
			
		||||
$(document).ready(function () {
 | 
			
		||||
 | 
			
		||||
    // Initialize Visual Selector plugin
 | 
			
		||||
    let visualSelectorAPI = null;
 | 
			
		||||
    if ($('#selector-wrapper').length > 0) {
 | 
			
		||||
        visualSelectorAPI = $('#selector-wrapper').visualSelector({
 | 
			
		||||
            screenshotUrl: screenshot_url,
 | 
			
		||||
            visualSelectorDataUrl: watch_visual_selector_data_url
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        // Function to check and bootstrap visual selector based on hash
 | 
			
		||||
        function checkAndBootstrapVisualSelector() {
 | 
			
		||||
            if (visualSelectorAPI) {
 | 
			
		||||
                if (window.location.hash && window.location.hash.includes('visualselector')) {
 | 
			
		||||
                    $('img#selector-background').off('load');
 | 
			
		||||
                    visualSelectorAPI.bootstrap();
 | 
			
		||||
                } else {
 | 
			
		||||
                    // Shutdown when navigating away from visualselector
 | 
			
		||||
                    visualSelectorAPI.shutdown();
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Bootstrap the visual selector when the tab is clicked
 | 
			
		||||
        $('#visualselector-tab').click(function() {
 | 
			
		||||
            if (visualSelectorAPI) {
 | 
			
		||||
                $('img#selector-background').off('load');
 | 
			
		||||
                visualSelectorAPI.bootstrap();
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        // Check on page load if hash contains 'visualselector'
 | 
			
		||||
        checkAndBootstrapVisualSelector();
 | 
			
		||||
 | 
			
		||||
        // Listen for hash changes (when anchor changes)
 | 
			
		||||
        $(window).on('hashchange', function() {
 | 
			
		||||
            checkAndBootstrapVisualSelector();
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    $('#notification-setting-reset-to-default').click(function (e) {
 | 
			
		||||
        $('#notification_title').val('');
 | 
			
		||||
        $('#notification_body').val('');
 | 
			
		||||
@@ -1,260 +1,357 @@
 | 
			
		||||
// Copyright (C) 2021 Leigh Morresi (dgtlmoon@gmail.com)
 | 
			
		||||
// All rights reserved.
 | 
			
		||||
// yes - this is really a hack, if you are a front-ender and want to help, please get in touch!
 | 
			
		||||
// jQuery plugin for Visual Selector
 | 
			
		||||
 | 
			
		||||
let runInClearMode = false;
 | 
			
		||||
(function($) {
 | 
			
		||||
    'use strict';
 | 
			
		||||
 | 
			
		||||
$(document).ready(() => {
 | 
			
		||||
    let currentSelections = [];
 | 
			
		||||
    let currentSelection = null;
 | 
			
		||||
    let appendToList = false;
 | 
			
		||||
    let c, xctx, ctx;
 | 
			
		||||
    let xScale = 1, yScale = 1;
 | 
			
		||||
    let selectorImage, selectorImageRect, selectorData;
 | 
			
		||||
    // Shared across all plugin instances
 | 
			
		||||
    let runInClearMode = false;
 | 
			
		||||
 | 
			
		||||
    $.fn.visualSelector = function(options) {
 | 
			
		||||
        // Default settings
 | 
			
		||||
        const defaults = {
 | 
			
		||||
            $selectorCanvasElem: $('#selector-canvas'),
 | 
			
		||||
            $includeFiltersElem: $('#include_filters'),
 | 
			
		||||
            $selectorBackgroundElem: $('img#selector-background'),
 | 
			
		||||
            $selectorCurrentXpathElem: $('#selector-current-xpath span'),
 | 
			
		||||
            $selectorCurrentXpathParentElem: $('#selector-current-xpath'),
 | 
			
		||||
            $fetchingUpdateNoticeElem: $('.fetching-update-notice'),
 | 
			
		||||
            $selectorWrapperElem: $('#selector-wrapper'),
 | 
			
		||||
            $visualSelectorHeadingElem: $('#visual-selector-heading'),
 | 
			
		||||
            $clearSelectorElem: $('#clear-selector'),
 | 
			
		||||
            screenshotUrl: window.screenshot_url || '',
 | 
			
		||||
            visualSelectorDataUrl: window.watch_visual_selector_data_url || '',
 | 
			
		||||
            currentSelections: [],
 | 
			
		||||
            singleSelectorOnly: false  // When true, only allows selecting one element (disables Shift+Click multi-select)
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
    // Global jQuery selectors with "Elem" appended
 | 
			
		||||
    const $selectorCanvasElem = $('#selector-canvas');
 | 
			
		||||
    const $includeFiltersElem = $("#include_filters");
 | 
			
		||||
    const $selectorBackgroundElem = $("img#selector-background");
 | 
			
		||||
    const $selectorCurrentXpathElem = $("#selector-current-xpath span");
 | 
			
		||||
    const $fetchingUpdateNoticeElem = $('.fetching-update-notice');
 | 
			
		||||
    const $selectorWrapperElem = $("#selector-wrapper");
 | 
			
		||||
        // Merge options with defaults
 | 
			
		||||
        const settings = $.extend({}, defaults, options);
 | 
			
		||||
 | 
			
		||||
    // Color constants
 | 
			
		||||
    const FILL_STYLE_HIGHLIGHT = 'rgba(205,0,0,0.35)';
 | 
			
		||||
    const FILL_STYLE_GREYED_OUT = 'rgba(205,205,205,0.95)';
 | 
			
		||||
    const STROKE_STYLE_HIGHLIGHT = 'rgba(255,0,0, 0.9)';
 | 
			
		||||
    const FILL_STYLE_REDLINE = 'rgba(255,0,0, 0.1)';
 | 
			
		||||
    const STROKE_STYLE_REDLINE = 'rgba(225,0,0,0.9)';
 | 
			
		||||
        // Extract settings for easier access
 | 
			
		||||
        const $selectorCanvasElem = settings.$selectorCanvasElem;
 | 
			
		||||
        const $includeFiltersElem = settings.$includeFiltersElem;
 | 
			
		||||
        const $selectorBackgroundElem = settings.$selectorBackgroundElem;
 | 
			
		||||
        const $selectorCurrentXpathElem = settings.$selectorCurrentXpathElem;
 | 
			
		||||
        const $selectorCurrentXpathParentElem = settings.$selectorCurrentXpathParentElem;
 | 
			
		||||
        const $fetchingUpdateNoticeElem = settings.$fetchingUpdateNoticeElem;
 | 
			
		||||
        const $selectorWrapperElem = settings.$selectorWrapperElem;
 | 
			
		||||
        const $visualSelectorHeadingElem = settings.$visualSelectorHeadingElem;
 | 
			
		||||
        const $clearSelectorElem = settings.$clearSelectorElem;
 | 
			
		||||
 | 
			
		||||
    $('#visualselector-tab').click(() => {
 | 
			
		||||
        $selectorBackgroundElem.off('load');
 | 
			
		||||
        currentSelections = [];
 | 
			
		||||
        bootstrapVisualSelector();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    function clearReset() {
 | 
			
		||||
        ctx.clearRect(0, 0, c.width, c.height);
 | 
			
		||||
 | 
			
		||||
        if ($includeFiltersElem.val().length) {
 | 
			
		||||
            alert("Existing filters under the 'Filters & Triggers' tab were cleared.");
 | 
			
		||||
        }
 | 
			
		||||
        $includeFiltersElem.val('');
 | 
			
		||||
 | 
			
		||||
        currentSelections = [];
 | 
			
		||||
 | 
			
		||||
        // Means we ignore the xpaths from the scraper marked as sel.highlight_as_custom_filter (it matched a previous selector)
 | 
			
		||||
        runInClearMode = true;
 | 
			
		||||
 | 
			
		||||
        highlightCurrentSelected();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function splitToList(v) {
 | 
			
		||||
        return v.split('\n').map(line => line.trim()).filter(line => line.length > 0);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function sortScrapedElementsBySize() {
 | 
			
		||||
        // Sort the currentSelections array by area (width * height) in descending order
 | 
			
		||||
        selectorData['size_pos'].sort((a, b) => {
 | 
			
		||||
            const areaA = a.width * a.height;
 | 
			
		||||
            const areaB = b.width * b.height;
 | 
			
		||||
            return areaB - areaA;
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    $(document).on('keydown keyup', (event) => {
 | 
			
		||||
        if (event.code === 'ShiftLeft' || event.code === 'ShiftRight') {
 | 
			
		||||
            appendToList = event.type === 'keydown';
 | 
			
		||||
        // Validate required elements exist (supports both textarea and input[type="text"])
 | 
			
		||||
        if (!$includeFiltersElem.length) {
 | 
			
		||||
            console.error('Visual Selector Error: $includeFiltersElem not found. The visual selector requires a valid textarea or input[type="text"] element to write selections to.');
 | 
			
		||||
            console.error('Attempted selector:', settings.$includeFiltersElem.selector || settings.$includeFiltersElem);
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (event.type === 'keydown') {
 | 
			
		||||
            if ($selectorBackgroundElem.is(":visible") && event.key === "Escape") {
 | 
			
		||||
                clearReset();
 | 
			
		||||
        // Verify the element is a textarea or input
 | 
			
		||||
        const elementType = $includeFiltersElem.prop('tagName').toLowerCase();
 | 
			
		||||
        if (elementType !== 'textarea' && elementType !== 'input') {
 | 
			
		||||
            console.error('Visual Selector Error: $includeFiltersElem must be a textarea or input element, found:', elementType);
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Plugin instance state
 | 
			
		||||
        let currentSelections = settings.currentSelections || [];
 | 
			
		||||
        let currentSelection = null;
 | 
			
		||||
        let appendToList = false;
 | 
			
		||||
        let c, xctx, ctx;
 | 
			
		||||
        let xScale = 1, yScale = 1;
 | 
			
		||||
        let selectorImage, selectorImageRect, selectorData;
 | 
			
		||||
 | 
			
		||||
        // Color constants
 | 
			
		||||
        const FILL_STYLE_HIGHLIGHT = 'rgba(205,0,0,0.35)';
 | 
			
		||||
        const FILL_STYLE_GREYED_OUT = 'rgba(205,205,205,0.95)';
 | 
			
		||||
        const STROKE_STYLE_HIGHLIGHT = 'rgba(255,0,0, 0.9)';
 | 
			
		||||
        const FILL_STYLE_REDLINE = 'rgba(255,0,0, 0.1)';
 | 
			
		||||
        const STROKE_STYLE_REDLINE = 'rgba(225,0,0,0.9)';
 | 
			
		||||
 | 
			
		||||
        function clearReset() {
 | 
			
		||||
            ctx.clearRect(0, 0, c.width, c.height);
 | 
			
		||||
 | 
			
		||||
            if ($includeFiltersElem.val().length) {
 | 
			
		||||
                alert("Existing filters under the 'Filters & Triggers' tab were cleared.");
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
            $includeFiltersElem.val('');
 | 
			
		||||
 | 
			
		||||
    $('#clear-selector').on('click', () => {
 | 
			
		||||
        clearReset();
 | 
			
		||||
    });
 | 
			
		||||
    // So if they start switching between visualSelector and manual filters, stop it from rendering old filters
 | 
			
		||||
    $('li.tab a').on('click', () => {
 | 
			
		||||
        runInClearMode = true;
 | 
			
		||||
    });
 | 
			
		||||
            currentSelections = [];
 | 
			
		||||
 | 
			
		||||
    if (!window.location.hash || window.location.hash !== '#visualselector') {
 | 
			
		||||
        $selectorBackgroundElem.attr('src', '');
 | 
			
		||||
        return;
 | 
			
		||||
    }
 | 
			
		||||
            // Means we ignore the xpaths from the scraper marked as sel.highlight_as_custom_filter (it matched a previous selector)
 | 
			
		||||
            runInClearMode = true;
 | 
			
		||||
 | 
			
		||||
    bootstrapVisualSelector();
 | 
			
		||||
 | 
			
		||||
    function bootstrapVisualSelector() {
 | 
			
		||||
        $selectorBackgroundElem
 | 
			
		||||
            .on("error", () => {
 | 
			
		||||
                $fetchingUpdateNoticeElem.html("<strong>Ooops!</strong> The VisualSelector tool needs at least one fetched page, please unpause the watch and/or wait for the watch to complete fetching and then reload this page.")
 | 
			
		||||
                    .css('color', '#bb0000');
 | 
			
		||||
                $('#selector-current-xpath, #clear-selector').hide();
 | 
			
		||||
            })
 | 
			
		||||
            .on('load', () => {
 | 
			
		||||
                console.log("Loaded background...");
 | 
			
		||||
                c = document.getElementById("selector-canvas");
 | 
			
		||||
                xctx = c.getContext("2d");
 | 
			
		||||
                ctx = c.getContext("2d");
 | 
			
		||||
                fetchData();
 | 
			
		||||
                $selectorCanvasElem.off("mousemove mousedown");
 | 
			
		||||
            })
 | 
			
		||||
            .attr("src", screenshot_url);
 | 
			
		||||
 | 
			
		||||
        let s = `${$selectorBackgroundElem.attr('src')}?${new Date().getTime()}`;
 | 
			
		||||
        $selectorBackgroundElem.attr('src', s);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function alertIfFilterNotFound() {
 | 
			
		||||
        let existingFilters = splitToList($includeFiltersElem.val());
 | 
			
		||||
        let sizePosXpaths = selectorData['size_pos'].map(sel => sel.xpath);
 | 
			
		||||
 | 
			
		||||
        for (let filter of existingFilters) {
 | 
			
		||||
            if (!sizePosXpaths.includes(filter)) {
 | 
			
		||||
                alert(`One or more of your existing filters was not found and will be removed when a new filter is selected.`);
 | 
			
		||||
                break;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function fetchData() {
 | 
			
		||||
        $fetchingUpdateNoticeElem.html("Fetching element data..");
 | 
			
		||||
 | 
			
		||||
        $.ajax({
 | 
			
		||||
            url: watch_visual_selector_data_url,
 | 
			
		||||
            context: document.body
 | 
			
		||||
        }).done((data) => {
 | 
			
		||||
            $fetchingUpdateNoticeElem.html("Rendering..");
 | 
			
		||||
            selectorData = data;
 | 
			
		||||
 | 
			
		||||
            sortScrapedElementsBySize();
 | 
			
		||||
            console.log(`Reported browser width from backend: ${data['browser_width']}`);
 | 
			
		||||
 | 
			
		||||
            // Little sanity check for the user, alert them if something missing
 | 
			
		||||
            alertIfFilterNotFound();
 | 
			
		||||
 | 
			
		||||
            setScale();
 | 
			
		||||
            reflowSelector();
 | 
			
		||||
            $fetchingUpdateNoticeElem.fadeOut();
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function updateFiltersText() {
 | 
			
		||||
        // Assuming currentSelections is already defined and contains the selections
 | 
			
		||||
        let uniqueSelections = new Set(currentSelections.map(sel => (sel[0] === '/' ? `xpath:${sel.xpath}` : sel.xpath)));
 | 
			
		||||
 | 
			
		||||
        if (currentSelections.length > 0) {
 | 
			
		||||
            // Convert the Set back to an array and join with newline characters
 | 
			
		||||
            let textboxFilterText = Array.from(uniqueSelections).join("\n");
 | 
			
		||||
            $includeFiltersElem.val(textboxFilterText);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function setScale() {
 | 
			
		||||
        $selectorWrapperElem.show();
 | 
			
		||||
        selectorImage = $selectorBackgroundElem[0];
 | 
			
		||||
        selectorImageRect = selectorImage.getBoundingClientRect();
 | 
			
		||||
 | 
			
		||||
        $selectorCanvasElem.attr({
 | 
			
		||||
            'height': selectorImageRect.height,
 | 
			
		||||
            'width': selectorImageRect.width
 | 
			
		||||
        });
 | 
			
		||||
        $selectorWrapperElem.attr('width', selectorImageRect.width);
 | 
			
		||||
        $('#visual-selector-heading').css('max-width', selectorImageRect.width + "px")
 | 
			
		||||
 | 
			
		||||
        xScale = selectorImageRect.width / selectorImage.naturalWidth;
 | 
			
		||||
        yScale = selectorImageRect.height / selectorImage.naturalHeight;
 | 
			
		||||
 | 
			
		||||
        ctx.strokeStyle = STROKE_STYLE_HIGHLIGHT;
 | 
			
		||||
        ctx.fillStyle = FILL_STYLE_REDLINE;
 | 
			
		||||
        ctx.lineWidth = 3;
 | 
			
		||||
        console.log("Scaling set  x: " + xScale + " by y:" + yScale);
 | 
			
		||||
        $("#selector-current-xpath").css('max-width', selectorImageRect.width);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function reflowSelector() {
 | 
			
		||||
        $(window).resize(() => {
 | 
			
		||||
            setScale();
 | 
			
		||||
            highlightCurrentSelected();
 | 
			
		||||
        });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        setScale();
 | 
			
		||||
        function splitToList(v) {
 | 
			
		||||
            return v.split('\n').map(line => line.trim()).filter(line => line.length > 0);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        console.log(selectorData['size_pos'].length + " selectors found");
 | 
			
		||||
        function sortScrapedElementsBySize() {
 | 
			
		||||
            // Sort the currentSelections array by area (width * height) in descending order
 | 
			
		||||
            selectorData['size_pos'].sort((a, b) => {
 | 
			
		||||
                const areaA = a.width * a.height;
 | 
			
		||||
                const areaB = b.width * b.height;
 | 
			
		||||
                return areaB - areaA;
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let existingFilters = splitToList($includeFiltersElem.val());
 | 
			
		||||
        function alertIfFilterNotFound() {
 | 
			
		||||
            let existingFilters = splitToList($includeFiltersElem.val());
 | 
			
		||||
            let sizePosXpaths = selectorData['size_pos'].map(sel => sel.xpath);
 | 
			
		||||
 | 
			
		||||
        selectorData['size_pos'].forEach(sel => {
 | 
			
		||||
            if ((!runInClearMode && sel.highlight_as_custom_filter) || existingFilters.includes(sel.xpath)) {
 | 
			
		||||
                console.log("highlighting " + c);
 | 
			
		||||
                currentSelections.push(sel);
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        highlightCurrentSelected();
 | 
			
		||||
        updateFiltersText();
 | 
			
		||||
 | 
			
		||||
        $selectorCanvasElem.bind('mousemove', handleMouseMove.debounce(5));
 | 
			
		||||
        $selectorCanvasElem.bind('mousedown', handleMouseDown.debounce(5));
 | 
			
		||||
        $selectorCanvasElem.bind('mouseleave', highlightCurrentSelected.debounce(5));
 | 
			
		||||
 | 
			
		||||
        function handleMouseMove(e) {
 | 
			
		||||
            if (!e.offsetX && !e.offsetY) {
 | 
			
		||||
                const targetOffset = $(e.target).offset();
 | 
			
		||||
                e.offsetX = e.pageX - targetOffset.left;
 | 
			
		||||
                e.offsetY = e.pageY - targetOffset.top;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            ctx.fillStyle = FILL_STYLE_HIGHLIGHT;
 | 
			
		||||
 | 
			
		||||
            selectorData['size_pos'].forEach(sel => {
 | 
			
		||||
                if (e.offsetY > sel.top * yScale && e.offsetY < sel.top * yScale + sel.height * yScale &&
 | 
			
		||||
                    e.offsetX > sel.left * yScale && e.offsetX < sel.left * yScale + sel.width * yScale) {
 | 
			
		||||
                    setCurrentSelectedText(sel.xpath);
 | 
			
		||||
                    drawHighlight(sel);
 | 
			
		||||
                    currentSelections.push(sel);
 | 
			
		||||
                    currentSelection = sel;
 | 
			
		||||
                    highlightCurrentSelected();
 | 
			
		||||
                    currentSelections.pop();
 | 
			
		||||
            for (let filter of existingFilters) {
 | 
			
		||||
                if (!sizePosXpaths.includes(filter)) {
 | 
			
		||||
                    alert(`One or more of your existing filters was not found and will be removed when a new filter is selected.`);
 | 
			
		||||
                    break;
 | 
			
		||||
                }
 | 
			
		||||
            })
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        function fetchData() {
 | 
			
		||||
            $fetchingUpdateNoticeElem.html("Fetching element data..");
 | 
			
		||||
 | 
			
		||||
        function setCurrentSelectedText(s) {
 | 
			
		||||
            $selectorCurrentXpathElem[0].innerHTML = s;
 | 
			
		||||
            $.ajax({
 | 
			
		||||
                url: settings.visualSelectorDataUrl,
 | 
			
		||||
                context: document.body
 | 
			
		||||
            }).done((data) => {
 | 
			
		||||
                $fetchingUpdateNoticeElem.html("Rendering..");
 | 
			
		||||
                selectorData = data;
 | 
			
		||||
 | 
			
		||||
                sortScrapedElementsBySize();
 | 
			
		||||
                console.log(`Reported browser width from backend: ${data['browser_width']}`);
 | 
			
		||||
 | 
			
		||||
                // Little sanity check for the user, alert them if something missing
 | 
			
		||||
                alertIfFilterNotFound();
 | 
			
		||||
 | 
			
		||||
                setScale();
 | 
			
		||||
                reflowSelector();
 | 
			
		||||
                $fetchingUpdateNoticeElem.fadeOut();
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        function drawHighlight(sel) {
 | 
			
		||||
            ctx.strokeRect(sel.left * xScale, sel.top * yScale, sel.width * xScale, sel.height * yScale);
 | 
			
		||||
            ctx.fillRect(sel.left * xScale, sel.top * yScale, sel.width * xScale, sel.height * yScale);
 | 
			
		||||
        function updateFiltersText() {
 | 
			
		||||
            // Assuming currentSelections is already defined and contains the selections
 | 
			
		||||
            let uniqueSelections = new Set(currentSelections.map(sel => (sel[0] === '/' ? `xpath:${sel.xpath}` : sel.xpath)));
 | 
			
		||||
 | 
			
		||||
            if (currentSelections.length > 0) {
 | 
			
		||||
                // Convert the Set back to an array and join with newline characters
 | 
			
		||||
                let textboxFilterText = Array.from(uniqueSelections).join("\n");
 | 
			
		||||
                $includeFiltersElem.val(textboxFilterText);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        function handleMouseDown() {
 | 
			
		||||
            // If we are in 'appendToList' mode, grow the list, if not, just 1
 | 
			
		||||
            currentSelections = appendToList ? [...currentSelections, currentSelection] : [currentSelection];
 | 
			
		||||
        function setScale() {
 | 
			
		||||
            $selectorWrapperElem.show();
 | 
			
		||||
            selectorImage = $selectorBackgroundElem[0];
 | 
			
		||||
            selectorImageRect = selectorImage.getBoundingClientRect();
 | 
			
		||||
 | 
			
		||||
            $selectorCanvasElem.attr({
 | 
			
		||||
                'height': selectorImageRect.height,
 | 
			
		||||
                'width': selectorImageRect.width
 | 
			
		||||
            });
 | 
			
		||||
            $selectorWrapperElem.attr('width', selectorImageRect.width);
 | 
			
		||||
            $visualSelectorHeadingElem.css('max-width', selectorImageRect.width + "px")
 | 
			
		||||
 | 
			
		||||
            xScale = selectorImageRect.width / selectorImage.naturalWidth;
 | 
			
		||||
            yScale = selectorImageRect.height / selectorImage.naturalHeight;
 | 
			
		||||
 | 
			
		||||
            ctx.strokeStyle = STROKE_STYLE_HIGHLIGHT;
 | 
			
		||||
            ctx.fillStyle = FILL_STYLE_REDLINE;
 | 
			
		||||
            ctx.lineWidth = 3;
 | 
			
		||||
            console.log("Scaling set  x: " + xScale + " by y:" + yScale);
 | 
			
		||||
            $selectorCurrentXpathParentElem.css('max-width', selectorImageRect.width);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        function reflowSelector() {
 | 
			
		||||
            $(window).resize(() => {
 | 
			
		||||
                setScale();
 | 
			
		||||
                highlightCurrentSelected();
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            setScale();
 | 
			
		||||
 | 
			
		||||
            console.log(selectorData['size_pos'].length + " selectors found");
 | 
			
		||||
 | 
			
		||||
            let existingFilters = splitToList($includeFiltersElem.val());
 | 
			
		||||
 | 
			
		||||
            // In singleSelectorOnly mode, only load the first existing filter
 | 
			
		||||
            if (settings.singleSelectorOnly && existingFilters.length > 1) {
 | 
			
		||||
                existingFilters = [existingFilters[0]];
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            for (let sel of selectorData['size_pos']) {
 | 
			
		||||
                if ((!runInClearMode && sel.highlight_as_custom_filter) || existingFilters.includes(sel.xpath)) {
 | 
			
		||||
                    console.log("highlighting " + sel.xpath);
 | 
			
		||||
                    currentSelections.push(sel);
 | 
			
		||||
                    // In singleSelectorOnly mode, stop after finding the first match
 | 
			
		||||
                    if (settings.singleSelectorOnly) {
 | 
			
		||||
                        break;
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
            highlightCurrentSelected();
 | 
			
		||||
            updateFiltersText();
 | 
			
		||||
 | 
			
		||||
            $selectorCanvasElem.bind('mousemove', handleMouseMove.debounce(5));
 | 
			
		||||
            $selectorCanvasElem.bind('mousedown', handleMouseDown.debounce(5));
 | 
			
		||||
            $selectorCanvasElem.bind('mouseleave', highlightCurrentSelected.debounce(5));
 | 
			
		||||
 | 
			
		||||
            function handleMouseMove(e) {
 | 
			
		||||
                if (!e.offsetX && !e.offsetY) {
 | 
			
		||||
                    const targetOffset = $(e.target).offset();
 | 
			
		||||
                    e.offsetX = e.pageX - targetOffset.left;
 | 
			
		||||
                    e.offsetY = e.pageY - targetOffset.top;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                ctx.fillStyle = FILL_STYLE_HIGHLIGHT;
 | 
			
		||||
 | 
			
		||||
                selectorData['size_pos'].forEach(sel => {
 | 
			
		||||
                    if (e.offsetY > sel.top * yScale && e.offsetY < sel.top * yScale + sel.height * yScale &&
 | 
			
		||||
                        e.offsetX > sel.left * yScale && e.offsetX < sel.left * yScale + sel.width * yScale) {
 | 
			
		||||
                        setCurrentSelectedText(sel.xpath);
 | 
			
		||||
                        drawHighlight(sel);
 | 
			
		||||
                        currentSelections.push(sel);
 | 
			
		||||
                        currentSelection = sel;
 | 
			
		||||
                        highlightCurrentSelected();
 | 
			
		||||
                        currentSelections.pop();
 | 
			
		||||
                    }
 | 
			
		||||
                })
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
            function setCurrentSelectedText(s) {
 | 
			
		||||
                $selectorCurrentXpathElem[0].innerHTML = s;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            function drawHighlight(sel) {
 | 
			
		||||
                ctx.strokeRect(sel.left * xScale, sel.top * yScale, sel.width * xScale, sel.height * yScale);
 | 
			
		||||
                ctx.fillRect(sel.left * xScale, sel.top * yScale, sel.width * xScale, sel.height * yScale);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            function handleMouseDown() {
 | 
			
		||||
                // In singleSelectorOnly mode, always use single selection (ignore appendToList/Shift)
 | 
			
		||||
                if (settings.singleSelectorOnly) {
 | 
			
		||||
                    currentSelections = [currentSelection];
 | 
			
		||||
                } else {
 | 
			
		||||
                    // If we are in 'appendToList' mode, grow the list, if not, just 1
 | 
			
		||||
                    currentSelections = appendToList ? [...currentSelections, currentSelection] : [currentSelection];
 | 
			
		||||
                }
 | 
			
		||||
                highlightCurrentSelected();
 | 
			
		||||
                updateFiltersText();
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    }
 | 
			
		||||
        function highlightCurrentSelected() {
 | 
			
		||||
            xctx.fillStyle = FILL_STYLE_GREYED_OUT;
 | 
			
		||||
            xctx.strokeStyle = STROKE_STYLE_REDLINE;
 | 
			
		||||
            xctx.lineWidth = 3;
 | 
			
		||||
            xctx.clearRect(0, 0, c.width, c.height);
 | 
			
		||||
 | 
			
		||||
    function highlightCurrentSelected() {
 | 
			
		||||
        xctx.fillStyle = FILL_STYLE_GREYED_OUT;
 | 
			
		||||
        xctx.strokeStyle = STROKE_STYLE_REDLINE;
 | 
			
		||||
        xctx.lineWidth = 3;
 | 
			
		||||
        xctx.clearRect(0, 0, c.width, c.height);
 | 
			
		||||
            currentSelections.forEach(sel => {
 | 
			
		||||
                //xctx.clearRect(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);
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        currentSelections.forEach(sel => {
 | 
			
		||||
            //xctx.clearRect(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);
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
});
 | 
			
		||||
        function bootstrapVisualSelector() {
 | 
			
		||||
            $selectorBackgroundElem
 | 
			
		||||
                .on("error", (d) => {
 | 
			
		||||
                    console.error(d)
 | 
			
		||||
                    $fetchingUpdateNoticeElem.html("<strong>Ooops!</strong> The VisualSelector tool needs at least one fetched page, please unpause the watch and/or wait for the watch to complete fetching and then reload this page.")
 | 
			
		||||
                        .css('color', '#bb0000');
 | 
			
		||||
                    $selectorCurrentXpathParentElem.hide();
 | 
			
		||||
                    $clearSelectorElem.hide();
 | 
			
		||||
                })
 | 
			
		||||
                .on('load', () => {
 | 
			
		||||
                    console.log("Loaded background...");
 | 
			
		||||
                    c = document.getElementById("selector-canvas");
 | 
			
		||||
                    xctx = c.getContext("2d");
 | 
			
		||||
                    ctx = c.getContext("2d");
 | 
			
		||||
                    fetchData();
 | 
			
		||||
                    $selectorCanvasElem.off("mousemove mousedown");
 | 
			
		||||
                });
 | 
			
		||||
 | 
			
		||||
            // Set the src with cache-busting timestamp
 | 
			
		||||
            let s = `${settings.screenshotUrl}?${new Date().getTime()}`;
 | 
			
		||||
            console.log(s);
 | 
			
		||||
            $selectorBackgroundElem.attr('src', s);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Set up global event handlers (these run once on initialization)
 | 
			
		||||
        function initializeEventHandlers() {
 | 
			
		||||
            $(document).on('keydown.visualSelector keyup.visualSelector', (event) => {
 | 
			
		||||
                // Only enable shift+click multi-select if singleSelectorOnly is false
 | 
			
		||||
                if (!settings.singleSelectorOnly && (event.code === 'ShiftLeft' || event.code === 'ShiftRight')) {
 | 
			
		||||
                    appendToList = event.type === 'keydown';
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                if (event.type === 'keydown') {
 | 
			
		||||
                    if ($selectorBackgroundElem.is(":visible") && event.key === "Escape") {
 | 
			
		||||
                        clearReset();
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            $clearSelectorElem.on('click.visualSelector', () => {
 | 
			
		||||
                clearReset();
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            // So if they start switching between visualSelector and manual filters, stop it from rendering old filters
 | 
			
		||||
            $('li.tab a').on('click.visualSelector', () => {
 | 
			
		||||
                runInClearMode = true;
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Initialize event handlers
 | 
			
		||||
        initializeEventHandlers();
 | 
			
		||||
 | 
			
		||||
        // Return public API
 | 
			
		||||
        return {
 | 
			
		||||
            bootstrap: function() {
 | 
			
		||||
                currentSelections = [];
 | 
			
		||||
                runInClearMode = false;
 | 
			
		||||
                bootstrapVisualSelector();
 | 
			
		||||
            },
 | 
			
		||||
            shutdown: function() {
 | 
			
		||||
                // Clear the background image and canvas when navigating away
 | 
			
		||||
                $selectorBackgroundElem.attr('src', '');
 | 
			
		||||
                if (c && ctx) {
 | 
			
		||||
                    ctx.clearRect(0, 0, c.width, c.height);
 | 
			
		||||
                }
 | 
			
		||||
                if (c && xctx) {
 | 
			
		||||
                    xctx.clearRect(0, 0, c.width, c.height);
 | 
			
		||||
                }
 | 
			
		||||
                // Unbind mouse events on canvas
 | 
			
		||||
                $selectorCanvasElem.off('mousemove mousedown mouseleave');
 | 
			
		||||
                // Unbind background image events
 | 
			
		||||
                $selectorBackgroundElem.off('load error');
 | 
			
		||||
            },
 | 
			
		||||
            clear: function() {
 | 
			
		||||
                clearReset();
 | 
			
		||||
            },
 | 
			
		||||
            destroy: function() {
 | 
			
		||||
                // Clean up event handlers
 | 
			
		||||
                $(document).off('.visualSelector');
 | 
			
		||||
                $clearSelectorElem.off('.visualSelector');
 | 
			
		||||
                $('li.tab a').off('.visualSelector');
 | 
			
		||||
                $selectorCanvasElem.off('mousemove mousedown mouseleave');
 | 
			
		||||
                $(window).off('resize');
 | 
			
		||||
            },
 | 
			
		||||
            getCurrentSelections: function() {
 | 
			
		||||
                return currentSelections;
 | 
			
		||||
            },
 | 
			
		||||
            setCurrentSelections: function(selections) {
 | 
			
		||||
                currentSelections = selections;
 | 
			
		||||
                highlightCurrentSelected();
 | 
			
		||||
                updateFiltersText();
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
})(jQuery);
 | 
			
		||||
 
 | 
			
		||||
@@ -105,6 +105,8 @@ extruct
 | 
			
		||||
 | 
			
		||||
# For cleaning up unknown currency formats
 | 
			
		||||
babel
 | 
			
		||||
# For normalizing currency symbols to ISO 4217 codes
 | 
			
		||||
iso4217parse
 | 
			
		||||
 | 
			
		||||
levenshtein
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user