diff --git a/changedetectionio/blueprint/ui/templates/edit.html b/changedetectionio/blueprint/ui/templates/edit.html index f6e7f6a086d..4ff7f5ae4b9 100644 --- a/changedetectionio/blueprint/ui/templates/edit.html +++ b/changedetectionio/blueprint/ui/templates/edit.html @@ -29,7 +29,7 @@ const default_system_fetch_backend="{{ settings_application['fetch_backend'] }}"; - + {% if playwright_enabled %} @@ -50,8 +50,10 @@ {% endif %}
  • Browser Steps
  • - {% if watch['processor'] == 'text_json_diff' %} + {% if watch['processor'] == 'text_json_diff' or watch['processor'] == 'restock_diff' %}
  • Visual Filter Selector
  • + {% endif %} + {% if watch['processor'] == 'text_json_diff' %}
  • Filters & Triggers
  • Conditions
  • {% endif %} @@ -377,7 +379,7 @@

    Text filtering

    {{ extra_form_content|safe }} {% endif %} - {% if watch['processor'] == 'text_json_diff' %} + {% if watch['processor'] == 'text_json_diff' or watch['processor'] == 'restock_diff' %}
    diff --git a/changedetectionio/model/__init__.py b/changedetectionio/model/__init__.py index 222dafc7014..46500f1ebf4 100644 --- a/changedetectionio/model/__init__.py +++ b/changedetectionio/model/__init__.py @@ -55,6 +55,7 @@ def __init__(self, *arg, **kw): '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, diff --git a/changedetectionio/processors/restock_diff/__init__.py b/changedetectionio/processors/restock_diff/__init__.py index 3d472beece0..70d8cf08598 100644 --- a/changedetectionio/processors/restock_diff/__init__.py +++ b/changedetectionio/processors/restock_diff/__init__.py @@ -6,8 +6,69 @@ 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 @@ def parse_currency(self, raw_value: str) -> Union[float, None]: 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 @@ def __setitem__(self, key, value): # 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) diff --git a/changedetectionio/processors/restock_diff/forms.py b/changedetectionio/processors/restock_diff/forms.py index 39334aa3c71..fe6851e38e7 100644 --- a/changedetectionio/processors/restock_diff/forms.py +++ b/changedetectionio/processors/restock_diff/forms.py @@ -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 @@ def extra_form_content(self): {{ render_field(form.restock_settings.price_change_threshold_percent) }} Price must change more than this % to trigger a change since the first check.
    For example, If the product is $1,000 USD originally, 2% would mean it has to change more than $20 since the first check.
    - + +
    + {{ render_field(form.restock_settings.price_change_custom_include_filters) }} + Override the automatic price metadata reader with this custom select from the Visual Selector, in the case that the automatic detection was incorrect.
    +
    """ diff --git a/changedetectionio/processors/restock_diff/processor.py b/changedetectionio/processors/restock_diff/processor.py index 1fa81058caa..42a0036acbb 100644 --- a/changedetectionio/processors/restock_diff/processor.py +++ b/changedetectionio/processors/restock_diff/processor.py @@ -6,6 +6,8 @@ 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 @@ def run_changedetection(self, watch): 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'): diff --git a/changedetectionio/static/js/restock_diff.js b/changedetectionio/static/js/restock_diff.js new file mode 100644 index 00000000000..25d92d82a01 --- /dev/null +++ b/changedetectionio/static/js/restock_diff.js @@ -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(); + }); +}); + diff --git a/changedetectionio/static/js/watch-settings.js b/changedetectionio/static/js/text_json_diff.js similarity index 69% rename from changedetectionio/static/js/watch-settings.js rename to changedetectionio/static/js/text_json_diff.js index 4d805340139..5e4e91ac6ed 100644 --- a/changedetectionio/static/js/watch-settings.js +++ b/changedetectionio/static/js/text_json_diff.js @@ -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(''); diff --git a/changedetectionio/static/js/visual-selector.js b/changedetectionio/static/js/visual-selector.js index f6f8e79c2f3..ffd5a34a9ba 100644 --- a/changedetectionio/static/js/visual-selector.js +++ b/changedetectionio/static/js/visual-selector.js @@ -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! - -let runInClearMode = false; - -$(document).ready(() => { - let currentSelections = []; - let currentSelection = null; - let appendToList = false; - let c, xctx, ctx; - let xScale = 1, yScale = 1; - let selectorImage, selectorImageRect, selectorData; - - - // 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"); - - // 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)'; - - $('#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."); +// jQuery plugin for Visual Selector + +(function($) { + 'use strict'; + + // 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) + }; + + // Merge options with defaults + const settings = $.extend({}, defaults, options); + + // 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; + + // 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; } - $includeFiltersElem.val(''); - currentSelections = []; + // 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; + } - // Means we ignore the xpaths from the scraper marked as sel.highlight_as_custom_filter (it matched a previous selector) - runInClearMode = true; + // 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(''); - highlightCurrentSelected(); - } + currentSelections = []; - function splitToList(v) { - return v.split('\n').map(line => line.trim()).filter(line => line.length > 0); - } + // Means we ignore the xpaths from the scraper marked as sel.highlight_as_custom_filter (it matched a previous selector) + runInClearMode = true; - 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; - }); - } + highlightCurrentSelected(); + } - $(document).on('keydown keyup', (event) => { - if (event.code === 'ShiftLeft' || event.code === 'ShiftRight') { - appendToList = event.type === 'keydown'; + function splitToList(v) { + return v.split('\n').map(line => line.trim()).filter(line => line.length > 0); } - if (event.type === 'keydown') { - if ($selectorBackgroundElem.is(":visible") && event.key === "Escape") { - clearReset(); - } + 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; + }); } - }); - - $('#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; - }); - - if (!window.location.hash || window.location.hash !== '#visualselector') { - $selectorBackgroundElem.attr('src', ''); - return; - } - - bootstrapVisualSelector(); - - function bootstrapVisualSelector() { - $selectorBackgroundElem - .on("error", () => { - $fetchingUpdateNoticeElem.html("Ooops! 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 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.."); + function fetchData() { + $fetchingUpdateNoticeElem.html("Fetching element data.."); - $.ajax({ - url: watch_visual_selector_data_url, - context: document.body - }).done((data) => { - $fetchingUpdateNoticeElem.html("Rendering.."); - selectorData = data; + $.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']}`); + sortScrapedElementsBySize(); + console.log(`Reported browser width from backend: ${data['browser_width']}`); - // Little sanity check for the user, alert them if something missing - alertIfFilterNotFound(); + // 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); + setScale(); + reflowSelector(); + $fetchingUpdateNoticeElem.fadeOut(); + }); } - } - - 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(() => { + + 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); + $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(); - highlightCurrentSelected(); - }); - setScale(); + console.log(selectorData['size_pos'].length + " selectors found"); - console.log(selectorData['size_pos'].length + " selectors found"); + let existingFilters = splitToList($includeFiltersElem.val()); - let existingFilters = splitToList($includeFiltersElem.val()); + // In singleSelectorOnly mode, only load the first existing filter + if (settings.singleSelectorOnly && existingFilters.length > 1) { + existingFilters = [existingFilters[0]]; + } - selectorData['size_pos'].forEach(sel => { - if ((!runInClearMode && sel.highlight_as_custom_filter) || existingFilters.includes(sel.xpath)) { - console.log("highlighting " + c); - currentSelections.push(sel); + 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(); + highlightCurrentSelected(); + updateFiltersText(); + + $selectorCanvasElem.bind('mousemove', handleMouseMove.debounce(5)); + $selectorCanvasElem.bind('mousedown', handleMouseDown.debounce(5)); + $selectorCanvasElem.bind('mouseleave', highlightCurrentSelected.debounce(5)); - $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; + } - 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(); + } + }) } - 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 setCurrentSelectedText(s) { - $selectorCurrentXpathElem[0].innerHTML = s; + 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 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 bootstrapVisualSelector() { + $selectorBackgroundElem + .on("error", (d) => { + console.error(d) + $fetchingUpdateNoticeElem.html("Ooops! 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); } - function handleMouseDown() { - // If we are in 'appendToList' mode, grow the list, if not, just 1 - currentSelections = appendToList ? [...currentSelections, currentSelection] : [currentSelection]; - highlightCurrentSelected(); - updateFiltersText(); - } + // 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(); + }); - function highlightCurrentSelected() { - xctx.fillStyle = FILL_STYLE_GREYED_OUT; - xctx.strokeStyle = STROKE_STYLE_REDLINE; - xctx.lineWidth = 3; - xctx.clearRect(0, 0, c.width, c.height); + // 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(); + } + }; + }; - 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); - }); - } -}); \ No newline at end of file +})(jQuery); diff --git a/requirements.txt b/requirements.txt index d8eb4a02b2a..b6cfc780a6e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -105,6 +105,8 @@ extruct # For cleaning up unknown currency formats babel +# For normalizing currency symbols to ISO 4217 codes +iso4217parse levenshtein