Compare commits

...

2 Commits

Author SHA1 Message Date
dgtlmoon
641300578b Fix template discovery path
Some checks are pending
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Waiting to run
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Blocked by required conditions
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Blocked by required conditions
ChangeDetection.io App Test / lint-code (push) Waiting to run
ChangeDetection.io App Test / test-application-3-10 (push) Blocked by required conditions
ChangeDetection.io App Test / test-application-3-11 (push) Blocked by required conditions
ChangeDetection.io App Test / test-application-3-12 (push) Blocked by required conditions
ChangeDetection.io App Test / test-application-3-13 (push) Blocked by required conditions
2026-01-02 14:49:06 +01:00
dgtlmoon
4c90fd185f Translations support 2026-01-02 13:47:46 +01:00
42 changed files with 12534 additions and 455 deletions

View File

@@ -113,6 +113,9 @@ EXPOSE 5000
# The actual flask app module
COPY changedetectionio /app/changedetectionio
# Compile translation files for i18n support
RUN pybabel compile -d /app/changedetectionio/translations
# Also for OpenAPI validation wrapper - needs the YML
RUN [ ! -d "/app/docs" ] && mkdir /app/docs
COPY docs/api-spec.yaml /app/docs/api-spec.yaml

View File

@@ -3,31 +3,31 @@
{% from '_helpers.html' import render_simple_field, render_field %}
<div class="edit-form">
<div class="box-wrap inner">
<h4>Backups</h4>
<h4>{{ _('Backups') }}</h4>
{% if backup_running %}
<p>
<strong>A backup is running!</strong>
<strong>{{ _('A backup is running!') }}</strong>
</p>
{% endif %}
<p>
Here you can download and request a new backup, when a backup is completed you will see it listed below.
{{ _('Here you can download and request a new backup, when a backup is completed you will see it listed below.') }}
</p>
<br>
{% if available_backups %}
<ul>
{% for backup in available_backups %}
<li><a href="{{ url_for('backups.download_backup', filename=backup["filename"]) }}">{{ backup["filename"] }}</a> {{ backup["filesize"] }} Mb</li>
<li><a href="{{ url_for('backups.download_backup', filename=backup["filename"]) }}">{{ backup["filename"] }}</a> {{ backup["filesize"] }} {{ _('Mb') }}</li>
{% endfor %}
</ul>
{% else %}
<p>
<strong>No backups found.</strong>
<strong>{{ _('No backups found.') }}</strong>
</p>
{% endif %}
<a class="pure-button pure-button-primary" href="{{ url_for('backups.request_backup') }}">Create backup</a>
<a class="pure-button pure-button-primary" href="{{ url_for('backups.request_backup') }}">{{ _('Create backup') }}</a>
{% if available_backups %}
<a class="pure-button button-small button-error " href="{{ url_for('backups.remove_backups') }}">Remove backups</a>
<a class="pure-button button-small button-error " href="{{ url_for('backups.remove_backups') }}">{{ _('Remove backups') }}</a>
{% endif %}
</div>
</div>

View File

@@ -6,9 +6,9 @@
<div class="tabs collapsable">
<ul>
<li class="tab" id=""><a href="#url-list">URL List</a></li>
<li class="tab"><a href="#distill-io">Distill.io</a></li>
<li class="tab"><a href="#xlsx">.XLSX &amp; Wachete</a></li>
<li class="tab" id=""><a href="#url-list">{{ _('URL List') }}</a></li>
<li class="tab"><a href="#distill-io">{{ _('Distill.io') }}</a></li>
<li class="tab"><a href="#xlsx">{{ _('.XLSX & Wachete') }}</a></li>
</ul>
</div>
@@ -17,11 +17,10 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="tab-pane-inner" id="url-list">
<div class="pure-control-group">
Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma
(,):
{{ _('Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):') }}
<br>
<p><strong>Example: </strong><code>https://example.com tag1, tag2, last tag</code></p>
URLs which do not pass validation will stay in the textarea.
<p><strong>{{ _('Example:') }} </strong><code>https://example.com tag1, tag2, last tag</code></p>
{{ _('URLs which do not pass validation will stay in the textarea.') }}
</div>
{{ render_field(form.processor, class="processor") }}
@@ -42,12 +41,12 @@
<div class="pure-control-group">
Copy and Paste your Distill.io watch 'export' file, this should be a JSON file.<br>
This is <i>experimental</i>, supported fields are <code>name</code>, <code>uri</code>, <code>tags</code>, <code>config:selections</code>, the rest (including <code>schedule</code>) are ignored.
{{ _('Copy and Paste your Distill.io watch \'export\' file, this should be a JSON file.') }}<br>
{{ _('This is') }} <i>{{ _('experimental') }}</i>, {{ _('supported fields are') }} <code>name</code>, <code>uri</code>, <code>tags</code>, <code>config:selections</code>, {{ _('the rest (including') }} <code>schedule</code>) {{ _('are ignored.') }}
<br>
<p>
How to export? <a href="https://distill.io/docs/web-monitor/how-export-and-import-monitors/">https://distill.io/docs/web-monitor/how-export-and-import-monitors/</a><br>
Be sure to set your default fetcher to Chrome if required.<br>
{{ _('How to export?') }} <a href="https://distill.io/docs/web-monitor/how-export-and-import-monitors/">https://distill.io/docs/web-monitor/how-export-and-import-monitors/</a><br>
{{ _('Be sure to set your default fetcher to Chrome if required.') }}<br>
</p>
</div>
@@ -89,32 +88,32 @@
</fieldset>
<div class="pure-control-group">
<span class="pure-form-message-inline">
Table of custom column and data types mapping for the <strong>Custom mapping</strong> File mapping type.
{{ _('Table of custom column and data types mapping for the') }} <strong>{{ _('Custom mapping') }}</strong> {{ _('File mapping type.') }}
</span>
<table style="border: 1px solid #aaa; padding: 0.5rem; border-radius: 4px;">
<tr>
<td><strong>Column #</strong></td>
<td><strong>{{ _('Column #') }}</strong></td>
{% for n in range(4) %}
<td><input type="number" name="custom_xlsx[col_{{n}}]" style="width: 4rem;" min="1"></td>
{% endfor %}
</tr>
<tr>
<td><strong>Type</strong></td>
<td><strong>{{ _('Type') }}</strong></td>
{% for n in range(4) %}
<td><select name="custom_xlsx[col_type_{{n}}]">
<option value="" style="color: #aaa"> -- none --</option>
<option value="url">URL</option>
<option value="title">Title</option>
<option value="include_filters">CSS/xPath filter</option>
<option value="tag">Group / Tag name(s)</option>
<option value="interval_minutes">Recheck time (minutes)</option>
<option value="" style="color: #aaa"> -- {{ _('none') }} --</option>
<option value="url">{{ _('URL') }}</option>
<option value="title">{{ _('Title') }}</option>
<option value="include_filters">{{ _('CSS/xPath filter') }}</option>
<option value="tag">{{ _('Group / Tag name(s)') }}</option>
<option value="interval_minutes">{{ _('Recheck time (minutes)') }}</option>
</select></td>
{% endfor %}
</tr>
</table>
</div>
</div>
<button type="submit" class="pure-button pure-input-1-2 pure-button-primary">Import</button>
<button type="submit" class="pure-button pure-input-1-2 pure-button-primary">{{ _('Import') }}</button>
</form>
</div>

View File

@@ -4,7 +4,7 @@
<div class="edit-form">
<div class="inner">
<h4 style="margin-top: 0px;">Notification debug log</h4>
<h4 style="margin-top: 0px;">{{ _('Notification debug log') }}</h4>
<div id="notification-error-log">
<ul style="font-size: 80%; margin:0px; padding: 0 0 0 7px">
{% for log in logs|reverse %}

View File

@@ -18,21 +18,21 @@
<div class="edit-form">
<div class="tabs collapsable">
<ul>
<li class="tab" id=""><a href="#general">General</a></li>
<li class="tab"><a href="#notifications">Notifications</a></li>
<li class="tab"><a href="#fetching">Fetching</a></li>
<li class="tab"><a href="#filters">Global Filters</a></li>
<li class="tab"><a href="#ui-options">UI Options</a></li>
<li class="tab"><a href="#api">API</a></li>
<li class="tab"><a href="#rss">RSS</a></li>
<li class="tab"><a href="#timedate">Time &amp Date</a></li>
<li class="tab"><a href="#proxies">CAPTCHA &amp; Proxies</a></li>
<li class="tab" id=""><a href="#general">{{ _('General') }}</a></li>
<li class="tab"><a href="#notifications">{{ _('Notifications') }}</a></li>
<li class="tab"><a href="#fetching">{{ _('Fetching') }}</a></li>
<li class="tab"><a href="#filters">{{ _('Global Filters') }}</a></li>
<li class="tab"><a href="#ui-options">{{ _('UI Options') }}</a></li>
<li class="tab"><a href="#api">{{ _('API') }}</a></li>
<li class="tab"><a href="#rss">{{ _('RSS') }}</a></li>
<li class="tab"><a href="#timedate">{{ _('Time & Date') }}</a></li>
<li class="tab"><a href="#proxies">{{ _('CAPTCHA & Proxies') }}</a></li>
{% if plugin_tabs %}
{% for tab in plugin_tabs %}
<li class="tab"><a href="#plugin-{{ tab.plugin_id }}">{{ tab.tab_label }}</a></li>
{% endfor %}
{% endif %}
<li class="tab"><a href="#info">Info</a></li>
<li class="tab"><a href="#info">{{ _('Info') }}</a></li>
</ul>
</div>
<div class="box-wrap inner">
@@ -43,7 +43,7 @@
<div class="pure-control-group">
{{ render_field(form.requests.form.time_between_check, class="time-check-widget") }}
<span class="pure-form-message-inline">Default recheck time for all watches, current system minimum is <i>{{min_system_recheck_seconds}}</i> seconds (<a href="https://github.com/dgtlmoon/changedetection.io/wiki/Misc-system-settings#enviroment-variables">more info</a>).</span>
<span class="pure-form-message-inline">{{ _('Default recheck time for all watches, current system minimum is') }} <i>{{min_system_recheck_seconds}}</i> {{ _('seconds') }} (<a href="https://github.com/dgtlmoon/changedetection.io/wiki/Misc-system-settings#enviroment-variables">{{ _('more info') }}</a>).</span>
<div id="time-between-check-schedule">
<!-- Start Time and End Time {{ timezone_default_config }} -->
<div id="limit-between-time">
@@ -387,8 +387,8 @@ nav
{% endfor %}
{% endif %}
<div class="tab-pane-inner" id="info">
<p><strong>Python version:</strong> {{ python_version }}</p>
<p><strong>Plugins active:</strong></p>
<p><strong>{{ _('Python version:') }}</strong> {{ python_version }}</p>
<p><strong>{{ _('Plugins active:') }}</strong></p>
{% if active_plugins %}
<ul>
{% for plugin in active_plugins %}
@@ -396,14 +396,14 @@ nav
{% endfor %}
</ul>
{% else %}
<p>No plugins active</p>
<p>{{ _('No plugins active') }}</p>
{% endif %}
</div>
<div id="actions">
<div class="pure-control-group">
{{ render_button(form.save_button) }}
<a href="{{url_for('watchlist.index')}}" class="pure-button button-cancel">Back</a>
<a href="{{url_for('ui.clear_all_history')}}" class="pure-button button-error">Clear Snapshot History</a>
<a href="{{url_for('watchlist.index')}}" class="pure-button button-cancel">{{ _('Back') }}</a>
<a href="{{url_for('ui.clear_all_history')}}" class="pure-button button-error">{{ _('Clear Snapshot History') }}</a>
</div>
</div>
</form>

View File

@@ -24,12 +24,12 @@
<div class="tabs collapsable">
<ul>
<li class="tab" id=""><a href="#general">General</a></li>
<li class="tab"><a href="#filters-and-triggers">Filters &amp; Triggers</a></li>
<li class="tab" id=""><a href="#general">{{ _('General') }}</a></li>
<li class="tab"><a href="#filters-and-triggers">{{ _('Filters & Triggers') }}</a></li>
{% if extra_tab_content %}
<li class="tab"><a href="#extras_tab">{{ extra_tab_content }}</a></li>
{% endif %}
<li class="tab"><a href="#notifications">Notifications</a></li>
<li class="tab"><a href="#notifications">{{ _('Notifications') }}</a></li>
</ul>
</div>
@@ -47,10 +47,10 @@
</div>
<div class="tab-pane-inner" id="filters-and-triggers">
<p>These settings are <strong><i>added</i></strong> to any existing watch configurations.</p>
<p>{{ _('These settings are') }} <strong><i>{{ _('added') }}</i></strong> {{ _('to any existing watch configurations.') }}</p>
{% include "edit/include_subtract.html" %}
<div class="text-filtering border-fieldset">
<h3>Text filtering</h3>
<h3>{{ _('Text filtering') }}</h3>
{% include "edit/text-options.html" %}
</div>
</div>
@@ -70,18 +70,18 @@
<div class="pure-control-group inline-radio">
{{ render_checkbox_field(form.notification_screenshot) }}
<span class="pure-form-message-inline">
<strong>Use with caution!</strong> This will easily fill up your email storage quota or flood other storages.
<strong>{{ _('Use with caution!') }}</strong> {{ _('This will easily fill up your email storage quota or flood other storages.') }}
</span>
</div>
{% endif %}
<div class="field-group" id="notification-field-group">
{% if has_default_notification_urls %}
<div class="inline-warning">
<img class="inline-warning-icon" src="{{url_for('static_content', group='images', filename='notice.svg')}}" alt="Look out!" title="Lookout!" >
There are <a href="{{ url_for('settings.settings_page')}}#notifications">system-wide notification URLs enabled</a>, this form will override notification settings for this watch only &dash; an empty Notification URL list here will still send notifications.
<img class="inline-warning-icon" src="{{url_for('static_content', group='images', filename='notice.svg')}}" alt="{{ _('Look out!') }}" title="{{ _('Lookout!') }}" >
{{ _('There are') }} <a href="{{ url_for('settings.settings_page')}}#notifications">{{ _('system-wide notification URLs enabled') }}</a>, {{ _('this form will override notification settings for this watch only') }} &dash; {{ _('an empty Notification URL list here will still send notifications.') }}
</div>
{% endif %}
<a href="#notifications" id="notification-setting-reset-to-default" class="pure-button button-xsmall" style="right: 20px; top: 20px; position: absolute; background-color: #5f42dd; border-radius: 4px; font-size: 70%; color: #fff">Use system defaults</a>
<a href="#notifications" id="notification-setting-reset-to-default" class="pure-button button-xsmall" style="right: 20px; top: 20px; position: absolute; background-color: #5f42dd; border-radius: 4px; font-size: 70%; color: #fff">{{ _('Use system defaults') }}</a>
{{ render_common_settings_form(form, emailprefix, settings_application, extra_notification_token_placeholder_info) }}
</div>

View File

@@ -8,17 +8,17 @@
<form class="pure-form" action="{{ url_for('tags.form_tag_add') }}" method="POST" id="new-watch-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" >
<fieldset>
<legend>Add a new organisational tag</legend>
<legend>{{ _('Add a new organisational tag') }}</legend>
<div id="watch-add-wrapper-zone">
<div>
{{ render_simple_field(form.name, placeholder="Watch group / tag") }}
{{ render_simple_field(form.name, placeholder=_("Watch group / tag")) }}
</div>
<div>
{{ render_simple_field(form.save_button, title="Save" ) }}
{{ render_simple_field(form.save_button, title=_("Save") ) }}
</div>
</div>
<br>
<div style="color: #fff;">Groups allows you to manage filters and notifications for multiple watches under a single organisational tag.</div>
<div style="color: #fff;">{{ _('Groups allows you to manage filters and notifications for multiple watches under a single organisational tag.') }}</div>
</fieldset>
</form>
<!-- @todo maybe some overview matrix, 'tick' with which has notification, filter rules etc -->
@@ -28,8 +28,8 @@
<thead>
<tr>
<th></th>
<th># Watches</th>
<th>Tag / Label name</th>
<th>{{ _('# Watches') }}</th>
<th>{{ _('Tag / Label name') }}</th>
<th></th>
</tr>
</thead>
@@ -39,7 +39,7 @@
--->
{% if not available_tags|length %}
<tr>
<td colspan="3">No website organisational tags/groups configured</td>
<td colspan="3">{{ _('No website organisational tags/groups configured') }}</td>
</tr>
{% endif %}
{% for uuid, tag in available_tags %}
@@ -50,24 +50,24 @@
<td>{{ "{:,}".format(tag_count[uuid]) if uuid in tag_count else 0 }}</td>
<td class="title-col inline"> <a href="{{url_for('watchlist.index', tag=uuid) }}">{{ tag.title }}</a></td>
<td>
<a class="pure-button pure-button-primary" href="{{ url_for('tags.form_tag_edit', uuid=uuid) }}">Edit</a>&nbsp;
<a class="pure-button pure-button-primary" href="{{ url_for('tags.form_tag_edit', uuid=uuid) }}">{{ _('Edit') }}</a>&nbsp;
<a class="pure-button button-error"
href="{{ url_for('tags.delete', uuid=uuid) }}"
data-requires-confirm
data-confirm-type="danger"
data-confirm-title="Delete Group?"
data-confirm-message="<p>Are you sure you want to delete group <strong>{{ tag.title }}</strong>?</p><p>This action cannot be undone.</p>"
data-confirm-button="Delete"
title="Deletes and removes tag">Delete</a>
data-confirm-title="{{ _('Delete Group?') }}"
data-confirm-message="{{ _('<p>Are you sure you want to delete group <strong>%(title)s</strong>?</p><p>This action cannot be undone.</p>', title=tag.title) }}"
data-confirm-button="{{ _('Delete') }}"
title="{{ _('Deletes and removes tag') }}">{{ _('Delete') }}</a>
<a class="pure-button button-warning"
href="{{ url_for('tags.unlink', uuid=uuid) }}"
data-requires-confirm
data-confirm-type="warning"
data-confirm-title="Unlink Group?"
data-confirm-message="<p>Are you sure you want to unlink all watches from group <strong>{{ tag.title }}</strong>?</p><p>The tag will be kept but watches will be removed from it.</p>"
data-confirm-button="Unlink"
title="Keep the tag but unlink any watches">Unlink</a>
<a href="{{ url_for('rss.rss_tag_feed', tag_uuid=uuid, token=app_rss_token)}}"><img alt="RSS Feed for this watch" style="padding-left: 1em;" src="{{url_for('static_content', group='images', filename='generic_feed-icon.svg')}}" height="15"></a>
data-confirm-title="{{ _('Unlink Group?') }}"
data-confirm-message="{{ _('<p>Are you sure you want to unlink all watches from group <strong>%(title)s</strong>?</p><p>The tag will be kept but watches will be removed from it.</p>', title=tag.title) }}"
data-confirm-button="{{ _('Unlink') }}"
title="{{ _('Keep the tag but unlink any watches') }}">{{ _('Unlink') }}</a>
<a href="{{ url_for('rss.rss_tag_feed', tag_uuid=uuid, token=app_rss_token)}}"><img alt="{{ _('RSS Feed for this watch') }}" style="padding-left: 1em;" src="{{url_for('static_content', group='images', filename='generic_feed-icon.svg')}}" height="15"></a>
</td>
</tr>
{% endfor %}

View File

@@ -9,13 +9,12 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" >
<fieldset>
<div class="pure-control-group">
This will remove version history (snapshots) for ALL watches, but keep
your list of URLs! <br />
You may like to use the <strong>BACKUP</strong> link first.<br />
{{ _('This will remove version history (snapshots) for ALL watches, but keep your list of URLs!') }} <br />
{{ _('You may like to use the') }} <strong>{{ _('BACKUP') }}</strong> {{ _('link first.') }}<br />
</div>
<br />
<div class="pure-control-group">
<label for="confirmtext">Confirmation text</label>
<label for="confirmtext">{{ _('Confirmation text') }}</label>
<input
type="text"
id="confirmtext"
@@ -25,20 +24,19 @@
size="10"
/>
<span class="pure-form-message-inline"
>Type in the word <strong>clear</strong> to confirm that you
understand.</span
>{{ _('Type in the word') }} <strong>{{ _('clear') }}</strong> {{ _('to confirm that you understand.') }}</span
>
</div>
<br />
<div class="pure-control-group">
<button type="submit" class="pure-button pure-button-primary">
Clear History!
{{ _('Clear History!') }}
</button>
</div>
<br />
<div class="pure-control-group">
<a href="{{url_for('watchlist.index')}}" class="pure-button button-cancel"
>Cancel</a
>{{ _('Cancel') }}</a
>
</div>
</fieldset>

View File

@@ -1,12 +1,12 @@
<ul id="highlightSnippetActions">
<li>
<button class="pure-button pure-button-primary" onclick="diffToJpeg()" title="Share diff as image">Share as Image</button>
<button class="pure-button pure-button-primary" onclick="diffToJpeg()" title="{{ _('Share diff as image') }}">{{ _('Share as Image') }}</button>
</li>
<li>
<a class="pure-button pure-button-primary" data-mode="exact" href="javascript:void(0);">Ignore any lines matching</a>
<a class="pure-button pure-button-primary" data-mode="exact" href="javascript:void(0);">{{ _('Ignore any lines matching') }}</a>
</li>
<li>
<a class="pure-button pure-button-primary" data-mode="digit-regex" href="javascript:void(0);" >Ignore any lines matching excluding digits</a>
<a class="pure-button pure-button-primary" data-mode="digit-regex" href="javascript:void(0);" >{{ _('Ignore any lines matching excluding digits') }}</a>
</li>
</ul>

View File

@@ -25,7 +25,7 @@
<fieldset class="diff-fieldset">
{% if versions|length >= 1 %}
<span style="white-space: nowrap;">
<label id="change-from" for="diff-from-version" class="from-to-label">From</label>
<label id="change-from" for="diff-from-version" class="from-to-label">{{ _('From') }}</label>
<select id="diff-from-version" name="from_version" class="needs-localtime">
{%- for version in versions|reverse -%}
<option value="{{ version }}" {% if version== from_version %} selected="" {% endif %}>
@@ -35,7 +35,7 @@
</select>
</span>
<span style="white-space: nowrap;">
<label id="change-to" for="diff-to-version" class="from-to-label">To</label>
<label id="change-to" for="diff-to-version" class="from-to-label">{{ _('To') }}</label>
<select id="diff-to-version" name="to_version" class="needs-localtime">
{%- for version in versions|reverse -%}
<option value="{{ version }}" {% if version== to_version %} selected="" {% endif %}>
@@ -50,69 +50,69 @@
<fieldset id="diff-style">
<span>
<label for="diffWords" class="pure-checkbox">
<input type="radio" name="type" id="diffWords" value="diffWords" {% if diff_prefs.type == 'diffWords' %}checked=""{% endif %}> Words</label>
<input type="radio" name="type" id="diffWords" value="diffWords" {% if diff_prefs.type == 'diffWords' %}checked=""{% endif %}> {{ _('Words') }}</label>
</span>
<span>
<label for="diffLines" class="pure-checkbox">
<input type="radio" name="type" id="diffLines" value="diffLines" {% if diff_prefs.type == 'diffLines' %}checked=""{% endif %}> Lines</label>
<input type="radio" name="type" id="diffLines" value="diffLines" {% if diff_prefs.type == 'diffLines' %}checked=""{% endif %}> {{ _('Lines') }}</label>
</span>
<span>
<label for="ignoreWhitespace" class="pure-checkbox" id="label-diff-ignorewhitespace">
<input type="checkbox" id="ignoreWhitespace" name="ignoreWhitespace" {% if diff_prefs.ignoreWhitespace %}checked=""{% endif %}> Ignore Whitespace</label>
<input type="checkbox" id="ignoreWhitespace" name="ignoreWhitespace" {% if diff_prefs.ignoreWhitespace %}checked=""{% endif %}> {{ _('Ignore Whitespace') }}</label>
</span>
<span>
<label for="changesOnly" class="pure-checkbox" id="label-diff-changes">
<input type="checkbox" id="changesOnly" name="changesOnly" {% if diff_prefs.changesOnly %}checked=""{% endif %}> Same/non-changed</label>
<input type="checkbox" id="changesOnly" name="changesOnly" {% if diff_prefs.changesOnly %}checked=""{% endif %}> {{ _('Same/non-changed') }}</label>
</span>
<span>
<label for="removed" class="pure-checkbox" id="label-diff-removed">
<input type="checkbox" id="removed" name="removed" {% if diff_prefs.removed %}checked=""{% endif %}> Removed</label>
<input type="checkbox" id="removed" name="removed" {% if diff_prefs.removed %}checked=""{% endif %}> {{ _('Removed') }}</label>
</span>
<span>
<label for="added" class="pure-checkbox" id="label-diff-added">
<input type="checkbox" id="added" name="added" {% if diff_prefs.added %}checked=""{% endif %}> Added</label>
<input type="checkbox" id="added" name="added" {% if diff_prefs.added %}checked=""{% endif %}> {{ _('Added') }}</label>
</span>
<span>
<label for="replaced" class="pure-checkbox" id="label-diff-replaced">
<input type="checkbox" id="replaced" name="replaced" {% if diff_prefs.replaced %}checked=""{% endif %}> Replaced</label>
<input type="checkbox" id="replaced" name="replaced" {% if diff_prefs.replaced %}checked=""{% endif %}> {{ _('Replaced') }}</label>
</span>
</fieldset>
{%- if versions|length >= 2 -%}
<div id="keyboard-nav">
<strong>Keyboard: </strong>
<a href="" class="pure-button pure-button-primary" id="btn-previous"> &larr; Previous</a>
&nbsp; <a class="pure-button pure-button-primary" id="btn-next" href=""> &rarr; Next</a>
<strong>{{ _('Keyboard:') }} </strong>
<a href="" class="pure-button pure-button-primary" id="btn-previous"> &larr; {{ _('Previous') }}</a>
&nbsp; <a class="pure-button pure-button-primary" id="btn-next" href=""> &rarr; {{ _('Next') }}</a>
</div>
{%- endif -%}
</form>
</div>
<div id="diff-jump">
<a id="jump-next-diff" title="Jump to next difference">Jump</a>
<a id="jump-next-diff" title="{{ _('Jump to next difference') }}">{{ _('Jump') }}</a>
</div>
<script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script>
<div class="tabs">
<ul>
{% if last_error_text %}<li class="tab" id="error-text-tab"><a href="#error-text">Error Text</a></li> {% endif %}
{% if last_error_screenshot %}<li class="tab" id="error-screenshot-tab"><a href="#error-screenshot">Error Screenshot</a></li> {% endif %}
<li class="tab" id="text-tab"><a href="#text">Text</a></li>
<li class="tab" id="screenshot-tab"><a href="#screenshot">Current screenshot</a></li>
<li class="tab" id="extract-tab"><a href="{{ url_for('ui.ui_diff.diff_history_page_extract_GET', uuid=uuid)}}">Extract Data</a></li>
{% if last_error_text %}<li class="tab" id="error-text-tab"><a href="#error-text">{{ _('Error Text') }}</a></li> {% endif %}
{% if last_error_screenshot %}<li class="tab" id="error-screenshot-tab"><a href="#error-screenshot">{{ _('Error Screenshot') }}</a></li> {% endif %}
<li class="tab" id="text-tab"><a href="#text">{{ _('Text') }}</a></li>
<li class="tab" id="screenshot-tab"><a href="#screenshot">{{ _('Current screenshot') }}</a></li>
<li class="tab" id="extract-tab"><a href="{{ url_for('ui.ui_diff.diff_history_page_extract_GET', uuid=uuid)}}">{{ _('Extract Data') }}</a></li>
</ul>
</div>
<div id="diff-ui">
<div class="tab-pane-inner" id="error-text">
<div class="snapshot-age error">{{watch_a.error_text_ctime|format_seconds_ago}} seconds ago.</div>
<div class="snapshot-age error">{{watch_a.error_text_ctime|format_seconds_ago}} {{ _('seconds ago.') }}</div>
<pre>
{{ last_error_text }}
</pre>
</div>
<div class="tab-pane-inner" id="error-screenshot">
<div class="snapshot-age error">{{watch_a.snapshot_error_screenshot_ctime|format_seconds_ago}} seconds ago</div>
<img id="error-screenshot-img" style="max-width: 80%" alt="Current error-ing screenshot from most recent request" >
<div class="snapshot-age error">{{watch_a.snapshot_error_screenshot_ctime|format_seconds_ago}} {{ _('seconds ago') }}</div>
<img id="error-screenshot-img" style="max-width: 80%" alt="{{ _('Current error-ing screenshot from most recent request') }}" >
</div>
<div class="tab-pane-inner" id="text">
@@ -124,34 +124,34 @@
</div>
{%- endif -%}
{%- if password_enabled_and_share_is_off -%}
<div class="tip">Pro-tip: You can enable <strong>"share access when password is enabled"</strong> from settings.
<div class="tip">{{ _('Pro-tip: You can enable') }} <strong>{{ _('"share access when password is enabled"') }}</strong> {{ _('from settings.') }}
</div>
{%- endif -%}
<div id="text-diff-heading-area" style="user-select: none;">
<div class="snapshot-age"><span>{{ from_version|format_timestamp_timeago }}</span>
{%- if note -%}<span class="note"><strong>{{ note }}</strong></span>{%- endif -%}
<a href="{{ url_for("ui.ui_preview.preview_page", uuid=uuid) }}">Goto single snapshot</a>
<a href="{{ url_for("ui.ui_preview.preview_page", uuid=uuid) }}">{{ _('Goto single snapshot') }}</a>
</div>
</div>
<pre id="difference" style="border-left: 2px solid #ddd;">{{ content| diff_unescape_difference_spans }}</pre>
<div id="diff-visualiser-area-after" style="user-select: none;">
<strong>Tip:</strong> Highlight text to share or add to ignore lists.
<strong>{{ _('Tip:') }}</strong> {{ _('Highlight text to share or add to ignore lists.') }}
</div>
</div>
<div class="tab-pane-inner" id="screenshot">
<div class="tip">
For now, Differences are performed on text, not graphically, only the latest screenshot is available.
{{ _('For now, Differences are performed on text, not graphically, only the latest screenshot is available.') }}
</div>
{% if is_html_webdriver %}
{% if screenshot %}
<div class="snapshot-age">{{watch_a.snapshot_screenshot_ctime|format_timestamp_timeago}}</div>
<img style="max-width: 80%" id="screenshot-img" alt="Current screenshot from most recent request" >
<img style="max-width: 80%" id="screenshot-img" alt="{{ _('Current screenshot from most recent request') }}" >
{% else %}
No screenshot available just yet! Try rechecking the page.
{{ _('No screenshot available just yet! Try rechecking the page.') }}
{% endif %}
{% else %}
<strong>Screenshot requires Playwright/WebDriver enabled</strong>
<strong>{{ _('Screenshot requires Playwright/WebDriver enabled') }}</strong>
{% endif %}
</div>

View File

@@ -44,20 +44,20 @@
<div class="tabs collapsable">
<ul>
<li class="tab"><a href="#general">General</a></li>
<li class="tab"><a href="#request">Request</a></li>
<li class="tab"><a href="#general">{{ _('General') }}</a></li>
<li class="tab"><a href="#request">{{ _('Request') }}</a></li>
{% if extra_tab_content %}
<li class="tab"><a href="#extras_tab">{{ extra_tab_content }}</a></li>
{% endif %}
<li class="tab"><a id="browsersteps-tab" href="#browser-steps">Browser Steps</a></li>
<li class="tab"><a id="browsersteps-tab" href="#browser-steps">{{ _('Browser Steps') }}</a></li>
<!-- should goto extra forms? -->
{% if watch['processor'] == 'text_json_diff' or watch['processor'] == 'image_ssim_diff' %}
<li class="tab"><a id="visualselector-tab" href="#visualselector">Visual Filter Selector</a></li>
<li class="tab" id="filters-and-triggers-tab"><a href="#filters-and-triggers">Filters &amp; Triggers</a></li>
<li class="tab" id="conditions-tab"><a href="#conditions">Conditions</a></li>
<li class="tab"><a id="visualselector-tab" href="#visualselector">{{ _('Visual Filter Selector') }}</a></li>
<li class="tab" id="filters-and-triggers-tab"><a href="#filters-and-triggers">{{ _('Filters & Triggers') }}</a></li>
<li class="tab" id="conditions-tab"><a href="#conditions">{{ _('Conditions') }}</a></li>
{% endif %}
<li class="tab"><a href="#notifications">Notifications</a></li>
<li class="tab"><a href="#stats">Stats</a></li>
<li class="tab"><a href="#notifications">{{ _('Notifications') }}</a></li>
<li class="tab"><a href="#stats">{{ _('Stats') }}</a></li>
</ul>
</div>
@@ -70,19 +70,19 @@
<fieldset>
<div class="pure-control-group">
{{ render_field(form.url, placeholder="https://...", required=true, class="m-d") }}
<div class="pure-form-message">Some sites use JavaScript to create the content, for this you should <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Fetching-pages-with-WebDriver">use the Chrome/WebDriver Fetcher</a></div>
<div class="pure-form-message">Variables are supported in the URL (<a href="https://github.com/dgtlmoon/changedetection.io/wiki/Handling-variables-in-the-watched-URL">help and examples here</a>).</div>
<div class="pure-form-message">{{ _('Some sites use JavaScript to create the content, for this you should') }} <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Fetching-pages-with-WebDriver">{{ _('use the Chrome/WebDriver Fetcher') }}</a></div>
<div class="pure-form-message">{{ _('Variables are supported in the URL') }} (<a href="https://github.com/dgtlmoon/changedetection.io/wiki/Handling-variables-in-the-watched-URL">{{ _('help and examples here') }}</a>).</div>
</div>
<div class="pure-control-group">
{{ render_field(form.tags) }}
<span class="pure-form-message-inline">Organisational tag/group name used in the main listing page</span>
<span class="pure-form-message-inline">{{ _('Organisational tag/group name used in the main listing page') }}</span>
</div>
<div class="pure-control-group inline-radio">
{{ render_field(form.processor) }}
</div>
<div class="pure-control-group">
{{ render_field(form.title, class="m-d", placeholder=watch.label) }}
<span class="pure-form-message-inline">Automatically uses the page title if found, you can also use your own title/description here</span>
<span class="pure-form-message-inline">{{ _('Automatically uses the page title if found, you can also use your own title/description here') }}</span>
</div>
<div class="pure-control-group time-between-check border-fieldset">
@@ -92,7 +92,7 @@
{{ render_field(form.time_between_check, class="time-check-widget") }}
<span class="pure-form-message-inline">
The interval/amount of time between each check.
{{ _('The interval/amount of time between each check.') }}
</span>
</div>
<div id="time-between-check-schedule">
@@ -107,7 +107,7 @@
<div class="pure-control-group">
{{ render_checkbox_field(form.filter_failure_notification_send) }}
<span class="pure-form-message-inline">
Sends a notification when the filter can no longer be seen on the page, good for knowing when the page changed and your filter will not work anymore.
{{ _('Sends a notification when the filter can no longer be seen on the page, good for knowing when the page changed and your filter will not work anymore.') }}
</span>
</div>
<div class="pure-control-group">
@@ -120,17 +120,17 @@
<div class="pure-control-group inline-radio">
{{ render_field(form.fetch_backend, class="fetch-backend") }}
<span class="pure-form-message-inline">
<p>Use the <strong>Basic</strong> method (default) where your watched site doesn't need Javascript to render.</p>
<p>The <strong>Chrome/Javascript</strong> method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'. </p>
Tip: <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration#brightdata-proxy-support">Connect using Bright Data and Oxylabs Proxies, find out more here.</a>
<p>{{ _('Use the') }} <strong>{{ _('Basic') }}</strong> {{ _('method (default) where your watched site doesn\'t need Javascript to render.') }}</p>
<p>{{ _('The') }} <strong>{{ _('Chrome/Javascript') }}</strong> {{ _('method requires a network connection to a running WebDriver+Chrome server, set by the ENV var \'WEBDRIVER_URL\'.') }} </p>
{{ _('Tip:') }} <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration#brightdata-proxy-support">{{ _('Connect using Bright Data and Oxylabs Proxies, find out more here.') }}</a>
</span>
</div>
{% if form.proxy %}
<div class="pure-control-group inline-radio">
<div>{{ form.proxy.label }} <a href="" id="check-all-proxies" class="pure-button button-secondary button-xsmall" >Check/Scan all</a></div>
<div>{{ form.proxy.label }} <a href="" id="check-all-proxies" class="pure-button button-secondary button-xsmall" >{{ _('Check/Scan all') }}</a></div>
<div>{{ form.proxy(class="fetch-backend-proxy") }}</div>
<span class="pure-form-message-inline">
Choose a proxy for this watch
{{ _('Choose a proxy for this watch') }}
</span>
</div>
{% endif %}
@@ -140,31 +140,29 @@
<div class="pure-control-group">
{{ render_field(form.webdriver_delay) }}
<div class="pure-form-message-inline">
<strong>If you're having trouble waiting for the page to be fully rendered (text missing etc), try increasing the 'wait' time here.</strong>
<strong>{{ _('If you\'re having trouble waiting for the page to be fully rendered (text missing etc), try increasing the \'wait\' time here.') }}</strong>
<br>
This will wait <i>n</i> seconds before extracting the text.
{{ _('This will wait') }} <i>n</i> {{ _('seconds before extracting the text.') }}
{% if using_global_webdriver_wait %}
<br><strong>Using the current global default settings</strong>
<br><strong>{{ _('Using the current global default settings') }}</strong>
{% endif %}
</div>
</div>
<div class="pure-control-group">
<a class="pure-button button-secondary button-xsmall show-advanced">Show advanced options</a>
<a class="pure-button button-secondary button-xsmall show-advanced">{{ _('Show advanced options') }}</a>
</div>
<div class="advanced-options" style="display: none;">
{{ render_field(form.webdriver_js_execute_code) }}
<div class="pure-form-message-inline">
Run this code before performing change detection, handy for filling in fields and other
actions <a
href="https://github.com/dgtlmoon/changedetection.io/wiki/Run-JavaScript-before-change-detection">More
help and examples here</a>
{{ _('Run this code before performing change detection, handy for filling in fields and other actions') }} <a
href="https://github.com/dgtlmoon/changedetection.io/wiki/Run-JavaScript-before-change-detection">{{ _('More help and examples here') }}</a>
</div>
</div>
</fieldset>
<!-- html requests always -->
<fieldset data-visible-for="fetch_backend=html_requests">
<div class="pure-control-group">
<a class="pure-button button-secondary button-xsmall show-advanced">Show advanced options</a>
<a class="pure-button button-secondary button-xsmall show-advanced">{{ _('Show advanced options') }}</a>
</div>
<div class="advanced-options" style="display: none;">
<div class="pure-control-group" id="request-method">
@@ -179,7 +177,7 @@
\"year\":{% now 'Europe/Berlin', '%Y' %}
}") }}
</div>
<div class="pure-form-message">Variables are supported in the request body (<a href="https://github.com/dgtlmoon/changedetection.io/wiki/Handling-variables-in-the-watched-URL">help and examples here</a>).</div>
<div class="pure-form-message">{{ _('Variables are supported in the request body') }} (<a href="https://github.com/dgtlmoon/changedetection.io/wiki/Handling-variables-in-the-watched-URL">{{ _('help and examples here') }}</a>).</div>
</div>
</fieldset>
<!-- hmm -->
@@ -188,15 +186,15 @@
Cookie: foobar
User-Agent: wonderbra 1.0
Math: {{ 1 + 1 }}") }}
<div class="pure-form-message">Variables are supported in the request header values (<a href="https://github.com/dgtlmoon/changedetection.io/wiki/Handling-variables-in-the-watched-URL">help and examples here</a>).</div>
<div class="pure-form-message">{{ _('Variables are supported in the request header values') }} (<a href="https://github.com/dgtlmoon/changedetection.io/wiki/Handling-variables-in-the-watched-URL">{{ _('help and examples here') }}</a>).</div>
<div class="pure-form-message-inline">
{% if has_extra_headers_file %}
<strong>Alert! Extra headers file found and will be added to this watch!</strong>
<strong>{{ _('Alert! Extra headers file found and will be added to this watch!') }}</strong>
{% else %}
Headers can be also read from a file in your data-directory <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Adding-headers-from-an-external-file">Read more here</a>
{{ _('Headers can be also read from a file in your data-directory') }} <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Adding-headers-from-an-external-file">{{ _('Read more here') }}</a>
{% endif %}
<br>
(Not supported by Selenium browser)
({{ _('Not supported by Selenium browser') }})
</div>
</div>
<fieldset data-visible-for="fetch_backend=html_requests fetch_backend=html_webdriver" >
@@ -220,19 +218,19 @@ Math: {{ 1 + 1 }}") }}
<!--- Do this later -->
<div class="checkbox" style="display: none;">
<input type=checkbox id="include_text_elements" > <label for="include_text_elements">Turn on text finder</label>
<input type=checkbox id="include_text_elements" > <label for="include_text_elements">{{ _('Turn on text finder') }}</label>
</div>
<div id="loading-status-text" style="display: none;">Please wait, first browser step can take a little time to load..<div class="spinner"></div></div>
<div id="loading-status-text" style="display: none;">{{ _('Please wait, first browser step can take a little time to load..') }}<div class="spinner"></div></div>
<div class="flex-wrapper" >
<div id="browser-steps-ui" class="noselect">
<div class="noselect" id="browsersteps-selector-wrapper" style="width: 100%">
<span class="loader" >
<span id="browsersteps-click-start">
<h2 >Click here to Start</h2>
<h2 >{{ _('Click here to Start') }}</h2>
<svg style="height: 3.5rem;" version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><g id="start"/><g id="play_x5F_alt"><path d="M16,0C7.164,0,0,7.164,0,16s7.164,16,16,16s16-7.164,16-16S24.836,0,16,0z M10,24V8l16.008,8L10,24z" style="fill: var(--color-grey-400);"/></g></svg><br>
Please allow 10-15 seconds for the browser to connect.<br>
{{ _('Please allow 10-15 seconds for the browser to connect.') }}<br>
</span>
<div class="spinner" style="display: none;"></div>
</span>
@@ -241,19 +239,19 @@ Math: {{ 1 + 1 }}") }}
</div>
</div>
<div id="browser-steps-fieldlist" >
<span id="browser-seconds-remaining">Press "Play" to start.</span> <span style="font-size: 80%;"> (<a target="newwindow" href="https://github.com/dgtlmoon/changedetection.io/pull/478/files#diff-1a79d924d1840c485238e66772391268a89c95b781d69091384cf1ea1ac146c9R4">?</a>) </span>
<span id="browser-seconds-remaining">{{ _('Press "Play" to start.') }}</span> <span style="font-size: 80%;"> (<a target="newwindow" href="https://github.com/dgtlmoon/changedetection.io/pull/478/files#diff-1a79d924d1840c485238e66772391268a89c95b781d69091384cf1ea1ac146c9R4">?</a>) </span>
{{ render_field(form.browser_steps) }}
</div>
</div>
</div>
</fieldset>
{% else %}
<strong>Visual Selector data is not ready, watch needs to be checked atleast once.</strong>
<strong>{{ _('Visual Selector data is not ready, watch needs to be checked atleast once.') }}</strong>
{% endif %}
{% else %}
<p>
<strong>Sorry, this functionality only works with fetchers that support interactive Javascript (so far only Playwright based fetchers)<br>
You need to <a href="#request">Set the fetch method</a> to one that supports interactive Javascript.</strong>
<strong>{{ _('Sorry, this functionality only works with fetchers that support interactive Javascript (so far only Playwright based fetchers)') }}<br>
{{ _('You need to') }} <a href="#request">{{ _('Set the fetch method') }}</a> {{ _('to one that supports interactive Javascript.') }}</strong>
</p>
{% endif %}
</div>
@@ -268,18 +266,18 @@ Math: {{ 1 + 1 }}") }}
<div class="pure-control-group inline-radio">
{{ render_checkbox_field(form.notification_screenshot) }}
<span class="pure-form-message-inline">
<strong>Use with caution!</strong> This will easily fill up your email storage quota or flood other storages.
<strong>{{ _('Use with caution!') }}</strong> {{ _('This will easily fill up your email storage quota or flood other storages.') }}
</span>
</div>
{% endif %}
<div class="field-group" id="notification-field-group">
{% if has_default_notification_urls %}
<div class="inline-warning">
<img class="inline-warning-icon" src="{{url_for('static_content', group='images', filename='notice.svg')}}" alt="Look out!" title="Lookout!" >
There are <a href="{{ url_for('settings.settings_page')}}#notifications">system-wide notification URLs enabled</a>, this form will override notification settings for this watch only &dash; an empty Notification URL list here will still send notifications.
<img class="inline-warning-icon" src="{{url_for('static_content', group='images', filename='notice.svg')}}" alt="{{ _('Look out!') }}" title="{{ _('Lookout!') }}" >
{{ _('There are') }} <a href="{{ url_for('settings.settings_page')}}#notifications">{{ _('system-wide notification URLs enabled') }}</a>, {{ _('this form will override notification settings for this watch only') }} &dash; {{ _('an empty Notification URL list here will still send notifications.') }}
</div>
{% endif %}
<a href="#notifications" id="notification-setting-reset-to-default" class="pure-button button-xsmall" style="right: 20px; top: 20px; position: absolute; background-color: #5f42dd; border-radius: 4px; font-size: 70%; color: #fff">Use system defaults</a>
<a href="#notifications" id="notification-setting-reset-to-default" class="pure-button button-xsmall" style="right: 20px; top: 20px; position: absolute; background-color: #5f42dd; border-radius: 4px; font-size: 70%; color: #fff">{{ _('Use system defaults') }}</a>
{{ render_common_settings_form(form, emailprefix, settings_application, extra_notification_token_placeholder_info) }}
</div>
</fieldset>
@@ -296,23 +294,23 @@ Math: {{ 1 + 1 }}") }}
{{ render_conditions_fieldlist_of_formfields_as_table(form.conditions) }}
<div class="pure-form-message-inline">
<p id="verify-state-text">Use the verify (✓) button to test if a condition passes against the current snapshot.</p>
Read a quick tutorial about <a href="https://changedetection.io/tutorial/conditional-actions-web-page-changes">using conditional web page changes here</a>.<br>
<p id="verify-state-text">{{ _('Use the verify (✓) button to test if a condition passes against the current snapshot.') }}</p>
{{ _('Read a quick tutorial about') }} <a href="https://changedetection.io/tutorial/conditional-actions-web-page-changes">{{ _('using conditional web page changes here') }}</a>.<br>
</div>
</div>
</div>
<div class="tab-pane-inner" id="filters-and-triggers">
<span id="activate-text-preview" class="pure-button pure-button-primary button-xsmall">Activate preview</span>
<span id="activate-text-preview" class="pure-button pure-button-primary button-xsmall">{{ _('Activate preview') }}</span>
<div>
<div id="edit-text-filter">
<div class="pure-control-group" id="pro-tips">
<strong>Pro-tips:</strong><br>
<strong>{{ _('Pro-tips:') }}</strong><br>
<ul>
<li>
Use the preview page to see your filters and triggers highlighted.
{{ _('Use the preview page to see your filters and triggers highlighted.') }}
</li>
<li>
Some sites use JavaScript to create the content, for this you should <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Fetching-pages-with-WebDriver">use the Chrome/WebDriver Fetcher</a>
{{ _('Some sites use JavaScript to create the content, for this you should') }} <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Fetching-pages-with-WebDriver">{{ _('use the Chrome/WebDriver Fetcher') }}</a>
</li>
</ul>
</div>
@@ -320,30 +318,30 @@ Math: {{ 1 + 1 }}") }}
{% include "edit/include_subtract.html" %}
<div class="text-filtering border-fieldset">
<fieldset class="pure-group" id="text-filtering-type-options">
<h3>Text filtering</h3>
Limit trigger/ignore/block/extract to;<br>
<h3>{{ _('Text filtering') }}</h3>
{{ _('Limit trigger/ignore/block/extract to;') }}<br>
{{ render_checkbox_field(form.filter_text_added) }}
{{ render_checkbox_field(form.filter_text_replaced) }}
{{ render_checkbox_field(form.filter_text_removed) }}
<span class="pure-form-message-inline">Note: Depending on the length and similarity of the text on each line, the algorithm may consider an <strong>addition</strong> instead of <strong>replacement</strong> for example.</span><br>
<span class="pure-form-message-inline">&nbsp;So it's always better to select <strong>Added</strong>+<strong>Replaced</strong> when you're interested in new content.</span><br>
<span class="pure-form-message-inline">&nbsp;When content is merely moved in a list, it will also trigger an <strong>addition</strong>, consider enabling <code><strong>Only trigger when unique lines appear</strong></code></span>
<span class="pure-form-message-inline">{{ _('Note: Depending on the length and similarity of the text on each line, the algorithm may consider an') }} <strong>{{ _('addition') }}</strong> {{ _('instead of') }} <strong>{{ _('replacement') }}</strong> {{ _('for example.') }}</span><br>
<span class="pure-form-message-inline">&nbsp;{{ _('So it\'s always better to select') }} <strong>{{ _('Added') }}</strong>+<strong>{{ _('Replaced') }}</strong> {{ _('when you\'re interested in new content.') }}</span><br>
<span class="pure-form-message-inline">&nbsp;{{ _('When content is merely moved in a list, it will also trigger an') }} <strong>{{ _('addition') }}</strong>, {{ _('consider enabling') }} <code><strong>{{ _('Only trigger when unique lines appear') }}</strong></code></span>
</fieldset>
<fieldset class="pure-control-group">
{{ render_checkbox_field(form.check_unique_lines) }}
<span class="pure-form-message-inline">Good for websites that just move the content around, and you want to know when NEW content is added, compares new lines against all history for this watch.</span>
<span class="pure-form-message-inline">{{ _('Good for websites that just move the content around, and you want to know when NEW content is added, compares new lines against all history for this watch.') }}</span>
</fieldset>
<fieldset class="pure-control-group">
{{ render_checkbox_field(form.remove_duplicate_lines) }}
<span class="pure-form-message-inline">Remove duplicate lines of text</span>
<span class="pure-form-message-inline">{{ _('Remove duplicate lines of text') }}</span>
</fieldset>
<fieldset class="pure-control-group">
{{ render_checkbox_field(form.sort_text_alphabetically) }}
<span class="pure-form-message-inline">Helps reduce changes detected caused by sites shuffling lines around, combine with <i>check unique lines</i> below.</span>
<span class="pure-form-message-inline">{{ _('Helps reduce changes detected caused by sites shuffling lines around, combine with') }} <i>{{ _('check unique lines') }}</i> {{ _('below.') }}</span>
</fieldset>
<fieldset class="pure-control-group">
{{ render_checkbox_field(form.trim_text_whitespace) }}
<span class="pure-form-message-inline">Remove any whitespace before and after each line of text</span>
<span class="pure-form-message-inline">{{ _('Remove any whitespace before and after each line of text') }}</span>
</fieldset>
{% include "edit/text-options.html" %}
</div>
@@ -357,10 +355,10 @@ Math: {{ 1 + 1 }}") }}
<div class="minitabs-wrapper">
<div class="minitabs-content">
<div id="text-preview-inner" class="monospace-preview">
<p>Loading...</p>
<p>{{ _('Loading...') }}</p>
</div>
<div id="text-preview-before-inner" style="display: none;" class="monospace-preview">
<p>Loading...</p>
<p>{{ _('Loading...') }}</p>
</div>
</div>
</div>
@@ -385,19 +383,19 @@ Math: {{ 1 + 1 }}") }}
{% if capabilities.supports_screenshots and capabilities.supports_xpath_element_data %}
{% if visual_selector_data_ready %}
<span class="pure-form-message-inline" id="visual-selector-heading">
The Visual Selector tool lets you select the <i>text</i> elements that will be used for the change detection. It automatically fills-in the filters in the "CSS/JSONPath/JQ/XPath Filters" box of the <a href="#filters-and-triggers">Filters & Triggers</a> tab. Use <strong>Shift+Click</strong> to select multiple items.
{{ _('The Visual Selector tool lets you select the') }} <i>{{ _('text') }}</i> {{ _('elements that will be used for the change detection. It automatically fills-in the filters in the "CSS/JSONPath/JQ/XPath Filters" box of the') }} <a href="#filters-and-triggers">{{ _('Filters & Triggers') }}</a> {{ _('tab. Use') }} <strong>{{ _('Shift+Click') }}</strong> {{ _('to select multiple items.') }}
</span>
{% if watch['processor'] == 'image_ssim_diff' %}
<div id="selection-mode-controls" style="margin: 10px 0; padding: 10px; background: var(--color-background-tab); border-radius: 5px;">
<label style="font-weight: 600; margin-right: 15px;">Selection Mode:</label>
<label style="font-weight: 600; margin-right: 15px;">{{ _('Selection Mode:') }}</label>
<label style="margin-right: 15px;">
<input type="radio" name="selector-mode" value="element" style="margin-right: 5px;">
Select by element
{{ _('Select by element') }}
</label>
<label>
<input type="radio" name="selector-mode" value="draw" checked style="margin-right: 5px;">
Draw area
{{ _('Draw area') }}
</label>
{{ render_field(form.processor_config_bounding_box) }}
{{ render_field(form.processor_config_selection_mode) }}
@@ -405,9 +403,9 @@ Math: {{ 1 + 1 }}") }}
{% endif %}
<div id="selector-header">
<a id="clear-selector" class="pure-button button-secondary button-xsmall" style="font-size: 70%">Clear selection</a>
<a id="clear-selector" class="pure-button button-secondary button-xsmall" style="font-size: 70%">{{ _('Clear selection') }}</a>
<!-- visual selector IMG will try to load, it will either replace this or on error replace it with some handy text -->
<i class="fetching-update-notice" style="font-size: 80%;">One moment, fetching screenshot and element information..</i>
<i class="fetching-update-notice" style="font-size: 80%;">{{ _('One moment, fetching screenshot and element information..') }}</i>
</div>
<div id="selector-wrapper" style="display: none">
<!-- request the screenshot and get the element offset info ready -->
@@ -416,14 +414,14 @@ Math: {{ 1 + 1 }}") }}
<img id="selector-background" >
<canvas id="selector-canvas"></canvas>
</div>
<div id="selector-current-xpath" style="overflow-x: hidden"><strong>Currently:</strong>&nbsp;<span class="text">Loading...</span></div>
<div id="selector-current-xpath" style="overflow-x: hidden"><strong>{{ _('Currently:') }}</strong>&nbsp;<span class="text">{{ _('Loading...') }}</span></div>
{% else %}
<strong>Visual Selector data is not ready, watch needs to be checked atleast once.</strong>
<strong>{{ _('Visual Selector data is not ready, watch needs to be checked atleast once.') }}</strong>
{% endif %}
{% else %}
<p>
<strong>Sorry, this functionality only works with fetchers that support Javascript and screenshots (such as playwright etc).<br>
You need to <a href="#request">Set the fetch method</a> to one that supports Javascript and screenshots.</strong>
<strong>{{ _('Sorry, this functionality only works with fetchers that support Javascript and screenshots (such as playwright etc).') }}<br>
{{ _('You need to') }} <a href="#request">{{ _('Set the fetch method') }}</a> {{ _('to one that supports Javascript and screenshots.') }}</strong>
</p>
{% endif %}
</div>
@@ -440,27 +438,27 @@ Math: {{ 1 + 1 }}") }}
<table class="pure-table" id="stats-table">
<tbody>
<tr>
<td>Check count</td>
<td>{{ _('Check count') }}</td>
<td>{{ "{:,}".format( watch.check_count) }}</td>
</tr>
<tr>
<td>Consecutive filter failures</td>
<td>{{ _('Consecutive filter failures') }}</td>
<td>{{ "{:,}".format( watch.consecutive_filter_failures) }}</td>
</tr>
<tr>
<td>History length</td>
<td>{{ _('History length') }}</td>
<td>{{ "{:,}".format(watch.history|length) }}</td>
</tr>
<tr>
<td>Last fetch duration</td>
<td>{{ _('Last fetch duration') }}</td>
<td>{{ watch.fetch_time }}s</td>
</tr>
<tr>
<td>Notification alert count</td>
<td>{{ _('Notification alert count') }}</td>
<td>{{ watch.notification_alert_count }}</td>
</tr>
<tr>
<td>Server type reply</td>
<td>{{ _('Server type reply') }}</td>
<td>{{ watch.get('remote_server_reply') }}</td>
</tr>
</tbody>
@@ -474,7 +472,7 @@ Math: {{ 1 + 1 }}") }}
{% if watch.history_n %}
<p>
<a href="{{url_for('ui.ui_edit.watch_get_latest_html', uuid=uuid)}}" class="pure-button button-small">Download latest HTML snapshot</a>
<a href="{{url_for('ui.ui_edit.watch_get_latest_html', uuid=uuid)}}" class="pure-button button-small">{{ _('Download latest HTML snapshot') }}</a>
</p>
{% endif %}
@@ -487,19 +485,19 @@ Math: {{ 1 + 1 }}") }}
class="pure-button button-error"
data-requires-confirm
data-confirm-type="danger"
data-confirm-title="Delete Watch?"
data-confirm-message="<p>Are you sure you want to delete the watch for:</p><p><strong>{{ watch.get('url', 'this watch') }}</strong></p><p>This action cannot be undone.</p>"
data-confirm-button="Delete">Delete</a>
data-confirm-title="{{ _('Delete Watch?') }}"
data-confirm-message="<p>{{ _('Are you sure you want to delete the watch for:') }}</p><p><strong>{{ watch.get('url', 'this watch') }}</strong></p><p>{{ _('This action cannot be undone.') }}</p>"
data-confirm-button="{{ _('Delete') }}">{{ _('Delete') }}</a>
{% if watch.history_n %}<a href="{{url_for('ui.clear_watch_history', uuid=uuid)}}"
class="pure-button button-error"
data-requires-confirm
data-confirm-type="warning"
data-confirm-title="Clear History?"
data-confirm-message="<p>Are you sure you want to clear all history for:</p><p><strong>{{ watch.get('url', 'this watch') }}</strong></p><p>This will remove all snapshots and previous versions. This action cannot be undone.</p>"
data-confirm-button="Clear History">Clear History</a>{% endif %}
data-confirm-title="{{ _('Clear History?') }}"
data-confirm-message="<p>{{ _('Are you sure you want to clear all history for:') }}</p><p><strong>{{ watch.get('url', 'this watch') }}</strong></p><p>{{ _('This will remove all snapshots and previous versions. This action cannot be undone.') }}</p>"
data-confirm-button="{{ _('Clear History') }}">{{ _('Clear History') }}</a>{% endif %}
<a href="{{url_for('ui.form_clone', uuid=uuid)}}"
class="pure-button">Clone &amp; Edit</a>
<a href="{{ url_for('rss.rss_single_watch', uuid=uuid, token=app_rss_token)}}"><img alt="RSS Feed for this watch" style="padding: .5em 1em;" src="{{url_for('static_content', group='images', filename='generic_feed-icon.svg')}}" height="15"></a>
class="pure-button">{{ _('Clone & Edit') }}</a>
<a href="{{ url_for('rss.rss_single_watch', uuid=uuid, token=app_rss_token)}}"><img alt="{{ _('RSS Feed for this watch') }}" style="padding: .5em 1em;" src="{{url_for('static_content', group='images', filename='generic_feed-icon.svg')}}" height="15"></a>
</div>
</div>
</form>

View File

@@ -19,7 +19,7 @@
<div id="diff-form" style="text-align: center;">
<form class="pure-form " action="" method="POST">
<fieldset>
<label for="preview-version">Select timestamp</label> <select id="preview-version"
<label for="preview-version">{{ _('Select timestamp') }}</label> <select id="preview-version"
name="from_version"
class="needs-localtime">
{% for version in versions|reverse %}
@@ -28,27 +28,27 @@
</option>
{% endfor %}
</select>
<button type="submit" class="pure-button pure-button-primary">Go</button>
<button type="submit" class="pure-button pure-button-primary">{{ _('Go') }}</button>
</fieldset>
</form>
<br>
<strong>Keyboard: </strong><a href="" class="pure-button pure-button-primary" id="btn-previous">
&larr; Previous</a> &nbsp; <a class="pure-button pure-button-primary" id="btn-next" href="">
&rarr; Next</a>
<strong>{{ _('Keyboard:') }} </strong><a href="" class="pure-button pure-button-primary" id="btn-previous">
&larr; {{ _('Previous') }}</a> &nbsp; <a class="pure-button pure-button-primary" id="btn-next" href="">
&rarr; {{ _('Next') }}</a>
</div>
{% endif %}
<div class="tabs">
<ul>
{% if last_error_text %}
<li class="tab" id="error-text-tab"><a href="#error-text">Error Text</a></li> {% endif %}
<li class="tab" id="error-text-tab"><a href="#error-text">{{ _('Error Text') }}</a></li> {% endif %}
{% if last_error_screenshot %}
<li class="tab" id="error-screenshot-tab"><a href="#error-screenshot">Error Screenshot</a>
<li class="tab" id="error-screenshot-tab"><a href="#error-screenshot">{{ _('Error Screenshot') }}</a>
</li> {% endif %}
{% if history_n > 0 %}
<li class="tab" id="text-tab"><a href="#text">Text</a></li>
<li class="tab" id="screenshot-tab"><a href="#screenshot">Current screenshot</a></li>
<li class="tab" id="text-tab"><a href="#text">{{ _('Text') }}</a></li>
<li class="tab" id="screenshot-tab"><a href="#screenshot">{{ _('Current screenshot') }}</a></li>
{% endif %}
</ul>
</div>
@@ -56,17 +56,17 @@
<div id="diff-ui">
<div class="tab-pane-inner" id="error-text">
<div class="snapshot-age error">{{ watch.error_text_ctime|format_seconds_ago }} seconds ago</div>
<div class="snapshot-age error">{{ watch.error_text_ctime|format_seconds_ago }} {{ _('seconds ago') }}</div>
<pre>
{{ last_error_text }}
</pre>
</div>
<div class="tab-pane-inner" id="error-screenshot">
<div class="snapshot-age error">{{ watch.snapshot_error_screenshot_ctime|format_seconds_ago }} seconds ago
<div class="snapshot-age error">{{ watch.snapshot_error_screenshot_ctime|format_seconds_ago }} {{ _('seconds ago') }}
</div>
<img id="error-screenshot-img" style="max-width: 80%"
alt="Current erroring screenshot from most recent request">
alt="{{ _('Current erroring screenshot from most recent request') }}">
</div>
<div class="tab-pane-inner" id="text">
@@ -77,18 +77,18 @@
<div class="tab-pane-inner" id="screenshot">
<div class="tip">
For now, Differences are performed on text, not graphically, only the latest screenshot is available.
{{ _('For now, Differences are performed on text, not graphically, only the latest screenshot is available.') }}
</div>
<br>
{% if capabilities.supports_screenshots %}
{% if screenshot %}
<div class="snapshot-age">{{ watch.snapshot_screenshot_ctime|format_timestamp_timeago }}</div>
<img style="max-width: 80%" id="screenshot-img" alt="Current screenshot from most recent request">
<img style="max-width: 80%" id="screenshot-img" alt="{{ _('Current screenshot from most recent request') }}">
{% else %}
No screenshot available just yet! Try rechecking the page.
{{ _('No screenshot available just yet! Try rechecking the page.') }}
{% endif %}
{% else %}
<strong>Screenshot requires a Content Fetcher ( Sockpuppetbrowser, selenium, etc ) that supports screenshots.</strong>
<strong>{{ _('Screenshot requires a Content Fetcher ( Sockpuppetbrowser, selenium, etc ) that supports screenshots.') }}</strong>
{% endif %}
</div>
</div>

View File

@@ -28,11 +28,11 @@ document.addEventListener('DOMContentLoaded', function() {
<form class="pure-form" action="{{ url_for('ui.ui_views.form_quick_watch_add', tag=active_tag_uuid) }}" method="POST" id="new-watch-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" >
<fieldset>
<legend>Add a new web page change detection watch</legend>
<legend>{{ _('Add a new web page change detection watch') }}</legend>
<div id="watch-add-wrapper-zone">
{{ render_nolabel_field(form.url, placeholder="https://...", required=true) }}
{{ render_nolabel_field(form.watch_submit_button, title="Watch this URL!" ) }}
{{ render_nolabel_field(form.edit_and_watch_submit_button, title="Edit first then Watch") }}
{{ render_nolabel_field(form.watch_submit_button, title=_("Watch this URL!") ) }}
{{ render_nolabel_field(form.edit_and_watch_submit_button, title=_("Edit first then Watch") ) }}
</div>
<div id="watch-group-tag">
{{ render_field(form.tags, value=active_tag.title if active_tag_uuid else '', placeholder="Watch group / tag", class="transparent-field") }}
@@ -42,7 +42,7 @@ document.addEventListener('DOMContentLoaded', function() {
</div>
</fieldset>
<span style="color:#eee; font-size: 80%;"><img alt="Create a shareable link" style="height: 1em;display:inline-block;" src="{{url_for('static_content', group='images', filename='spread-white.svg')}}" > Tip: You can also add 'shared' watches. <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Sharing-a-Watch">More info</a></span>
<span style="color:#eee; font-size: 80%;"><img alt="{{ _('Create a shareable link') }}" style="height: 1em;display:inline-block;" src="{{url_for('static_content', group='images', filename='spread-white.svg')}}" > {{ _("Tip: You can also add 'shared' watches.") }} <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Sharing-a-Watch">{{ _('More info') }}</a></span>
</form>
</div>
<div class="box">
@@ -50,34 +50,34 @@ document.addEventListener('DOMContentLoaded', function() {
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" >
<input type="hidden" id="op_extradata" name="op_extradata" value="" >
<div id="checkbox-operations">
<button class="pure-button button-secondary button-xsmall" name="op" value="pause"><i data-feather="pause" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>Pause</button>
<button class="pure-button button-secondary button-xsmall" name="op" value="unpause"><i data-feather="play" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>UnPause</button>
<button class="pure-button button-secondary button-xsmall" name="op" value="mute"><i data-feather="volume-x" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>Mute</button>
<button class="pure-button button-secondary button-xsmall" name="op" value="unmute"><i data-feather="volume-2" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>UnMute</button>
<button class="pure-button button-secondary button-xsmall" name="op" value="recheck"><i data-feather="refresh-cw" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>Recheck</button>
<button class="pure-button button-secondary button-xsmall" name="op" value="assign-tag" id="checkbox-assign-tag"><i data-feather="tag" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>Tag</button>
<button class="pure-button button-secondary button-xsmall" name="op" value="mark-viewed"><i data-feather="eye" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>Mark viewed</button>
<button class="pure-button button-secondary button-xsmall" name="op" value="notification-default"><i data-feather="bell" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>Use default notification</button>
<button class="pure-button button-secondary button-xsmall" name="op" value="clear-errors"><i data-feather="x-circle" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>Clear errors</button>
<button class="pure-button button-secondary button-xsmall" name="op" value="pause"><i data-feather="pause" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>{{ _('Pause') }}</button>
<button class="pure-button button-secondary button-xsmall" name="op" value="unpause"><i data-feather="play" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>{{ _('UnPause') }}</button>
<button class="pure-button button-secondary button-xsmall" name="op" value="mute"><i data-feather="volume-x" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>{{ _('Mute') }}</button>
<button class="pure-button button-secondary button-xsmall" name="op" value="unmute"><i data-feather="volume-2" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>{{ _('UnMute') }}</button>
<button class="pure-button button-secondary button-xsmall" name="op" value="recheck"><i data-feather="refresh-cw" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>{{ _('Recheck') }}</button>
<button class="pure-button button-secondary button-xsmall" name="op" value="assign-tag" id="checkbox-assign-tag"><i data-feather="tag" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>{{ _('Tag') }}</button>
<button class="pure-button button-secondary button-xsmall" name="op" value="mark-viewed"><i data-feather="eye" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>{{ _('Mark viewed') }}</button>
<button class="pure-button button-secondary button-xsmall" name="op" value="notification-default"><i data-feather="bell" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>{{ _('Use default notification') }}</button>
<button class="pure-button button-secondary button-xsmall" name="op" value="clear-errors"><i data-feather="x-circle" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>{{ _('Clear errors') }}</button>
<button class="pure-button button-secondary button-xsmall" style="background: #dd4242;" name="op" value="clear-history"
data-requires-confirm
data-confirm-type="danger"
data-confirm-title="Clear Histories"
data-confirm-message="<p>Are you sure you want to clear history for the selected items?</p><p>This action cannot be undone.</p>"
data-confirm-button="OK"><i data-feather="trash-2" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>Clear/reset history</button>
data-confirm-title="{{ _('Clear Histories') }}"
data-confirm-message="{{ _('<p>Are you sure you want to clear history for the selected items?</p><p>This action cannot be undone.</p>') }}"
data-confirm-button="{{ _('OK') }}"><i data-feather="trash-2" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>{{ _('Clear/reset history') }}</button>
<button class="pure-button button-secondary button-xsmall" style="background: #dd4242;" name="op" value="delete"
data-requires-confirm
data-confirm-type="danger"
data-confirm-title="Delete Watches?"
data-confirm-message="<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>"
data-confirm-button="Delete"><i data-feather="trash" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>Delete</button>
data-confirm-title="{{ _('Delete Watches?') }}"
data-confirm-message="{{ _('<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>') }}"
data-confirm-button="{{ _('Delete') }}"><i data-feather="trash" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>{{ _('Delete') }}</button>
</div>
{%- if watches|length >= pagination.per_page -%}
{{ pagination.info }}
{%- endif -%}
{%- if search_q -%}<div id="search-result-info">Searching "<strong><i>{{search_q}}</i></strong>"</div>{%- endif -%}
{%- if search_q -%}<div id="search-result-info">{{ _('Searching') }} "<strong><i>{{search_q}}</i></strong>"</div>{%- endif -%}
<div>
<a href="{{url_for('watchlist.index')}}" class="pure-button button-tag {{'active' if not active_tag_uuid }}">All</a>
<a href="{{url_for('watchlist.index')}}" class="pure-button button-tag {{'active' if not active_tag_uuid }}">{{ _('All') }}</a>
<!-- tag list -->
{%- for uuid, tag in tags -%}
@@ -115,19 +115,19 @@ document.addEventListener('DOMContentLoaded', function() {
&nbsp;
<a class="{{ 'active '+link_order if sort_attribute == 'notification_muted' else 'inactive' }}" href="{{url_for('watchlist.index', sort='notification_muted', order=link_order, tag=active_tag_uuid)}}"><i data-feather="volume-2" style="vertical-align: bottom; width: 14px; height: 14px; margin-right: 4px;"></i><span class='arrow {{link_order}}'></span></a>
</th>
<th><a class="{{ 'active '+link_order if sort_attribute == 'label' else 'inactive' }}" href="{{url_for('watchlist.index', sort='label', order=link_order, tag=active_tag_uuid)}}">Website <span class='arrow {{link_order}}'></span></a></th>
<th><a class="{{ 'active '+link_order if sort_attribute == 'label' else 'inactive' }}" href="{{url_for('watchlist.index', sort='label', order=link_order, tag=active_tag_uuid)}}">{{ _('Website') }} <span class='arrow {{link_order}}'></span></a></th>
{%- if any_has_restock_price_processor -%}
<th>Restock &amp; Price</th>
<th>{{ _('Restock & Price') }}</th>
{%- endif -%}
<th><a class="{{ 'active '+link_order if sort_attribute == 'last_checked' else 'inactive' }}" href="{{url_for('watchlist.index', sort='last_checked', order=link_order, tag=active_tag_uuid)}}"><span class="hide-on-mobile">Last</span> Checked <span class='arrow {{link_order}}'></span></a></th>
<th><a class="{{ 'active '+link_order if sort_attribute == 'last_changed' else 'inactive' }}" href="{{url_for('watchlist.index', sort='last_changed', order=link_order, tag=active_tag_uuid)}}"><span class="hide-on-mobile">Last</span> Changed <span class='arrow {{link_order}}'></span></a></th>
<th><a class="{{ 'active '+link_order if sort_attribute == 'last_checked' else 'inactive' }}" href="{{url_for('watchlist.index', sort='last_checked', order=link_order, tag=active_tag_uuid)}}"><span class="hide-on-mobile">{{ _('Last') }}</span> {{ _('Checked') }} <span class='arrow {{link_order}}'></span></a></th>
<th><a class="{{ 'active '+link_order if sort_attribute == 'last_changed' else 'inactive' }}" href="{{url_for('watchlist.index', sort='last_changed', order=link_order, tag=active_tag_uuid)}}"><span class="hide-on-mobile">{{ _('Last') }}</span> {{ _('Changed') }} <span class='arrow {{link_order}}'></span></a></th>
<th class="empty-cell"></th>
</tr>
</thead>
<tbody>
{%- if not watches|length -%}
<tr>
<td colspan="{{ cols_required }}" style="text-wrap: wrap;">No website watches configured, please add a URL in the box above, or <a href="{{ url_for('imports.import_page')}}" >import a list</a>.</td>
<td colspan="{{ cols_required }}" style="text-wrap: wrap;">{{ _('No website watches configured, please add a URL in the box above, or') }} <a href="{{ url_for('imports.import_page')}}" >{{ _('import a list') }}</a>.</td>
</tr>
{%- endif -%}
@@ -210,20 +210,20 @@ document.addEventListener('DOMContentLoaded', function() {
<td class="restock-and-price">
{%- if watch['processor'] == 'restock_diff' -%}
{%- if watch.has_restock_info -%}
<span class="restock-label {{'in-stock' if watch['restock']['in_stock'] else 'not-in-stock' }}" title="Detecting restock and price">
<span class="restock-label {{'in-stock' if watch['restock']['in_stock'] else 'not-in-stock' }}" title="{{ _('Detecting restock and price') }}">
<!-- maybe some object watch['processor'][restock_diff] or.. -->
{%- if watch['restock']['in_stock']-%} In stock {%- else-%} Not in stock {%- endif -%}
{%- if watch['restock']['in_stock']-%} {{ _('In stock') }} {%- else-%} {{ _('Not in stock') }} {%- endif -%}
</span>
{%- endif -%}
{%- if watch.get('restock') and watch['restock']['price'] != None -%}
{%- if watch['restock']['price'] != None -%}
<span class="restock-label price" title="Price">
<span class="restock-label price" title="{{ _('Price') }}">
{{ watch['restock']['price']|format_number_locale if watch['restock'].get('price') else '' }} {{ watch['restock'].get('currency','') }}
</span>
{%- endif -%}
{%- elif not watch.has_restock_info -%}
<span class="restock-label error">No information</span>
<span class="restock-label error">{{ _('No information') }}</span>
{%- endif -%}
{%- endif -%}
</td>
@@ -231,24 +231,24 @@ document.addEventListener('DOMContentLoaded', function() {
{#last_checked becomes fetch-start-time#}
<td class="last-checked" data-timestamp="{{ watch.last_checked }}" data-fetchduration={{ watch.fetch_time }} data-eta_complete="{{ watch.last_checked+watch.fetch_time }}" >
<div class="spinner-wrapper" style="display:none;" >
<span class="spinner"></span><span class="status-text">&nbsp;Checking now</span>
<span class="spinner"></span><span class="status-text">&nbsp;{{ _('Checking now') }}</span>
</div>
<span class="innertext">{{watch|format_last_checked_time|safe}}</span>
</td>
<td class="last-changed" data-timestamp="{{ watch.last_changed }}">{%- if watch.history_n >=2 and watch.last_changed >0 -%}
{{watch.last_changed|format_timestamp_timeago}}
{%- else -%}
Not yet
{{ _('Not yet') }}
{%- endif -%}
</td>
<td class="buttons">
<div>
{%- set target_attr = ' target="' ~ watch.uuid ~ '"' if datastore.data['settings']['application']['ui'].get('open_diff_in_new_tab') else '' -%}
<a href="" class="already-in-queue-button recheck pure-button pure-button-primary" style="display: none;" disabled="disabled">Queued</a>
<a href="{{ url_for('ui.form_watch_checknow', uuid=watch.uuid, tag=request.args.get('tag')) }}" data-op='recheck' class="ajax-op recheck pure-button pure-button-primary">Recheck</a>
<a href="{{ url_for('ui.ui_edit.edit_page', uuid=watch.uuid, tag=active_tag_uuid)}}#general" class="pure-button pure-button-primary">Edit</a>
<a href="{{ url_for('ui.ui_diff.diff_history_page', uuid=watch.uuid)}}" {{target_attr}} class="pure-button pure-button-primary history-link" style="display: none;">History</a>
<a href="{{ url_for('ui.ui_preview.preview_page', uuid=watch.uuid)}}" {{target_attr}} class="pure-button pure-button-primary preview-link" style="display: none;">Preview</a>
<a href="" class="already-in-queue-button recheck pure-button pure-button-primary" style="display: none;" disabled="disabled">{{ _('Queued') }}</a>
<a href="{{ url_for('ui.form_watch_checknow', uuid=watch.uuid, tag=request.args.get('tag')) }}" data-op='recheck' class="ajax-op recheck pure-button pure-button-primary">{{ _('Recheck') }}</a>
<a href="{{ url_for('ui.ui_edit.edit_page', uuid=watch.uuid, tag=active_tag_uuid)}}#general" class="pure-button pure-button-primary">{{ _('Edit') }}</a>
<a href="{{ url_for('ui.ui_diff.diff_history_page', uuid=watch.uuid)}}" {{target_attr}} class="pure-button pure-button-primary history-link" style="display: none;">{{ _('History') }}</a>
<a href="{{ url_for('ui.ui_preview.preview_page', uuid=watch.uuid)}}" {{target_attr}} class="pure-button pure-button-primary preview-link" style="display: none;">{{ _('Preview') }}</a>
</div>
</td>
</tr>
@@ -257,22 +257,21 @@ document.addEventListener('DOMContentLoaded', function() {
</table>
<ul id="post-list-buttons">
<li id="post-list-with-errors" style="display: none;" >
<a href="{{url_for('watchlist.index', with_errors=1, tag=request.args.get('tag')) }}" class="pure-button button-tag button-error">With errors ({{ errored_count }})</a>
<a href="{{url_for('watchlist.index', with_errors=1, tag=request.args.get('tag')) }}" class="pure-button button-tag button-error">{{ _('With errors') }} ({{ errored_count }})</a>
</li>
<li id="post-list-mark-views" style="display: none;" >
<a href="{{url_for('ui.mark_all_viewed',with_errors=request.args.get('with_errors',0)) }}" class="pure-button button-tag " id="mark-all-viewed">Mark all viewed</a>
<a href="{{url_for('ui.mark_all_viewed',with_errors=request.args.get('with_errors',0)) }}" class="pure-button button-tag " id="mark-all-viewed">{{ _('Mark all viewed') }}</a>
</li>
{%- if active_tag_uuid -%}
<li id="post-list-mark-views-tag">
<a href="{{url_for('ui.mark_all_viewed', tag=active_tag_uuid) }}" class="pure-button button-tag " id="mark-all-viewed">Mark all viewed in '{{active_tag.title}}'</a>
<a href="{{url_for('ui.mark_all_viewed', tag=active_tag_uuid) }}" class="pure-button button-tag " id="mark-all-viewed">{{ _("Mark all viewed in '%(title)s'", title=active_tag.title) }}</a>
</li>
{%- endif -%}
<li id="post-list-unread" style="display: none;" >
<a href="{{url_for('watchlist.index', unread=1, tag=request.args.get('tag')) }}" class="pure-button button-tag">Unread (<span id="unread-tab-counter">{{ unread_changes_count }}</span>)</a>
<a href="{{url_for('watchlist.index', unread=1, tag=request.args.get('tag')) }}" class="pure-button button-tag">{{ _('Unread') }} (<span id="unread-tab-counter">{{ unread_changes_count }}</span>)</a>
</li>
<li>
<a href="{{ url_for('ui.form_watch_checknow', tag=active_tag_uuid, with_errors=request.args.get('with_errors',0)) }}" class="pure-button button-tag" id="recheck-all">Recheck
all {% if active_tag_uuid %} in '{{active_tag.title}}'{%endif%}</a>
<a href="{{ url_for('ui.form_watch_checknow', tag=active_tag_uuid, with_errors=request.args.get('with_errors',0)) }}" class="pure-button button-tag" id="recheck-all">{{ _('Recheck all') }} {% if active_tag_uuid %} {{ _("in '%(title)s'", title=active_tag.title) }}{%endif%}</a>
</li>
<li>
<a href="{{ url_for('rss.feed', tag=active_tag_uuid, token=app_rss_token)}}"><img alt="RSS Feed" id="feed-icon" src="{{url_for('static_content', group='images', filename='generic_feed-icon.svg')}}" height="15"></a>

View File

@@ -23,6 +23,7 @@ from flask import (
render_template,
request,
send_from_directory,
session,
url_for,
)
from flask_compress import Compress as FlaskCompress
@@ -34,6 +35,7 @@ from flask_cors import CORS
# Make this a global singleton to avoid multiple signal objects
watch_check_update = signal('watch_check_update', doc='Signal sent when a watch check is completed')
from flask_wtf import CSRFProtect
from flask_babel import Babel, gettext, get_locale
from loguru import logger
from changedetectionio import __version__
@@ -41,6 +43,7 @@ from changedetectionio import queuedWatchMetaData
from changedetectionio.api import Watch, WatchHistory, WatchSingleHistory, WatchHistoryDiff, CreateWatch, Import, SystemInfo, Tag, Tags, Notifications, WatchFavicon
from changedetectionio.api.Search import Search
from .time_handler import is_within_schedule
from changedetectionio.languages import get_available_languages, get_language_codes, get_flag_for_locale, get_timeago_locale
datastore = None
@@ -207,16 +210,26 @@ def _get_worker_status_info():
def _jinja2_filter_datetime(watch_obj, format="%Y-%m-%d %H:%M:%S"):
if watch_obj['last_checked'] == 0:
return 'Not yet'
return gettext('Not yet')
return timeago.format(int(watch_obj['last_checked']), time.time())
locale = get_timeago_locale(str(get_locale()))
try:
return timeago.format(int(watch_obj['last_checked']), time.time(), locale)
except:
# Fallback to English if locale not supported by timeago
return timeago.format(int(watch_obj['last_checked']), time.time(), 'en')
@app.template_filter('format_timestamp_timeago')
def _jinja2_filter_datetimestamp(timestamp, format="%Y-%m-%d %H:%M:%S"):
if not timestamp:
return 'Not yet'
return gettext('Not yet')
return timeago.format(int(timestamp), time.time())
locale = get_timeago_locale(str(get_locale()))
try:
return timeago.format(int(timestamp), time.time(), locale)
except:
# Fallback to English if locale not supported by timeago
return timeago.format(int(timestamp), time.time(), 'en')
@app.template_filter('pagination_slice')
@@ -230,7 +243,7 @@ def _jinja2_filter_pagination_slice(arr, skip):
@app.template_filter('format_seconds_ago')
def _jinja2_filter_seconds_precise(timestamp):
if timestamp == False:
return 'Not yet'
return gettext('Not yet')
return format(int(time.time()-timestamp), ',d')
@@ -347,7 +360,33 @@ def changedetection_app(config=None, datastore_o=None):
login_manager = flask_login.LoginManager(app)
login_manager.login_view = 'login'
app.secret_key = init_app_secret(config['datastore_path'])
# Initialize Flask-Babel for i18n support
available_languages = get_available_languages()
language_codes = get_language_codes()
def get_locale():
# 1. Try to get locale from session (user explicitly selected)
if 'locale' in session:
locale = session['locale']
print(f"DEBUG: get_locale() returning from session: {locale}")
return locale
# 2. Fall back to Accept-Language header
locale = request.accept_languages.best_match(language_codes)
print(f"DEBUG: get_locale() returning from Accept-Language: {locale}")
return locale
# Initialize Babel with locale selector
babel = Babel(app, locale_selector=get_locale)
# Make i18n functions available to templates
app.jinja_env.globals.update(
_=gettext,
get_locale=get_locale,
get_flag_for_locale=get_flag_for_locale,
available_languages=available_languages
)
# Set up a request hook to check authentication for all routes
@app.before_request
def check_authentication():
@@ -434,6 +473,18 @@ def changedetection_app(config=None, datastore_o=None):
flask_login.logout_user()
return redirect(url_for('watchlist.index'))
@app.route('/set-language/<locale>')
def set_language(locale):
"""Set the user's preferred language in the session"""
# Validate the locale against available languages
if locale in language_codes:
session['locale'] = locale
else:
logger.error(f"Invalid locale {locale}, available: {language_codes}")
# Redirect back to the page they came from, or home
return redirect(url_for('watchlist.index'))
# https://github.com/pallets/flask/blob/93dd1709d05a1cf0e886df6223377bdab3b077fb/examples/tutorial/flaskr/__init__.py#L39
# You can divide up the stuff like this
@app.route('/login', methods=['GET', 'POST'])

View File

@@ -2,6 +2,7 @@ import os
import re
from loguru import logger
from wtforms.widgets.core import TimeInput
from flask_babel import lazy_gettext as _l, gettext
from changedetectionio.blueprint.rss import RSS_FORMAT_TYPES, RSS_TEMPLATE_TYPE_OPTIONS, RSS_TEMPLATE_HTML_DEFAULT
from changedetectionio.conditions.form import ConditionFormRow
@@ -9,6 +10,7 @@ from changedetectionio.notification_service import NotificationContextData
from changedetectionio.processors.image_ssim_diff import SCREENSHOT_COMPARISON_THRESHOLD_OPTIONS, \
SCREENSHOT_COMPARISON_THRESHOLD_OPTIONS_DEFAULT
from changedetectionio.strtobool import strtobool
from changedetectionio import processors
from wtforms import (
BooleanField,
@@ -58,8 +60,8 @@ valid_method = {
default_method = 'GET'
allow_simplehost = not strtobool(os.getenv('BLOCK_SIMPLEHOSTS', 'False'))
REQUIRE_ATLEAST_ONE_TIME_PART_MESSAGE_DEFAULT='At least one time interval (weeks, days, hours, minutes, or seconds) must be specified.'
REQUIRE_ATLEAST_ONE_TIME_PART_WHEN_NOT_GLOBAL_DEFAULT='At least one time interval (weeks, days, hours, minutes, or seconds) must be specified when not using global settings.'
REQUIRE_ATLEAST_ONE_TIME_PART_MESSAGE_DEFAULT=_l('At least one time interval (weeks, days, hours, minutes, or seconds) must be specified.')
REQUIRE_ATLEAST_ONE_TIME_PART_WHEN_NOT_GLOBAL_DEFAULT=_l('At least one time interval (weeks, days, hours, minutes, or seconds) must be specified when not using global settings.')
class StringListField(StringField):
widget = widgets.TextArea()
@@ -159,7 +161,7 @@ class TimeStringField(Field):
time_str = valuelist[0]
# Simple validation for HH:MM format
if not time_str or len(time_str.split(":")) != 2:
raise ValidationError("Invalid time format. Use HH:MM.")
raise ValidationError(_l("Invalid time format. Use HH:MM."))
self.data = time_str
@@ -175,15 +177,15 @@ class validateTimeZoneName(object):
from zoneinfo import available_timezones
python_timezones = available_timezones()
if field.data and field.data not in python_timezones:
raise ValidationError("Not a valid timezone name")
raise ValidationError(_l("Not a valid timezone name"))
class ScheduleLimitDaySubForm(Form):
enabled = BooleanField("not set", default=True)
start_time = TimeStringField("Start At", default="00:00", validators=[validators.Optional()])
duration = FormField(TimeDurationForm, label="Run duration")
enabled = BooleanField(_l("not set"), default=True)
start_time = TimeStringField(_l("Start At"), default="00:00", validators=[validators.Optional()])
duration = FormField(TimeDurationForm, label=_l("Run duration"))
class ScheduleLimitForm(Form):
enabled = BooleanField("Use time scheduler", default=False)
enabled = BooleanField(_l("Use time scheduler"), default=False)
# Because the label for=""" doesnt line up/work with the actual checkbox
monday = FormField(ScheduleLimitDaySubForm, label="")
tuesday = FormField(ScheduleLimitDaySubForm, label="")
@@ -193,7 +195,7 @@ class ScheduleLimitForm(Form):
saturday = FormField(ScheduleLimitDaySubForm, label="")
sunday = FormField(ScheduleLimitDaySubForm, label="")
timezone = StringField("Optional timezone to run in",
timezone = StringField(_l("Optional timezone to run in"),
render_kw={"list": "timezones"},
validators=[validateTimeZoneName()]
)
@@ -207,13 +209,13 @@ class ScheduleLimitForm(Form):
**kwargs,
):
super().__init__(formdata, obj, prefix, data, meta, **kwargs)
self.monday.form.enabled.label.text="Monday"
self.tuesday.form.enabled.label.text = "Tuesday"
self.wednesday.form.enabled.label.text = "Wednesday"
self.thursday.form.enabled.label.text = "Thursday"
self.friday.form.enabled.label.text = "Friday"
self.saturday.form.enabled.label.text = "Saturday"
self.sunday.form.enabled.label.text = "Sunday"
self.monday.form.enabled.label.text=_l("Monday")
self.tuesday.form.enabled.label.text = _l("Tuesday")
self.wednesday.form.enabled.label.text = _l("Wednesday")
self.thursday.form.enabled.label.text = _l("Thursday")
self.friday.form.enabled.label.text = _l("Friday")
self.saturday.form.enabled.label.text = _l("Saturday")
self.sunday.form.enabled.label.text = _l("Sunday")
def validate_time_between_check_has_values(form):
@@ -238,7 +240,7 @@ class RequiredTimeInterval(object):
Use this with FormField(TimeBetweenCheckForm, validators=[RequiredTimeInterval()]).
"""
def __init__(self, message=None):
self.message = message or 'At least one time interval (weeks, days, hours, minutes, or seconds) must be specified.'
self.message = message or _l('At least one time interval (weeks, days, hours, minutes, or seconds) must be specified.')
def __call__(self, form, field):
if not validate_time_between_check_has_values(field.form):
@@ -246,11 +248,11 @@ class RequiredTimeInterval(object):
class TimeBetweenCheckForm(Form):
weeks = IntegerField('Weeks', validators=[validators.Optional(), validators.NumberRange(min=0, message="Should contain zero or more seconds")])
days = IntegerField('Days', validators=[validators.Optional(), validators.NumberRange(min=0, message="Should contain zero or more seconds")])
hours = IntegerField('Hours', validators=[validators.Optional(), validators.NumberRange(min=0, message="Should contain zero or more seconds")])
minutes = IntegerField('Minutes', validators=[validators.Optional(), validators.NumberRange(min=0, message="Should contain zero or more seconds")])
seconds = IntegerField('Seconds', validators=[validators.Optional(), validators.NumberRange(min=0, message="Should contain zero or more seconds")])
weeks = IntegerField(_l('Weeks'), validators=[validators.Optional(), validators.NumberRange(min=0, message=_l("Should contain zero or more seconds"))])
days = IntegerField(_l('Days'), validators=[validators.Optional(), validators.NumberRange(min=0, message=_l("Should contain zero or more seconds"))])
hours = IntegerField(_l('Hours'), validators=[validators.Optional(), validators.NumberRange(min=0, message=_l("Should contain zero or more seconds"))])
minutes = IntegerField(_l('Minutes'), validators=[validators.Optional(), validators.NumberRange(min=0, message=_l("Should contain zero or more seconds"))])
seconds = IntegerField(_l('Seconds'), validators=[validators.Optional(), validators.NumberRange(min=0, message=_l("Should contain zero or more seconds"))])
# @todo add total seconds minimum validatior = minimum_seconds_recheck_time
def __init__(self, formdata=None, obj=None, prefix="", data=None, meta=None, **kwargs):
@@ -720,18 +722,16 @@ class ValidateStartsWithRegex(object):
if not stripped:
if self.allow_empty:
continue
raise ValidationError(self.message or "Empty value not allowed.")
raise ValidationError(self.message or _l("Empty value not allowed."))
if not self.pattern.match(stripped):
raise ValidationError(self.message or "Invalid value.")
raise ValidationError(self.message or _l("Invalid value."))
class quickWatchForm(Form):
from . import processors
url = fields.URLField('URL', validators=[validateURL()])
tags = StringTagUUID('Group tag', [validators.Optional()])
watch_submit_button = SubmitField('Watch', render_kw={"class": "pure-button pure-button-primary"})
processor = RadioField(u'Processor', choices=processors.available_processors(), default="text_json_diff")
edit_and_watch_submit_button = SubmitField('Edit > Watch', render_kw={"class": "pure-button pure-button-primary"})
watch_submit_button = SubmitField(_l('Watch'), render_kw={"class": "pure-button pure-button-primary"})
processor = RadioField(_l('Processor'), choices=lambda: processors.available_processors(), default="text_json_diff")
edit_and_watch_submit_button = SubmitField(_l('Edit > Watch'), render_kw={"class": "pure-button pure-button-primary"})
# Common to a single watch and the global settings
@@ -744,14 +744,14 @@ class commonSettingsForm(Form):
self.notification_title.extra_notification_tokens = kwargs.get('extra_notification_tokens', {})
self.notification_urls.extra_notification_tokens = kwargs.get('extra_notification_tokens', {})
fetch_backend = RadioField(u'Fetch Method', choices=content_fetchers.available_fetchers(), validators=[ValidateContentFetcherIsReady()])
notification_body = TextAreaField('Notification Body', default='{{ watch_url }} had a change.', validators=[validators.Optional(), ValidateJinja2Template()])
notification_format = SelectField('Notification format', choices=list(valid_notification_formats.items()))
notification_title = StringField('Notification Title', default='ChangeDetection.io Notification - {{ watch_url }}', validators=[validators.Optional(), ValidateJinja2Template()])
notification_urls = StringListField('Notification URL List', validators=[validators.Optional(), ValidateAppRiseServers(), ValidateJinja2Template()])
processor = RadioField( label=u"Processor - What do you want to achieve?", choices=processors.available_processors(), default="text_json_diff")
scheduler_timezone_default = StringField("Default timezone for watch check scheduler", render_kw={"list": "timezones"}, validators=[validateTimeZoneName()])
webdriver_delay = IntegerField('Wait seconds before extracting text', validators=[validators.Optional(), validators.NumberRange(min=1, message="Should contain one or more seconds")])
fetch_backend = RadioField(_l('Fetch Method'), choices=content_fetchers.available_fetchers(), validators=[ValidateContentFetcherIsReady()])
notification_body = TextAreaField(_l('Notification Body'), default='{{ watch_url }} had a change.', validators=[validators.Optional(), ValidateJinja2Template()])
notification_format = SelectField(_l('Notification format'), choices=list(valid_notification_formats.items()))
notification_title = StringField(_l('Notification Title'), default='ChangeDetection.io Notification - {{ watch_url }}', validators=[validators.Optional(), ValidateJinja2Template()])
notification_urls = StringListField(_l('Notification URL List'), validators=[validators.Optional(), ValidateAppRiseServers(), ValidateJinja2Template()])
processor = RadioField( label=_l("Processor - What do you want to achieve?"), choices=lambda: processors.available_processors(), default="text_json_diff")
scheduler_timezone_default = StringField(_l("Default timezone for watch check scheduler"), render_kw={"list": "timezones"}, validators=[validateTimeZoneName()])
webdriver_delay = IntegerField(_l('Wait seconds before extracting text'), validators=[validators.Optional(), validators.NumberRange(min=1, message=_l("Should contain one or more seconds"))])
# Not true anymore but keep the validate_ hook for future use, we convert color tags
# def validate_notification_urls(self, field):
@@ -763,22 +763,21 @@ class commonSettingsForm(Form):
class importForm(Form):
from . import processors
processor = RadioField(u'Processor', choices=processors.available_processors(), default="text_json_diff")
urls = TextAreaField('URLs')
xlsx_file = FileField('Upload .xlsx file', validators=[FileAllowed(['xlsx'], 'Must be .xlsx file!')])
file_mapping = SelectField('File mapping', [validators.DataRequired()], choices={('wachete', 'Wachete mapping'), ('custom','Custom mapping')})
processor = RadioField(_l('Processor'), choices=lambda: processors.available_processors(), default="text_json_diff")
urls = TextAreaField(_l('URLs'))
xlsx_file = FileField(_l('Upload .xlsx file'), validators=[FileAllowed(['xlsx'], _l('Must be .xlsx file!'))])
file_mapping = SelectField(_l('File mapping'), [validators.DataRequired()], choices={('wachete', 'Wachete mapping'), ('custom','Custom mapping')})
class SingleBrowserStep(Form):
operation = SelectField('Operation', [validators.Optional()], choices=browser_step_ui_config.keys())
operation = SelectField(_l('Operation'), [validators.Optional()], choices=browser_step_ui_config.keys())
# maybe better to set some <script>var..
selector = StringField('Selector', [validators.Optional()], render_kw={"placeholder": "CSS or xPath selector"})
optional_value = StringField('value', [validators.Optional()], render_kw={"placeholder": "Value"})
selector = StringField(_l('Selector'), [validators.Optional()], render_kw={"placeholder": "CSS or xPath selector"})
optional_value = StringField(_l('value'), [validators.Optional()], render_kw={"placeholder": "Value"})
# @todo move to JS? ajax fetch new field?
# remove_button = SubmitField('-', render_kw={"type": "button", "class": "pure-button pure-button-primary", 'title': 'Remove'})
# add_button = SubmitField('+', render_kw={"type": "button", "class": "pure-button pure-button-primary", 'title': 'Add new step after'})
# remove_button = SubmitField(_l('-'), render_kw={"type": "button", "class": "pure-button pure-button-primary", 'title': 'Remove'})
# add_button = SubmitField(_l('+'), render_kw={"type": "button", "class": "pure-button pure-button-primary", 'title': 'Add new step after'})
class processor_text_json_diff_form(commonSettingsForm):
@@ -794,49 +793,48 @@ class processor_text_json_diff_form(commonSettingsForm):
time_schedule_limit = FormField(ScheduleLimitForm)
time_between_check_use_default = BooleanField('Use global settings for time between check and scheduler.', default=False)
time_between_check_use_default = BooleanField(_l('Use global settings for time between check and scheduler.'), default=False)
include_filters = StringListField('CSS/JSONPath/JQ/XPath Filters', [ValidateCSSJSONXPATHInput()], default='')
include_filters = StringListField(_l('CSS/JSONPath/JQ/XPath Filters'), [ValidateCSSJSONXPATHInput()], default='')
subtractive_selectors = StringListField('Remove elements', [ValidateCSSJSONXPATHInput(allow_json=False)])
subtractive_selectors = StringListField(_l('Remove elements'), [ValidateCSSJSONXPATHInput(allow_json=False)])
extract_text = StringListField('Extract text', [ValidateListRegex()])
extract_text = StringListField(_l('Extract text'), [ValidateListRegex()])
title = StringField('Title', default='')
title = StringField(_l('Title'), default='')
ignore_text = StringListField('Ignore lines containing', [ValidateListRegex()])
ignore_text = StringListField(_l('Ignore lines containing'), [ValidateListRegex()])
headers = StringDictKeyValue('Request headers')
body = TextAreaField('Request body', [validators.Optional()])
method = SelectField('Request method', choices=valid_method, default=default_method)
ignore_status_codes = BooleanField('Ignore status codes (process non-2xx status codes as normal)', default=False)
check_unique_lines = BooleanField('Only trigger when unique lines appear in all history', default=False)
remove_duplicate_lines = BooleanField('Remove duplicate lines of text', default=False)
sort_text_alphabetically = BooleanField('Sort text alphabetically', default=False)
strip_ignored_lines = TernaryNoneBooleanField('Strip ignored lines', default=None)
trim_text_whitespace = BooleanField('Trim whitespace before and after text', default=False)
body = TextAreaField(_l('Request body'), [validators.Optional()])
method = SelectField(_l('Request method'), choices=valid_method, default=default_method)
ignore_status_codes = BooleanField(_l('Ignore status codes (process non-2xx status codes as normal)'), default=False)
check_unique_lines = BooleanField(_l('Only trigger when unique lines appear in all history'), default=False)
remove_duplicate_lines = BooleanField(_l('Remove duplicate lines of text'), default=False)
sort_text_alphabetically = BooleanField(_l('Sort text alphabetically'), default=False)
strip_ignored_lines = TernaryNoneBooleanField(_l('Strip ignored lines'), default=None)
trim_text_whitespace = BooleanField(_l('Trim whitespace before and after text'), default=False)
filter_text_added = BooleanField('Added lines', default=True)
filter_text_replaced = BooleanField('Replaced/changed lines', default=True)
filter_text_removed = BooleanField('Removed lines', default=True)
filter_text_added = BooleanField(_l('Added lines'), default=True)
filter_text_replaced = BooleanField(_l('Replaced/changed lines'), default=True)
filter_text_removed = BooleanField(_l('Removed lines'), default=True)
trigger_text = StringListField('Keyword triggers - Trigger/wait for text', [validators.Optional(), ValidateListRegex()])
trigger_text = StringListField(_l('Keyword triggers - Trigger/wait for text'), [validators.Optional(), ValidateListRegex()])
if os.getenv("PLAYWRIGHT_DRIVER_URL"):
browser_steps = FieldList(FormField(SingleBrowserStep), min_entries=10)
text_should_not_be_present = StringListField('Block change-detection while text matches', [validators.Optional(), ValidateListRegex()])
webdriver_js_execute_code = TextAreaField('Execute JavaScript before change detection', render_kw={"rows": "5"}, validators=[validators.Optional()])
text_should_not_be_present = StringListField(_l('Block change-detection while text matches'), [validators.Optional(), ValidateListRegex()])
webdriver_js_execute_code = TextAreaField(_l('Execute JavaScript before change detection'), render_kw={"rows": "5"}, validators=[validators.Optional()])
save_button = SubmitField('Save', render_kw={"class": "pure-button pure-button-primary"})
save_button = SubmitField(_l('Save'), render_kw={"class": "pure-button pure-button-primary"})
proxy = RadioField('Proxy')
proxy = RadioField(_l('Proxy'))
# filter_failure_notification_send @todo make ternary
filter_failure_notification_send = BooleanField(
'Send a notification when the filter can no longer be found on the page', default=False)
notification_muted = TernaryNoneBooleanField('Notifications', default=None, yes_text="Muted", no_text="On")
notification_screenshot = BooleanField('Attach screenshot to notification (where possible)', default=False)
filter_failure_notification_send = BooleanField(_l('Send a notification when the filter can no longer be found on the page'), default=False)
notification_muted = TernaryNoneBooleanField(_l('Notifications'), default=None, yes_text=_l("Muted"), no_text=_l("On"))
notification_screenshot = BooleanField(_l('Attach screenshot to notification (where possible)'), default=False)
conditions_match_logic = RadioField(u'Match', choices=[('ALL', 'Match all of the following'),('ANY', 'Match any of the following')], default='ALL')
conditions_match_logic = RadioField(_l('Match'), choices=[('ALL', _l('Match all of the following')),('ANY', _l('Match any of the following'))], default='ALL')
conditions = FieldList(FormField(ConditionFormRow), min_entries=1) # Add rule logic here
use_page_title_in_list = TernaryNoneBooleanField('Use page <title> in list', default=None)
use_page_title_in_list = TernaryNoneBooleanField(_l('Use page <title> in list'), default=None)
def extra_tab_content(self):
return None
@@ -853,7 +851,7 @@ class processor_text_json_diff_form(commonSettingsForm):
# Fail form validation when a body is set for a GET
if self.method.data == 'GET' and self.body.data:
self.body.errors.append('Body must be empty when Request Method is set to GET')
self.body.errors.append(gettext('Body must be empty when Request Method is set to GET'))
result = False
# Attempt to validate jinja2 templates in the URL
@@ -862,11 +860,11 @@ class processor_text_json_diff_form(commonSettingsForm):
except ModuleNotFoundError as e:
# incase jinja2_time or others is missing
logger.error(e)
self.url.errors.append(f'Invalid template syntax configuration: {e}')
self.url.errors.append(gettext('Invalid template syntax configuration: %(error)s') % {'error': e})
result = False
except Exception as e:
logger.error(e)
self.url.errors.append(f'Invalid template syntax: {e}')
self.url.errors.append(gettext('Invalid template syntax: %(error)s') % {'error': e})
result = False
# Attempt to validate jinja2 templates in the body
@@ -876,11 +874,11 @@ class processor_text_json_diff_form(commonSettingsForm):
except ModuleNotFoundError as e:
# incase jinja2_time or others is missing
logger.error(e)
self.body.errors.append(f'Invalid template syntax configuration: {e}')
self.body.errors.append(gettext('Invalid template syntax configuration: %(error)s') % {'error': e})
result = False
except Exception as e:
logger.error(e)
self.body.errors.append(f'Invalid template syntax: {e}')
self.body.errors.append(gettext('Invalid template syntax: %(error)s') % {'error': e})
result = False
# Attempt to validate jinja2 templates in the headers
@@ -891,11 +889,11 @@ class processor_text_json_diff_form(commonSettingsForm):
except ModuleNotFoundError as e:
# incase jinja2_time or others is missing
logger.error(e)
self.headers.errors.append(f'Invalid template syntax configuration: {e}')
self.headers.errors.append(gettext('Invalid template syntax configuration: %(error)s') % {'error': e})
result = False
except Exception as e:
logger.error(e)
self.headers.errors.append(f'Invalid template syntax in "{header}" header: {e}')
self.headers.errors.append(gettext('Invalid template syntax in \"%(header)s\" header: %(error)s') % {'header': header, 'error': e})
result = False
return result
@@ -919,122 +917,122 @@ class processor_text_json_diff_form(commonSettingsForm):
class SingleExtraProxy(Form):
# maybe better to set some <script>var..
proxy_name = StringField('Name', [validators.Optional()], render_kw={"placeholder": "Name"})
proxy_url = StringField('Proxy URL', [
proxy_name = StringField(_l('Name'), [validators.Optional()], render_kw={"placeholder": "Name"})
proxy_url = StringField(_l('Proxy URL'), [
validators.Optional(),
ValidateStartsWithRegex(
regex=r'^(https?|socks5)://', # ✅ main pattern
flags=re.IGNORECASE, # ✅ makes it case-insensitive
message='Proxy URLs must start with http://, https:// or socks5://',
message=_l('Proxy URLs must start with http://, https:// or socks5://'),
),
ValidateSimpleURL()
], render_kw={"placeholder": "socks5:// or regular proxy http://user:pass@...:3128", "size":50})
class SingleExtraBrowser(Form):
browser_name = StringField('Name', [validators.Optional()], render_kw={"placeholder": "Name"})
browser_connection_url = StringField('Browser connection URL', [
browser_name = StringField(_l('Name'), [validators.Optional()], render_kw={"placeholder": "Name"})
browser_connection_url = StringField(_l('Browser connection URL'), [
validators.Optional(),
ValidateStartsWithRegex(
regex=r'^(wss?|ws)://',
flags=re.IGNORECASE,
message='Browser URLs must start with wss:// or ws://'
message=_l('Browser URLs must start with wss:// or ws://')
),
ValidateSimpleURL()
], render_kw={"placeholder": "wss://brightdata... wss://oxylabs etc", "size":50})
class DefaultUAInputForm(Form):
html_requests = StringField('Plaintext requests', validators=[validators.Optional()], render_kw={"placeholder": "<default>"})
html_requests = StringField(_l('Plaintext requests'), validators=[validators.Optional()], render_kw={"placeholder": "<default>"})
if os.getenv("PLAYWRIGHT_DRIVER_URL") or os.getenv("WEBDRIVER_URL"):
html_webdriver = StringField('Chrome requests', validators=[validators.Optional()], render_kw={"placeholder": "<default>"})
html_webdriver = StringField(_l('Chrome requests'), validators=[validators.Optional()], render_kw={"placeholder": "<default>"})
# datastore.data['settings']['requests']..
class globalSettingsRequestForm(Form):
time_between_check = RequiredFormField(TimeBetweenCheckForm)
time_schedule_limit = FormField(ScheduleLimitForm)
proxy = RadioField('Default proxy')
jitter_seconds = IntegerField('Random jitter seconds ± check',
proxy = RadioField(_l('Default proxy'))
jitter_seconds = IntegerField(_l('Random jitter seconds ± check'),
render_kw={"style": "width: 5em;"},
validators=[validators.NumberRange(min=0, message="Should contain zero or more seconds")])
validators=[validators.NumberRange(min=0, message=_l("Should contain zero or more seconds"))])
workers = IntegerField('Number of fetch workers',
workers = IntegerField(_l('Number of fetch workers'),
render_kw={"style": "width: 5em;"},
validators=[validators.NumberRange(min=1, max=50,
message="Should be between 1 and 50")])
message=_l("Should be between 1 and 50"))])
timeout = IntegerField('Requests timeout in seconds',
timeout = IntegerField(_l('Requests timeout in seconds'),
render_kw={"style": "width: 5em;"},
validators=[validators.NumberRange(min=1, max=999,
message="Should be between 1 and 999")])
message=_l("Should be between 1 and 999"))])
extra_proxies = FieldList(FormField(SingleExtraProxy), min_entries=5)
extra_browsers = FieldList(FormField(SingleExtraBrowser), min_entries=5)
default_ua = FormField(DefaultUAInputForm, label="Default User-Agent overrides")
default_ua = FormField(DefaultUAInputForm, label=_l("Default User-Agent overrides"))
def validate_extra_proxies(self, extra_validators=None):
for e in self.data['extra_proxies']:
if e.get('proxy_name') or e.get('proxy_url'):
if not e.get('proxy_name','').strip() or not e.get('proxy_url','').strip():
self.extra_proxies.errors.append('Both a name, and a Proxy URL is required.')
self.extra_proxies.errors.append(gettext('Both a name, and a Proxy URL is required.'))
return False
class globalSettingsApplicationUIForm(Form):
open_diff_in_new_tab = BooleanField("Open 'History' page in a new tab", default=True, validators=[validators.Optional()])
socket_io_enabled = BooleanField('Realtime UI Updates Enabled', default=True, validators=[validators.Optional()])
favicons_enabled = BooleanField('Favicons Enabled', default=True, validators=[validators.Optional()])
use_page_title_in_list = BooleanField('Use page <title> in watch overview list') #BooleanField=True
open_diff_in_new_tab = BooleanField(_l("Open 'History' page in a new tab"), default=True, validators=[validators.Optional()])
socket_io_enabled = BooleanField(_l('Realtime UI Updates Enabled'), default=True, validators=[validators.Optional()])
favicons_enabled = BooleanField(_l('Favicons Enabled'), default=True, validators=[validators.Optional()])
use_page_title_in_list = BooleanField(_l('Use page <title> in watch overview list')) #BooleanField=True
# datastore.data['settings']['application']..
class globalSettingsApplicationForm(commonSettingsForm):
api_access_token_enabled = BooleanField('API access token security check enabled', default=True, validators=[validators.Optional()])
base_url = StringField('Notification base URL override',
api_access_token_enabled = BooleanField(_l('API access token security check enabled'), default=True, validators=[validators.Optional()])
base_url = StringField(_l('Notification base URL override'),
validators=[validators.Optional()],
render_kw={"placeholder": os.getenv('BASE_URL', 'Not set')}
)
empty_pages_are_a_change = BooleanField('Treat empty pages as a change?', default=False)
fetch_backend = RadioField('Fetch Method', default="html_requests", choices=content_fetchers.available_fetchers(), validators=[ValidateContentFetcherIsReady()])
global_ignore_text = StringListField('Ignore Text', [ValidateListRegex()])
global_subtractive_selectors = StringListField('Remove elements', [ValidateCSSJSONXPATHInput(allow_json=False)])
ignore_whitespace = BooleanField('Ignore whitespace')
empty_pages_are_a_change = BooleanField(_l('Treat empty pages as a change?'), default=False)
fetch_backend = RadioField(_l('Fetch Method'), default="html_requests", choices=content_fetchers.available_fetchers(), validators=[ValidateContentFetcherIsReady()])
global_ignore_text = StringListField(_l('Ignore Text'), [ValidateListRegex()])
global_subtractive_selectors = StringListField(_l('Remove elements'), [ValidateCSSJSONXPATHInput(allow_json=False)])
ignore_whitespace = BooleanField(_l('Ignore whitespace'))
# Screenshot comparison settings
min_change_percentage = FloatField(
'Screenshot: Minimum Change Percentage',
validators=[
validators.Optional(),
validators.NumberRange(min=0.0, max=100.0, message='Must be between 0 and 100')
validators.NumberRange(min=0.0, max=100.0, message=_l('Must be between 0 and 100'))
],
default=0.1,
render_kw={"placeholder": "0.1", "style": "width: 8em;"}
)
password = SaltyPasswordField()
pager_size = IntegerField('Pager size',
pager_size = IntegerField(_l('Pager size'),
render_kw={"style": "width: 5em;"},
validators=[validators.NumberRange(min=0,
message="Should be atleast zero (disabled)")])
message=_l("Should be atleast zero (disabled)"))])
rss_content_format = SelectField('RSS Content format', choices=list(RSS_FORMAT_TYPES.items()))
rss_template_type = SelectField('RSS <description> body built from', choices=list(RSS_TEMPLATE_TYPE_OPTIONS.items()))
rss_template_override = TextAreaField('RSS "System default" template override', render_kw={"rows": "5", "placeholder": RSS_TEMPLATE_HTML_DEFAULT}, validators=[validators.Optional(), ValidateJinja2Template()])
rss_content_format = SelectField(_l('RSS Content format'), choices=list(RSS_FORMAT_TYPES.items()))
rss_template_type = SelectField(_l('RSS <description> body built from'), choices=list(RSS_TEMPLATE_TYPE_OPTIONS.items()))
rss_template_override = TextAreaField(_l('RSS "System default" template override'), render_kw={"rows": "5", "placeholder": RSS_TEMPLATE_HTML_DEFAULT}, validators=[validators.Optional(), ValidateJinja2Template()])
removepassword_button = SubmitField('Remove password', render_kw={"class": "pure-button pure-button-primary"})
render_anchor_tag_content = BooleanField('Render anchor tag content', default=False)
shared_diff_access = BooleanField('Allow anonymous access to watch history page when password is enabled', default=False, validators=[validators.Optional()])
strip_ignored_lines = BooleanField('Strip ignored lines')
rss_hide_muted_watches = BooleanField('Hide muted watches from RSS feed', default=True,
removepassword_button = SubmitField(_l('Remove password'), render_kw={"class": "pure-button pure-button-primary"})
render_anchor_tag_content = BooleanField(_l('Render anchor tag content'), default=False)
shared_diff_access = BooleanField(_l('Allow anonymous access to watch history page when password is enabled'), default=False, validators=[validators.Optional()])
strip_ignored_lines = BooleanField(_l('Strip ignored lines'))
rss_hide_muted_watches = BooleanField(_l('Hide muted watches from RSS feed'), default=True,
validators=[validators.Optional()])
rss_reader_mode = BooleanField('Enable RSS reader mode ', default=False, validators=[validators.Optional()])
rss_diff_length = IntegerField(label='Number of changes to show in watch RSS feed',
rss_reader_mode = BooleanField(_l('Enable RSS reader mode '), default=False, validators=[validators.Optional()])
rss_diff_length = IntegerField(label=_l('Number of changes to show in watch RSS feed'),
render_kw={"style": "width: 5em;"},
validators=[validators.NumberRange(min=0, message="Should contain zero or more attempts")])
validators=[validators.NumberRange(min=0, message=_l("Should contain zero or more attempts"))])
filter_failure_notification_threshold_attempts = IntegerField('Number of times the filter can be missing before sending a notification',
filter_failure_notification_threshold_attempts = IntegerField(_l('Number of times the filter can be missing before sending a notification'),
render_kw={"style": "width: 5em;"},
validators=[validators.NumberRange(min=0,
message="Should contain zero or more attempts")])
message=_l("Should contain zero or more attempts"))])
ui = FormField(globalSettingsApplicationUIForm)
@@ -1050,9 +1048,9 @@ class globalSettingsForm(Form):
requests = FormField(globalSettingsRequestForm)
application = FormField(globalSettingsApplicationForm)
save_button = SubmitField('Save', render_kw={"class": "pure-button pure-button-primary"})
save_button = SubmitField(_l('Save'), render_kw={"class": "pure-button pure-button-primary"})
class extractDataForm(Form):
extract_regex = StringField('RegEx to extract', validators=[validators.DataRequired(), ValidateSinglePythonRegexString()])
extract_submit_button = SubmitField('Extract as CSV', render_kw={"class": "pure-button pure-button-primary"})
extract_regex = StringField(_l('RegEx to extract'), validators=[validators.DataRequired(), ValidateSinglePythonRegexString()])
extract_submit_button = SubmitField(_l('Extract as CSV'), render_kw={"class": "pure-button pure-button-primary"})

View File

@@ -0,0 +1,103 @@
"""
Language configuration for i18n support
Automatically discovers available languages from translations directory
"""
import os
from pathlib import Path
def get_timeago_locale(flask_locale):
"""
Convert Flask-Babel locale codes to timeago library locale codes.
The Python timeago library (https://github.com/hustcc/timeago) supports 48 locales
but uses different naming conventions than Flask-Babel. This function maps between them.
Notable differences:
- Chinese: Flask uses 'zh', timeago uses 'zh_CN'
- Portuguese: Flask uses 'pt', timeago uses 'pt_PT' or 'pt_BR'
- Swedish: Flask uses 'sv', timeago uses 'sv_SE'
- Norwegian: Flask uses 'no', timeago uses 'nb_NO' or 'nn_NO'
- Hindi: Flask uses 'hi', timeago uses 'in_HI'
- Czech: Flask uses 'cs', but timeago doesn't support Czech - fallback to English
Args:
flask_locale (str): Flask-Babel locale code (e.g., 'cs', 'zh', 'pt')
Returns:
str: timeago library locale code (e.g., 'en', 'zh_CN', 'pt_PT')
"""
locale_map = {
'zh': 'zh_CN', # Chinese Simplified
'pt': 'pt_PT', # Portuguese (Portugal)
'sv': 'sv_SE', # Swedish
'no': 'nb_NO', # Norwegian Bokmål
'hi': 'in_HI', # Hindi
'cs': 'en', # Czech not supported by timeago, fallback to English
}
return locale_map.get(flask_locale, flask_locale)
# Language metadata: flag icon CSS class and native name
# Using flag-icons library: https://flagicons.lipis.dev/
LANGUAGE_DATA = {
'en': {'flag': 'fi fi-gb fis', 'name': 'English'},
'de': {'flag': 'fi fi-de fis', 'name': 'Deutsch'},
'fr': {'flag': 'fi fi-fr fis', 'name': 'Français'},
'ko': {'flag': 'fi fi-kr fis', 'name': '한국어'},
'cs': {'flag': 'fi fi-cz fis', 'name': 'Čeština'},
'es': {'flag': 'fi fi-es fis', 'name': 'Español'},
'pt': {'flag': 'fi fi-pt fis', 'name': 'Português'},
'it': {'flag': 'fi fi-it fis', 'name': 'Italiano'},
'ja': {'flag': 'fi fi-jp fis', 'name': '日本語'},
'zh': {'flag': 'fi fi-cn fis', 'name': '中文 (简体)'},
'zh_TW': {'flag': 'fi fi-tw fis', 'name': '繁體中文'},
'ru': {'flag': 'fi fi-ru fis', 'name': 'Русский'},
'pl': {'flag': 'fi fi-pl fis', 'name': 'Polski'},
'nl': {'flag': 'fi fi-nl fis', 'name': 'Nederlands'},
'sv': {'flag': 'fi fi-se fis', 'name': 'Svenska'},
'da': {'flag': 'fi fi-dk fis', 'name': 'Dansk'},
'no': {'flag': 'fi fi-no fis', 'name': 'Norsk'},
'fi': {'flag': 'fi fi-fi fis', 'name': 'Suomi'},
'tr': {'flag': 'fi fi-tr fis', 'name': 'Türkçe'},
'ar': {'flag': 'fi fi-sa fis', 'name': 'العربية'},
'hi': {'flag': 'fi fi-in fis', 'name': 'हिन्दी'},
}
def get_available_languages():
"""
Discover available languages by scanning the translations directory
Returns a dict of available languages with their metadata
"""
translations_dir = Path(__file__).parent / 'translations'
# Always include English as base language
available = {
'en': LANGUAGE_DATA['en']
}
# Scan for translation directories
if translations_dir.exists():
for lang_dir in translations_dir.iterdir():
if lang_dir.is_dir() and lang_dir.name in LANGUAGE_DATA:
# Check if messages.po exists
po_file = lang_dir / 'LC_MESSAGES' / 'messages.po'
if po_file.exists():
available[lang_dir.name] = LANGUAGE_DATA[lang_dir.name]
return available
def get_language_codes():
"""Get list of available language codes"""
return list(get_available_languages().keys())
def get_flag_for_locale(locale):
"""Get flag emoji for a locale, or globe if unknown"""
return LANGUAGE_DATA.get(locale, {}).get('flag', '🌐')
def get_name_for_locale(locale):
"""Get native name for a locale"""
return LANGUAGE_DATA.get(locale, {}).get('name', locale.upper())

View File

@@ -430,7 +430,14 @@ def get_plugin_template_paths():
Returns:
list: List of absolute paths to plugin template directories
"""
template_paths = ['changedetectionio/processors/templates']
template_paths = []
# Add the base processors/templates directory (as absolute path)
processors_templates_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'processors', 'templates')
if os.path.isdir(processors_templates_dir):
template_paths.append(processors_templates_dir)
logger.debug(f"Added base processors template path: {processors_templates_dir}")
# Scan built-in processor plugins
from changedetectionio.processors import find_processors
processor_list = find_processors()

View File

@@ -1,5 +1,6 @@
from functools import lru_cache
from loguru import logger
from flask_babel import gettext
import importlib
import inspect
import os
@@ -123,12 +124,12 @@ def available_processors():
# Try to get the 'name' attribute from the processor module first
if hasattr(module, 'name'):
description = module.name
description = gettext(module.name)
else:
# Fall back to processor_description from parent module's __init__.py
parent_module = get_parent_module(module)
if parent_module and hasattr(parent_module, 'processor_description'):
description = parent_module.processor_description
description = gettext(parent_module.processor_description)
else:
# Final fallback to a readable name
description = sub_package_name.replace('_', ' ').title()
@@ -154,11 +155,10 @@ def available_processors():
return [(name, desc) for name, desc, weight in available]
@lru_cache(maxsize=1)
def get_processor_badge_texts():
"""
Get a dictionary mapping processor names to their list_badge_text values.
Cached to avoid repeated lookups.
Translations are applied based on the current request locale.
:return: A dict mapping processor name to badge text (e.g., {'text_json_diff': 'Text', 'restock_diff': 'Restock'})
"""
@@ -168,21 +168,20 @@ def get_processor_badge_texts():
for module, sub_package_name in processor_classes:
# Try to get the 'list_badge_text' attribute from the processor module
if hasattr(module, 'list_badge_text'):
badge_texts[sub_package_name] = module.list_badge_text
badge_texts[sub_package_name] = gettext(module.list_badge_text)
else:
# Fall back to parent module's __init__.py
parent_module = get_parent_module(module)
if parent_module and hasattr(parent_module, 'list_badge_text'):
badge_texts[sub_package_name] = parent_module.list_badge_text
badge_texts[sub_package_name] = gettext(parent_module.list_badge_text)
return badge_texts
@lru_cache(maxsize=1)
def get_processor_descriptions():
"""
Get a dictionary mapping processor names to their description/name values.
Cached to avoid repeated lookups.
Translations are applied based on the current request locale.
:return: A dict mapping processor name to description (e.g., {'text_json_diff': 'Webpage Text/HTML, JSON and PDF changes'})
"""
@@ -192,16 +191,16 @@ def get_processor_descriptions():
for module, sub_package_name in processor_classes:
# Try to get the 'name' or 'description' attribute from the processor module first
if hasattr(module, 'name'):
descriptions[sub_package_name] = module.name
descriptions[sub_package_name] = gettext(module.name)
elif hasattr(module, 'description'):
descriptions[sub_package_name] = module.description
descriptions[sub_package_name] = gettext(module.description)
else:
# Fall back to parent module's __init__.py
parent_module = get_parent_module(module)
if parent_module and hasattr(parent_module, 'processor_description'):
descriptions[sub_package_name] = parent_module.processor_description
descriptions[sub_package_name] = gettext(parent_module.processor_description)
elif parent_module and hasattr(parent_module, 'name'):
descriptions[sub_package_name] = parent_module.name
descriptions[sub_package_name] = gettext(parent_module.name)
else:
# Final fallback to a readable name
descriptions[sub_package_name] = sub_package_name.replace('_', ' ').title()

View File

@@ -3,6 +3,7 @@ Configuration forms for fast screenshot comparison processor.
"""
from wtforms import SelectField, StringField, validators, ValidationError, IntegerField
from flask_babel import lazy_gettext as _l
from changedetectionio.forms import processor_text_json_diff_form
import re
@@ -15,19 +16,19 @@ def validate_bounding_box(form, field):
return # Optional field
if len(field.data) > 100:
raise ValidationError('Bounding box value is too long')
raise ValidationError(_l('Bounding box value is too long'))
# Should be comma-separated integers
if not re.match(r'^\d+,\d+,\d+,\d+$', field.data):
raise ValidationError('Bounding box must be in format: x,y,width,height (integers only)')
raise ValidationError(_l('Bounding box must be in format: x,y,width,height (integers only)'))
# Validate values are reasonable (not negative, not ridiculously large)
parts = [int(p) for p in field.data.split(',')]
for part in parts:
if part < 0:
raise ValidationError('Bounding box values must be non-negative')
raise ValidationError(_l('Bounding box values must be non-negative'))
if part > 10000: # Reasonable max screen dimension
raise ValidationError('Bounding box values are too large')
raise ValidationError(_l('Bounding box values are too large'))
def validate_selection_mode(form, field):
@@ -36,25 +37,25 @@ def validate_selection_mode(form, field):
return # Optional field
if field.data not in ['element', 'draw']:
raise ValidationError('Selection mode must be either "element" or "draw"')
raise ValidationError(_l('Selection mode must be either "element" or "draw"'))
class processor_settings_form(processor_text_json_diff_form):
"""Form for fast image comparison processor settings."""
processor_config_min_change_percentage = IntegerField(
'Minimum Change Percentage',
_l('Minimum Change Percentage'),
validators=[
validators.Optional(),
validators.NumberRange(min=1, max=100, message='Must be between 0 and 100')
validators.NumberRange(min=1, max=100, message=_l('Must be between 0 and 100'))
],
render_kw={"placeholder": "Use global default (0.1)"}
)
processor_config_pixel_difference_threshold_sensitivity = SelectField(
'Pixel Difference Sensitivity',
_l('Pixel Difference Sensitivity'),
choices=[
('', 'Use global default')
('', _l('Use global default'))
] + SCREENSHOT_COMPARISON_THRESHOLD_OPTIONS,
validators=[validators.Optional()],
default=''
@@ -62,20 +63,20 @@ class processor_settings_form(processor_text_json_diff_form):
# Processor-specific config fields (stored in separate JSON file)
processor_config_bounding_box = StringField(
'Bounding Box',
_l('Bounding Box'),
validators=[
validators.Optional(),
validators.Length(max=100, message='Bounding box value is too long'),
validators.Length(max=100, message=_l('Bounding box value is too long')),
validate_bounding_box
],
render_kw={"style": "display: none;", "id": "bounding_box"}
)
processor_config_selection_mode = StringField(
'Selection Mode',
_l('Selection Mode'),
validators=[
validators.Optional(),
validators.Length(max=20, message='Selection mode value is too long'),
validators.Length(max=20, message=_l('Selection mode value is too long')),
validate_selection_mode
],
render_kw={"style": "display: none;", "id": "selection_mode"}
@@ -83,7 +84,7 @@ class processor_settings_form(processor_text_json_diff_form):
def extra_tab_content(self):
"""Tab label for processor-specific settings."""
return 'Screenshot Comparison'
return _l('Screenshot Comparison')
def extra_form_content(self):
"""Render processor-specific form fields.

View File

@@ -6,34 +6,35 @@ from wtforms import (
from wtforms.fields.choices import RadioField
from wtforms.fields.form import FormField
from wtforms.form import Form
from flask_babel import lazy_gettext as _l
from changedetectionio.forms import processor_text_json_diff_form
class RestockSettingsForm(Form):
in_stock_processing = RadioField(label='Re-stock detection', choices=[
('in_stock_only', "In Stock only (Out Of Stock -> In Stock only)"),
('all_changes', "Any availability changes"),
('off', "Off, don't follow availability/restock"),
in_stock_processing = RadioField(label=_l('Re-stock detection'), choices=[
('in_stock_only', _l("In Stock only (Out Of Stock -> In Stock only)")),
('all_changes', _l("Any availability changes")),
('off', _l("Off, don't follow availability/restock")),
], default="in_stock_only")
price_change_min = FloatField('Below price to trigger notification', [validators.Optional()],
render_kw={"placeholder": "No limit", "size": "10"})
price_change_max = FloatField('Above price to trigger notification', [validators.Optional()],
render_kw={"placeholder": "No limit", "size": "10"})
price_change_threshold_percent = FloatField('Threshold in % for price changes since the original price', validators=[
price_change_min = FloatField(_l('Below price to trigger notification'), [validators.Optional()],
render_kw={"placeholder": _l("No limit"), "size": "10"})
price_change_max = FloatField(_l('Above price to trigger notification'), [validators.Optional()],
render_kw={"placeholder": _l("No limit"), "size": "10"})
price_change_threshold_percent = FloatField(_l('Threshold in %% for price changes since the original price'), validators=[
validators.Optional(),
validators.NumberRange(min=0, max=100, message="Should be between 0 and 100"),
validators.NumberRange(min=0, max=100, message=_l("Should be between 0 and 100")),
], render_kw={"placeholder": "0%", "size": "5"})
follow_price_changes = BooleanField('Follow price changes', default=True)
follow_price_changes = BooleanField(_l('Follow price changes'), default=True)
class processor_settings_form(processor_text_json_diff_form):
restock_settings = FormField(RestockSettingsForm)
def extra_tab_content(self):
return 'Restock & Price Detection'
return _l('Restock & Price Detection')
def extra_form_content(self):
output = ""

View File

@@ -7,10 +7,11 @@ import urllib3
import time
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'
# Translatable strings - extracted by pybabel, translated at runtime in __init__.py
name = 'Re-stock & Price detection for pages with a SINGLE product' # _()
description = 'Detects if the product goes back to in-stock' # _()
processor_weight = 1
list_badge_text = "Restock"
list_badge_text = "Restock" # _()
class UnableToExtractRestockData(Exception):
def __init__(self, status_code):

View File

@@ -1,5 +1,6 @@
import timeago
from flask_socketio import SocketIO
from flask_babel import gettext, get_locale
import time
import os
@@ -7,6 +8,7 @@ from loguru import logger
from blinker import signal
from changedetectionio import strtobool
from changedetectionio.languages import get_timeago_locale
class SignalHandler:
@@ -166,7 +168,7 @@ def handle_watch_update(socketio, **kwargs):
'has_error': True if error_texts else False,
'has_favicon': True if watch.get_favicon_filename() else False,
'history_n': watch.history_n,
'last_changed_text': timeago.format(int(watch.last_changed), time.time()) if watch.history_n >= 2 and int(watch.last_changed) > 0 else 'Not yet',
'last_changed_text': timeago.format(int(watch.last_changed), time.time(), get_timeago_locale(str(get_locale()))) if watch.history_n >= 2 and int(watch.last_changed) > 0 else gettext('Not yet'),
'last_checked': watch.get('last_checked'),
'last_checked_text': _jinja2_filter_datetime(watch),
'notification_muted': True if watch.get('notification_muted') else False,

View File

@@ -0,0 +1,55 @@
/**
* Language selector modal functionality
* Allows users to select their preferred language
*/
document.addEventListener('DOMContentLoaded', function() {
const languageButton = document.getElementById('language-selector');
const languageModal = document.getElementById('language-modal');
const closeButton = document.getElementById('close-language-modal');
if (!languageButton || !languageModal) {
return;
}
// Open modal when language button is clicked
languageButton.addEventListener('click', function(e) {
e.preventDefault();
languageModal.showModal();
});
// Close modal when cancel button is clicked
if (closeButton) {
closeButton.addEventListener('click', function() {
languageModal.close();
});
}
// Close modal when clicking outside (on backdrop)
languageModal.addEventListener('click', function(e) {
const rect = languageModal.getBoundingClientRect();
if (
e.clientY < rect.top ||
e.clientY > rect.bottom ||
e.clientX < rect.left ||
e.clientX > rect.right
) {
languageModal.close();
}
});
// Close modal on Escape key
languageModal.addEventListener('cancel', function(e) {
e.preventDefault();
languageModal.close();
});
// Highlight current language
const currentLocale = document.documentElement.lang || 'en';
const languageOptions = languageModal.querySelectorAll('.language-option');
languageOptions.forEach(function(option) {
if (option.dataset.locale === currentLocale) {
option.classList.add('active');
}
});
});

View File

@@ -1159,3 +1159,44 @@ ul#highlightSnippetActions {
}
}
// Language Selector Modal Styles
.language-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.5rem 0;
}
.language-option {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
border-radius: 4px;
transition: background-color 0.2s ease;
text-decoration: none;
color: var(--color-text);
border: 1px solid transparent;
&:hover {
background-color: var(--color-background-menu-link-hover);
border-color: var(--color-border-table-cell);
}
&.active {
background-color: var(--color-link);
color: var(--color-text-button);
font-weight: 600;
}
.flag {
font-size: 1.5rem;
flex-shrink: 0;
}
.language-name {
flex-grow: 1;
font-size: 1rem;
}
}

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
{% macro render_field(field) %}
<div {% if field.errors or field.top_errors %} class="error" {% endif %}>{{ field.label }}</div>
<div {% if field.errors or field.top_errors %} class="error" {% endif %}><label for="{{ field.id }}">{{ field.label.text | string | forceescape }}</label></div>
<div {% if field.errors or field.top_errors %} class="error" {% endif %}>{{ field(**kwargs)|safe }}
{% if field.top_errors %}
top
@@ -46,7 +46,7 @@
{% macro render_checkbox_field(field) %}
<div class="checkbox {% if field.errors %} error {% endif %}">
{{ field(**kwargs)|safe }} {{ field.label }}
{{ field(**kwargs)|safe }} <label for="{{ field.id }}">{{ field.label.text | string | forceescape }}</label>
{% if field.errors %}
<ul class=errors>
{% for error in field.errors %}
@@ -62,7 +62,7 @@
{% set _ = field.__setattr__('boolean_mode', true) %}
{% endif %}
<div class="ternary-field {% if field.errors %} error {% endif %}">
<div class="ternary-field-label">{{ field.label }}</div>
<div class="ternary-field-label"><label for="{{ field.id }}">{{ field.label.text | string | forceescape }}</label></div>
<div class="ternary-field-widget">{{ field(**kwargs)|safe }}</div>
{% if field.errors %}
<ul class=errors>
@@ -76,7 +76,7 @@
{% macro render_simple_field(field) %}
<span class="label {% if field.errors %}error{% endif %}">{{ field.label }}</span>
<span class="label {% if field.errors %}error{% endif %}"><label for="{{ field.id }}">{{ field.label.text | string | forceescape }}</label></span>
<span {% if field.errors %} class="error" {% endif %}>{{ field(**kwargs)|safe }}
{% if field.errors %}
<ul class=errors>

View File

@@ -16,6 +16,7 @@
{%- endif -%}
{%- endif -%}
<link rel="stylesheet" href="{{url_for('static_content', group='styles', filename='pure-min.css')}}" >
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/lipis/flag-icons@7.2.3/css/flag-icons.min.css" >
<link rel="stylesheet" href="{{url_for('static_content', group='styles', filename='styles.css')}}?v={{ get_css_version() }}" >
{% if extra_stylesheets %}
{% for m in extra_stylesheets %}
@@ -73,20 +74,20 @@
{% if current_user.is_authenticated or not has_password %}
{% if not current_diff_url %}
<li class="pure-menu-item">
<a href="{{ url_for('tags.tags_overview_page')}}" class="pure-menu-link">GROUPS</a>
<a href="{{ url_for('tags.tags_overview_page')}}" class="pure-menu-link">{{ _('GROUPS') }}</a>
</li>
<li class="pure-menu-item">
<a href="{{ url_for('settings.settings_page')}}" class="pure-menu-link">SETTINGS</a>
<a href="{{ url_for('settings.settings_page')}}" class="pure-menu-link">{{ _('SETTINGS') }}</a>
</li>
<li class="pure-menu-item">
<a href="{{ url_for('imports.import_page')}}" class="pure-menu-link">IMPORT</a>
<a href="{{ url_for('imports.import_page')}}" class="pure-menu-link">{{ _('IMPORT') }}</a>
</li>
<li class="pure-menu-item">
<a href="{{ url_for('backups.index')}}" class="pure-menu-link">BACKUPS</a>
<a href="{{ url_for('backups.index')}}" class="pure-menu-link">{{ _('BACKUPS') }}</a>
</li>
{% else %}
<li class="pure-menu-item">
<a href="{{ url_for('ui.ui_edit.edit_page', uuid=uuid, next='diff') }}" class="pure-menu-link">EDIT</a>
<a href="{{ url_for('ui.ui_edit.edit_page', uuid=uuid, next='diff') }}" class="pure-menu-link">{{ _('EDIT') }}</a>
</li>
{% endif %}
{% else %}
@@ -96,7 +97,7 @@
{% endif %}
{% if current_user.is_authenticated %}
<li class="pure-menu-item">
<a href="{{url_for('logout')}}" class="pure-menu-link">LOG OUT</a>
<a href="{{url_for('logout')}}" class="pure-menu-link">{{ _('LOG OUT') }}</a>
</li>
{% endif %}
{% if current_user.is_authenticated or not has_password %}
@@ -105,15 +106,15 @@
<form name="searchForm" action="" method="GET">
<input id="search-q" class="" name="q" placeholder="URL or Title {% if active_tag_uuid %}in '{{ active_tag.title }}'{% endif %}" required="" type="text" value="">
<input name="tags" type="hidden" value="{% if active_tag_uuid %}{{active_tag_uuid}}{% endif %}">
<button class="toggle-button " id="toggle-search" type="button" title="Search, or Use Alt+S Key" >
<button class="toggle-button " id="toggle-search" type="button" title="{{ _('Search, or Use Alt+S Key') }}" >
{% include "svgs/search-icon.svg" %}
</button>
</form>
</li>
{% endif %}
<li class="pure-menu-item">
<button class="toggle-button" id ="toggle-light-mode" type="button" title="Toggle Light/Dark Mode">
<span class="visually-hidden">Toggle light/dark mode</span>
<button class="toggle-button" id ="toggle-light-mode" type="button" title="{{ _('Toggle Light/Dark Mode') }}">
<span class="visually-hidden">{{ _('Toggle light/dark mode') }}</span>
<span class="icon-light">
{% include "svgs/light-mode-toggle-icon.svg" %}
</span>
@@ -122,6 +123,12 @@
</span>
</button>
</li>
<li class="pure-menu-item">
<button class="toggle-button" id="language-selector" type="button" title="{{ _('Change Language') }}">
<span class="visually-hidden">{{ _('Change language') }}</span>
<span class="{{ get_flag_for_locale(get_locale()) }}" style="display: inline-block; width: 1.2em; height: 1.2em; vertical-align: middle; border-radius: 50%; overflow: hidden;"></span>
</button>
</li>
<li class="pure-menu-item" id="heart-us">
<svg
fill="#ff0000"
@@ -238,13 +245,34 @@
</section>
<script src="{{url_for('static_content', group='js', filename='toggle-theme.js')}}" defer></script>
<div id="checking-now-fixed-tab" style="display: none;"><span class="spinner"></span><span class="status-text">&nbsp;Checking now</span></div>
<div id="realtime-conn-error" style="display:none">Real-time updates offline</div>
<div id="checking-now-fixed-tab" style="display: none;"><span class="spinner"></span><span class="status-text">&nbsp;{{ _('Checking now') }}</span></div>
<div id="realtime-conn-error" style="display:none">{{ _('Real-time updates offline') }}</div>
{% if bottom_horizontal_offscreen_contents %}
<div id="bottom-horizontal-offscreen" style="display:none">
{{ bottom_horizontal_offscreen_contents|safe }}
</div>
{% endif %}
<!-- Language Selection Modal -->
<dialog id="language-modal" class="modal-dialog" aria-labelledby="language-modal-title">
<div class="modal-header">
<h2 class="modal-title" id="language-modal-title">{{ _('Select Language') }}</h2>
</div>
<div class="modal-body">
<div class="language-list">
{% for locale, lang_data in available_languages.items()|sort %}
<a href="{{ url_for('set_language', locale=locale) }}" class="language-option" data-locale="{{ locale }}">
<span class="{{ lang_data.flag }}" style="display: inline-block; width: 1.5em; height: 1.5em; vertical-align: middle; margin-right: 0.5em; border-radius: 50%; overflow: hidden;"></span> <span class="language-name">{{ lang_data.name }}</span>
</a>
{% endfor %}
</div>
</div>
<div class="modal-footer">
<button type="button" class="pure-button" id="close-language-modal">{{ _('Cancel') }}</button>
</div>
</dialog>
<script src="{{url_for('static_content', group='js', filename='language-selector.js')}}" defer></script>
</body>
</html>

View File

@@ -7,13 +7,13 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<fieldset>
<div class="pure-control-group">
<label for="password">Password</label>
<label for="password">{{ _('Password') }}</label>
<input type="password" id="password" required="" name="password" value=""
size="15" autofocus />
<input type="hidden" id="email" name="email" value="defaultuser@changedetection.io" >
</div>
<div class="pure-control-group">
<button type="submit" class="pure-button pure-button-primary">Login</button>
<button type="submit" class="pure-button pure-button-primary">{{ _('Login') }}</button>
</div>
</fieldset>
</form>

View File

@@ -26,6 +26,9 @@ def test_check_extract_text_from_diff(client, live_server, measure_memory_usage,
assert b"1 Imported" in res.data
wait_for_all_checks(client)
res = client.get(url_for("ui.ui_diff.diff_history_page_extract_GET", uuid="first"))
assert res.status_code == 200
assert b'extract_regex' in res.data
# Load in 5 different numbers/changes
last_date=""
@@ -40,9 +43,6 @@ def test_check_extract_text_from_diff(client, live_server, measure_memory_usage,
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
res = client.get(url_for("ui.ui_diff.diff_history_page_extract_POST", uuid="first"))
assert res.status_code == 200
assert b'extract_regex' in res.data
res = client.post(

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -113,6 +113,8 @@ extruct
# For cleaning up unknown currency formats
babel
# For internationalization (i18n) support
Flask-Babel>=4.0.0
levenshtein