Compare commits

...

6 Commits

Author SHA1 Message Date
dgtlmoon
2a3d3b89d5 Login message - move to login render, dont duplicate 2026-01-02 16:29:42 +01:00
dgtlmoon
f3174743e9 Improving translation, adding test 2026-01-02 16:23:18 +01:00
dgtlmoon
6e205b736b Adding test and fixing lang setting 2026-01-02 15:58:58 +01:00
dgtlmoon
44c615963e Adding italian 2026-01-02 15:48:06 +01:00
dgtlmoon
63271d58e1 Adding italian 2026-01-02 15:34:59 +01:00
dgtlmoon
59d42a8398 Adding flash() translations 2026-01-02 15:31:22 +01:00
36 changed files with 10622 additions and 1010 deletions

View File

@@ -3,6 +3,7 @@ import glob
import threading
from flask import Blueprint, render_template, send_from_directory, flash, url_for, redirect, abort
from flask_babel import gettext
import os
from changedetectionio.store import ChangeDetectionStore
@@ -82,11 +83,11 @@ def construct_blueprint(datastore: ChangeDetectionStore):
@backups_blueprint.route("/request-backup", methods=['GET'])
def request_backup():
if any(thread.is_alive() for thread in backup_threads):
flash("A backup is already running, check back in a few minutes", "error")
flash(gettext("A backup is already running, check back in a few minutes"), "error")
return redirect(url_for('backups.index'))
if len(find_backups()) > int(os.getenv("MAX_NUMBER_BACKUPS", 100)):
flash("Maximum number of backups reached, please remove some", "error")
flash(gettext("Maximum number of backups reached, please remove some"), "error")
return redirect(url_for('backups.index'))
# Be sure we're written fresh
@@ -94,7 +95,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
zip_thread = threading.Thread(target=create_backup, args=(datastore.datastore_path, datastore.data.get("watching")))
zip_thread.start()
backup_threads.append(zip_thread)
flash("Backup building in background, check back in a few minutes.")
flash(gettext("Backup building in background, check back in a few minutes."))
return redirect(url_for('backups.index'))
@@ -157,7 +158,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
for backup in backups:
os.unlink(backup)
flash("Backups were deleted.")
flash(gettext("Backups were deleted."))
return redirect(url_for('backups.index'))

View File

@@ -2,6 +2,7 @@ from abc import abstractmethod
import time
from wtforms import ValidationError
from loguru import logger
from flask_babel import gettext
from changedetectionio.forms import validate_url
@@ -41,7 +42,7 @@ class import_url_list(Importer):
now = time.time()
if (len(urls) > 5000):
flash("Importing 5,000 of the first URLs from your list, the rest can be imported again.")
flash(gettext("Importing 5,000 of the first URLs from your list, the rest can be imported again."))
for url in urls:
url = url.strip()
@@ -74,7 +75,7 @@ class import_url_list(Importer):
self.remaining_data = []
self.remaining_data.append(url)
flash("{} Imported from list in {:.2f}s, {} Skipped.".format(good, time.time() - now, len(self.remaining_data)))
flash(gettext("{} Imported from list in {:.2f}s, {} Skipped.").format(good, time.time() - now, len(self.remaining_data)))
class import_distill_io_json(Importer):
@@ -94,11 +95,11 @@ class import_distill_io_json(Importer):
try:
data = json.loads(data.strip())
except json.decoder.JSONDecodeError:
flash("Unable to read JSON file, was it broken?", 'error')
flash(gettext("Unable to read JSON file, was it broken?"), 'error')
return
if not data.get('data'):
flash("JSON structure looks invalid, was it broken?", 'error')
flash(gettext("JSON structure looks invalid, was it broken?"), 'error')
return
for d in data.get('data'):
@@ -135,7 +136,7 @@ class import_distill_io_json(Importer):
self.new_uuids.append(new_uuid)
good += 1
flash("{} Imported from Distill.io in {:.2f}s, {} Skipped.".format(len(self.new_uuids), time.time() - now, len(self.remaining_data)))
flash(gettext("{} Imported from Distill.io in {:.2f}s, {} Skipped.").format(len(self.new_uuids), time.time() - now, len(self.remaining_data)))
class import_xlsx_wachete(Importer):
@@ -156,7 +157,7 @@ class import_xlsx_wachete(Importer):
wb = load_workbook(data)
except Exception as e:
# @todo correct except
flash("Unable to read export XLSX file, something wrong with the file?", 'error')
flash(gettext("Unable to read export XLSX file, something wrong with the file?"), 'error')
return
row_id = 2
@@ -196,7 +197,7 @@ class import_xlsx_wachete(Importer):
validate_url(data.get('url'))
except ValidationError as e:
logger.error(f">> Import URL error {data.get('url')} {str(e)}")
flash(f"Error processing row number {row_id}, URL value was incorrect, row was skipped.", 'error')
flash(gettext("Error processing row number {}, URL value was incorrect, row was skipped.").format(row_id), 'error')
# Don't bother processing anything else on this row
continue
@@ -210,12 +211,11 @@ class import_xlsx_wachete(Importer):
good += 1
except Exception as e:
logger.error(e)
flash(f"Error processing row number {row_id}, check all cell data types are correct, row was skipped.", 'error')
flash(gettext("Error processing row number {}, check all cell data types are correct, row was skipped.").format(row_id), 'error')
else:
row_id += 1
flash(
"{} imported from Wachete .xlsx in {:.2f}s".format(len(self.new_uuids), time.time() - now))
flash(gettext("{} imported from Wachete .xlsx in {:.2f}s").format(len(self.new_uuids), time.time() - now))
class import_xlsx_custom(Importer):
@@ -236,7 +236,7 @@ class import_xlsx_custom(Importer):
wb = load_workbook(data)
except Exception as e:
# @todo correct except
flash("Unable to read export XLSX file, something wrong with the file?", 'error')
flash(gettext("Unable to read export XLSX file, something wrong with the file?"), 'error')
return
# @todo cehck atleast 2 rows, same in other method
@@ -265,7 +265,7 @@ class import_xlsx_custom(Importer):
validate_url(url)
except ValidationError as e:
logger.error(f">> Import URL error {url} {str(e)}")
flash(f"Error processing row number {row_i}, URL value was incorrect, row was skipped.", 'error')
flash(gettext("Error processing row number {}, URL value was incorrect, row was skipped.").format(row_i), 'error')
# Don't bother processing anything else on this row
url = None
break
@@ -294,9 +294,8 @@ class import_xlsx_custom(Importer):
good += 1
except Exception as e:
logger.error(e)
flash(f"Error processing row number {row_i}, check all cell data types are correct, row was skipped.", 'error')
flash(gettext("Error processing row number {}, check all cell data types are correct, row was skipped.").format(row_i), 'error')
else:
row_i += 1
flash(
"{} imported from custom .xlsx in {:.2f}s".format(len(self.new_uuids), time.time() - now))
flash(gettext("{} imported from custom .xlsx in {:.2f}s").format(len(self.new_uuids), time.time() - now))

View File

@@ -5,6 +5,7 @@ from zoneinfo import ZoneInfo, available_timezones
import secrets
import flask_login
from flask import Blueprint, render_template, request, redirect, url_for, flash
from flask_babel import gettext
from changedetectionio.store import ChangeDetectionStore
from changedetectionio.auth_decorator import login_optionally_required
@@ -60,7 +61,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
# SALTED_PASS means the password is "locked" to what we set in the Env var
if not os.getenv("SALTED_PASS", False):
datastore.remove_password()
flash("Password protection removed.", 'notice')
flash(gettext("Password protection removed."), 'notice')
flask_login.logout_user()
return redirect(url_for('settings.settings_page'))
@@ -94,16 +95,16 @@ def construct_blueprint(datastore: ChangeDetectionStore):
)
if result['status'] == 'success':
flash(f"Worker count adjusted: {result['message']}", 'notice')
flash(gettext("Worker count adjusted: {}").format(result['message']), 'notice')
elif result['status'] == 'not_supported':
flash("Dynamic worker adjustment not supported for sync workers", 'warning')
flash(gettext("Dynamic worker adjustment not supported for sync workers"), 'warning')
elif result['status'] == 'error':
flash(f"Error adjusting workers: {result['message']}", 'error')
flash(gettext("Error adjusting workers: {}").format(result['message']), 'error')
if not os.getenv("SALTED_PASS", False) and len(form.application.form.password.encrypted_password):
datastore.data['settings']['application']['password'] = form.application.form.password.encrypted_password
datastore.needs_write_urgent = True
flash("Password protection enabled.", 'notice')
flash(gettext("Password protection enabled."), 'notice')
flask_login.logout_user()
return redirect(url_for('watchlist.index'))
@@ -122,10 +123,10 @@ def construct_blueprint(datastore: ChangeDetectionStore):
if plugin_form.data:
save_plugin_settings(datastore.datastore_path, plugin_id, plugin_form.data)
flash("Settings updated.")
flash(gettext("Settings updated."))
else:
flash("An error occurred, please see below.", "error")
flash(gettext("An error occurred, please see below."), "error")
# Convert to ISO 8601 format, all date/time relative events stored as UTC time
utc_time = datetime.now(ZoneInfo("UTC")).isoformat()
@@ -175,7 +176,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
secret = secrets.token_hex(16)
datastore.data['settings']['application']['api_access_token'] = secret
datastore.needs_write_urgent = True
flash("API Key was regenerated.")
flash(gettext("API Key was regenerated."))
return redirect(url_for('settings.settings_page')+'#api')
@settings_blueprint.route("/notification-logs", methods=['GET'])

View File

@@ -1,5 +1,5 @@
from flask import Blueprint, request, render_template, flash, url_for, redirect
from flask_babel import gettext
from changedetectionio.store import ChangeDetectionStore
from changedetectionio.flask_app import login_optionally_required
@@ -43,11 +43,11 @@ def construct_blueprint(datastore: ChangeDetectionStore):
title = request.form.get('name').strip()
if datastore.tag_exists_by_name(title):
flash(f'The tag "{title}" already exists', "error")
flash(gettext('The tag "{}" already exists').format(title), "error")
return redirect(url_for('tags.tags_overview_page'))
datastore.add_tag(title)
flash("Tag added")
flash(gettext("Tag added"))
return redirect(url_for('tags.tags_overview_page'))
@@ -72,7 +72,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
removed += 1
watch['tags'].remove(uuid)
flash(f"Tag deleted and removed from {removed} watches")
flash(gettext("Tag deleted and removed from {} watches").format(removed))
return redirect(url_for('tags.tags_overview_page'))
@tags_blueprint.route("/unlink/<string:uuid>", methods=['GET'])
@@ -84,7 +84,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
unlinked += 1
watch['tags'].remove(uuid)
flash(f"Tag unlinked removed from {unlinked} watches")
flash(gettext("Tag unlinked removed from {} watches").format(unlinked))
return redirect(url_for('tags.tags_overview_page'))
@tags_blueprint.route("/delete_all", methods=['GET'])
@@ -94,7 +94,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
watch['tags'] = []
datastore.data['settings']['application']['tags'] = {}
flash(f"All tags deleted")
flash(gettext("All tags deleted"))
return redirect(url_for('tags.tags_overview_page'))
@tags_blueprint.route("/edit/<string:uuid>", methods=['GET'])
@@ -106,7 +106,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
default = datastore.data['settings']['application']['tags'].get(uuid)
if not default:
flash("Tag not found", "error")
flash(gettext("Tag not found"), "error")
return redirect(url_for('watchlist.index'))
form = group_restock_settings_form(
@@ -181,7 +181,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
datastore.data['settings']['application']['tags'][uuid].update(form.data)
datastore.data['settings']['application']['tags'][uuid]['processor'] = 'restock_diff'
datastore.needs_write_urgent = True
flash("Updated")
flash(gettext("Updated"))
return redirect(url_for('tags.tags_overview_page'))

View File

@@ -1,5 +1,6 @@
import time
from flask import Blueprint, request, redirect, url_for, flash, render_template, session
from flask_babel import gettext
from loguru import logger
from changedetectionio.store import ChangeDetectionStore
@@ -16,42 +17,42 @@ def _handle_operations(op, uuids, datastore, worker_handler, update_q, queuedWat
if datastore.data['watching'].get(uuid):
datastore.delete(uuid)
if emit_flash:
flash(f"{len(uuids)} watches deleted")
flash(gettext("{} watches deleted").format(len(uuids)))
elif op == 'pause':
for uuid in uuids:
if datastore.data['watching'].get(uuid):
datastore.data['watching'][uuid]['paused'] = True
if emit_flash:
flash(f"{len(uuids)} watches paused")
flash(gettext("{} watches paused").format(len(uuids)))
elif op == 'unpause':
for uuid in uuids:
if datastore.data['watching'].get(uuid):
datastore.data['watching'][uuid.strip()]['paused'] = False
if emit_flash:
flash(f"{len(uuids)} watches unpaused")
flash(gettext("{} watches unpaused").format(len(uuids)))
elif (op == 'mark-viewed'):
for uuid in uuids:
if datastore.data['watching'].get(uuid):
datastore.set_last_viewed(uuid, int(time.time()))
if emit_flash:
flash(f"{len(uuids)} watches updated")
flash(gettext("{} watches updated").format(len(uuids)))
elif (op == 'mute'):
for uuid in uuids:
if datastore.data['watching'].get(uuid):
datastore.data['watching'][uuid]['notification_muted'] = True
if emit_flash:
flash(f"{len(uuids)} watches muted")
flash(gettext("{} watches muted").format(len(uuids)))
elif (op == 'unmute'):
for uuid in uuids:
if datastore.data['watching'].get(uuid):
datastore.data['watching'][uuid]['notification_muted'] = False
if emit_flash:
flash(f"{len(uuids)} watches un-muted")
flash(gettext("{} watches un-muted").format(len(uuids)))
elif (op == 'recheck'):
for uuid in uuids:
@@ -59,21 +60,21 @@ def _handle_operations(op, uuids, datastore, worker_handler, update_q, queuedWat
# Recheck and require a full reprocessing
worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
if emit_flash:
flash(f"{len(uuids)} watches queued for rechecking")
flash(gettext("{} watches queued for rechecking").format(len(uuids)))
elif (op == 'clear-errors'):
for uuid in uuids:
if datastore.data['watching'].get(uuid):
datastore.data['watching'][uuid]["last_error"] = False
if emit_flash:
flash(f"{len(uuids)} watches errors cleared")
flash(gettext("{} watches errors cleared").format(len(uuids)))
elif (op == 'clear-history'):
for uuid in uuids:
if datastore.data['watching'].get(uuid):
datastore.clear_watch_history(uuid)
if emit_flash:
flash(f"{len(uuids)} watches cleared/reset.")
flash(gettext("{} watches cleared/reset.").format(len(uuids)))
elif (op == 'notification-default'):
from changedetectionio.notification import (
@@ -86,7 +87,7 @@ def _handle_operations(op, uuids, datastore, worker_handler, update_q, queuedWat
datastore.data['watching'][uuid]['notification_urls'] = []
datastore.data['watching'][uuid]['notification_format'] = USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH
if emit_flash:
flash(f"{len(uuids)} watches set to use default notification settings")
flash(gettext("{} watches set to use default notification settings").format(len(uuids)))
elif (op == 'assign-tag'):
op_extradata = extra_data
@@ -101,7 +102,7 @@ def _handle_operations(op, uuids, datastore, worker_handler, update_q, queuedWat
datastore.data['watching'][uuid]['tags'].append(tag_uuid)
if emit_flash:
flash(f"{len(uuids)} watches were tagged")
flash(gettext("{} watches were tagged").format(len(uuids)))
if uuids:
for uuid in uuids:
@@ -138,9 +139,9 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_handle
try:
datastore.clear_watch_history(uuid)
except KeyError:
flash('Watch not found', 'error')
flash(gettext('Watch not found'), 'error')
else:
flash("Cleared snapshot history for watch {}".format(uuid))
flash(gettext("Cleared snapshot history for watch {}").format(uuid))
return redirect(url_for('watchlist.index'))
@ui_blueprint.route("/clear_history", methods=['GET', 'POST'])
@@ -152,9 +153,9 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_handle
if confirmtext == 'clear':
for uuid in datastore.data['watching'].keys():
datastore.clear_watch_history(uuid)
flash("Cleared snapshot history for all watches")
flash(gettext("Cleared snapshot history for all watches"))
else:
flash('Incorrect confirmation text.', 'error')
flash(gettext('Incorrect confirmation text.'), 'error')
return redirect(url_for('watchlist.index'))
@@ -188,14 +189,14 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_handle
uuid = request.args.get('uuid')
if uuid != 'all' and not uuid in datastore.data['watching'].keys():
flash('The watch by UUID {} does not exist.'.format(uuid), 'error')
flash(gettext('The watch by UUID {} does not exist.').format(uuid), 'error')
return redirect(url_for('watchlist.index'))
# More for testing, possible to return the first/only
if uuid == 'first':
uuid = list(datastore.data['watching'].keys()).pop()
datastore.delete(uuid)
flash('Deleted.')
flash(gettext('Deleted.'))
return redirect(url_for('watchlist.index'))
@@ -212,7 +213,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_handle
if not datastore.data['watching'].get(uuid).get('paused'):
worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=5, item={'uuid': new_uuid}))
flash('Cloned, you are editing the new watch.')
flash(gettext('Cloned, you are editing the new watch.'))
return redirect(url_for("ui.ui_edit.edit_page", uuid=new_uuid))
@@ -251,11 +252,11 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_handle
i += 1
if i == 1:
flash("Queued 1 watch for rechecking.")
flash(gettext("Queued 1 watch for rechecking."))
if i > 1:
flash(f"Queued {i} watches for rechecking.")
flash(gettext("Queued {} watches for rechecking.").format(i))
if i == 0:
flash("No watches available to recheck.")
flash(gettext("No watches available to recheck."))
return redirect(url_for('watchlist.index', **({'tag': tag} if tag else {})))
@@ -326,7 +327,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_handle
except Exception as e:
logger.error(f"Error sharing -{str(e)}")
flash(f"Could not share, something went wrong while communicating with the share server - {str(e)}", 'error')
flash(gettext("Could not share, something went wrong while communicating with the share server - {}").format(str(e)), 'error')
return redirect(url_for('watchlist.index'))

View File

@@ -1,4 +1,5 @@
from flask import Blueprint, request, redirect, url_for, flash, render_template, make_response, send_from_directory
from flask_babel import gettext
import re
import importlib
@@ -89,12 +90,12 @@ def construct_blueprint(datastore: ChangeDetectionStore):
try:
watch = datastore.data['watching'][uuid]
except KeyError:
flash("No history found for the specified link, bad link?", "error")
flash(gettext("No history found for the specified link, bad link?"), "error")
return redirect(url_for('watchlist.index'))
dates = list(watch.history.keys())
if not dates or len(dates) < 2:
flash("Not enough history (2 snapshots required) to show difference page for this watch.", "error")
flash(gettext("Not enough history (2 snapshots required) to show difference page for this watch."), "error")
return redirect(url_for('watchlist.index'))
# Get the processor type for this watch
@@ -150,7 +151,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
try:
watch = datastore.data['watching'][uuid]
except KeyError:
flash("No history found for the specified link, bad link?", "error")
flash(gettext("No history found for the specified link, bad link?"), "error")
return redirect(url_for('watchlist.index'))
# Get the processor type for this watch
@@ -206,7 +207,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
try:
watch = datastore.data['watching'][uuid]
except KeyError:
flash("No history found for the specified link, bad link?", "error")
flash(gettext("No history found for the specified link, bad link?"), "error")
return redirect(url_for('watchlist.index'))
# Get the processor type for this watch
@@ -273,7 +274,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
try:
watch = datastore.data['watching'][uuid]
except KeyError:
flash("No history found for the specified link, bad link?", "error")
flash(gettext("No history found for the specified link, bad link?"), "error")
return redirect(url_for('watchlist.index'))
# Get the processor type for this watch

View File

@@ -2,6 +2,7 @@ from copy import deepcopy
import os
import importlib.resources
from flask import Blueprint, request, redirect, url_for, flash, render_template, abort
from flask_babel import gettext
from loguru import logger
from jinja2 import Environment, FileSystemLoader
@@ -31,14 +32,14 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
# More for testing, possible to return the first/only
if not datastore.data['watching'].keys():
flash("No watches to edit", "error")
flash(gettext("No watches to edit"), "error")
return redirect(url_for('watchlist.index'))
if uuid == 'first':
uuid = list(datastore.data['watching'].keys()).pop()
if not uuid in datastore.data['watching']:
flash("No watch with the UUID %s found." % (uuid), "error")
flash(gettext("No watch with the UUID {} found.").format(uuid), "error")
return redirect(url_for('watchlist.index'))
switch_processor = request.args.get('switch_processor')
@@ -46,7 +47,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
for p in processors.available_processors():
if p[0] == switch_processor:
datastore.data['watching'][uuid]['processor'] = switch_processor
flash(f"Switched to mode - {p[1]}.")
flash(gettext("Switched to mode - {}.").format(p[1]))
datastore.clear_watch_history(uuid)
redirect(url_for('ui_edit.edit_page', uuid=uuid))
@@ -65,7 +66,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
processor_name = datastore.data['watching'][uuid].get('processor', '')
processor_classes = next((tpl for tpl in processors.find_processors() if tpl[1] == processor_name), None)
if not processor_classes:
flash(f"Cannot load the edit form for processor/plugin '{processor_classes[1]}', plugin missing?", 'error')
flash(gettext("Cannot load the edit form for processor/plugin '{}', plugin missing?").format(processor_classes[1]), 'error')
return redirect(url_for('watchlist.index'))
parent_module = processors.get_parent_module(processor_classes[0])
@@ -235,7 +236,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
# Recast it if need be to right data Watch handler
watch_class = processors.get_custom_watch_obj_for_processor(form.data.get('processor'))
datastore.data['watching'][uuid] = watch_class(datastore_path=datastore.datastore_path, default=datastore.data['watching'][uuid])
flash("Updated watch - unpaused!" if request.args.get('unpause_on_save') else "Updated watch.")
flash(gettext("Updated watch - unpaused!") if request.args.get('unpause_on_save') else gettext("Updated watch."))
# Re #286 - We wait for syncing new data to disk in another thread every 60 seconds
# But in the case something is added we should save straight away
@@ -279,7 +280,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
else:
if request.method == 'POST' and not form.validate():
flash("An error occurred, please see below.", "error")
flash(gettext("An error occurred, please see below."), "error")
# JQ is difficult to install on windows and must be manually added (outside requirements.txt)
jq_support = True

View File

@@ -1,4 +1,5 @@
from flask import Blueprint, request, url_for, flash, render_template, redirect
from flask_babel import gettext
import time
from loguru import logger
@@ -32,7 +33,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
try:
watch = datastore.data['watching'][uuid]
except KeyError:
flash("No history found for the specified link, bad link?", "error")
flash(gettext("No history found for the specified link, bad link?"), "error")
return redirect(url_for('watchlist.index'))
# Get the processor type for this watch
@@ -74,7 +75,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
blocked_line_numbers = []
if datastore.data['watching'][uuid].history_n == 0 and (watch.get_error_text() or watch.get_error_snapshot()):
flash("Preview unavailable - No fetch/check completed or triggers not reached", "error")
flash(gettext("Preview unavailable - No fetch/check completed or triggers not reached"), "error")
else:
# So prepare the latest preview or not
preferred_version = request.args.get('version')
@@ -156,7 +157,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
try:
watch = datastore.data['watching'][uuid]
except KeyError:
flash("No history found for the specified link, bad link?", "error")
flash(gettext("No history found for the specified link, bad link?"), "error")
return redirect(url_for('watchlist.index'))
# Get the processor type for this watch

View File

@@ -1,4 +1,5 @@
from flask import Blueprint, request, redirect, url_for, flash
from flask_babel import gettext
from changedetectionio.store import ChangeDetectionStore
from changedetectionio.auth_decorator import login_optionally_required
from changedetectionio import worker_handler
@@ -20,7 +21,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
url = request.form.get('url').strip()
if datastore.url_exists(url):
flash(f'Warning, URL {url} already exists', "notice")
flash(gettext('Warning, URL {} already exists').format(url), "notice")
add_paused = request.form.get('edit_and_watch_submit_button') != None
processor = request.form.get('processor', 'text_json_diff')
@@ -28,12 +29,12 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
if new_uuid:
if add_paused:
flash('Watch added in Paused state, saving will unpause.')
flash(gettext('Watch added in Paused state, saving will unpause.'))
return redirect(url_for('ui.ui_edit.edit_page', uuid=new_uuid, unpause_on_save=1, tag=request.args.get('tag')))
else:
# Straight into the queue.
worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': new_uuid}))
flash("Watch added.")
flash(gettext("Watch added."))
return redirect(url_for('watchlist.index', tag=request.args.get('tag','')))

View File

@@ -369,11 +369,11 @@ def changedetection_app(config=None, datastore_o=None):
# 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}")
logger.trace(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}")
logger.trace(f"DEBUG: get_locale() returning from Accept-Language: {locale}")
return locale
# Initialize Babel with locale selector
@@ -397,6 +397,12 @@ def changedetection_app(config=None, datastore_o=None):
if request.endpoint and request.endpoint == 'static_content' and request.view_args:
# Handled by static_content handler
return None
# Permitted - static flag icons need to load on login page
elif request.endpoint and request.endpoint == 'static_flags':
return None
# Permitted - language selection should work on login page
elif request.endpoint and request.endpoint == 'set_language':
return None
# Permitted
elif request.endpoint and 'login' in request.endpoint:
return None
@@ -465,7 +471,6 @@ def changedetection_app(config=None, datastore_o=None):
@login_manager.unauthorized_handler
def unauthorized_handler():
flash("You must be logged in, please log in.", 'error')
return redirect(url_for('login', next=url_for('watchlist.index')))
@app.route('/logout')
@@ -492,9 +497,9 @@ def changedetection_app(config=None, datastore_o=None):
if request.method == 'GET':
if flask_login.current_user.is_authenticated:
flash("Already logged in")
flash(gettext("Already logged in"))
return redirect(url_for("watchlist.index"))
flash(gettext("You must be logged in, please log in."), 'error')
output = render_template("login.html")
return output
@@ -519,7 +524,7 @@ def changedetection_app(config=None, datastore_o=None):
return redirect(url_for('watchlist.index'))
else:
flash('Incorrect password', 'error')
flash(gettext('Incorrect password'), 'error')
return redirect(url_for('login'))

View File

@@ -7,6 +7,7 @@ and exporting to CSV format. This is the default extractor that all processors
"""
import os
from flask_babel import gettext
from loguru import logger
@@ -101,7 +102,7 @@ def process_extraction(watch, datastore, request, url_for, make_response, send_f
)
if not extract_form.validate():
flash("An error occurred, please see below.", "error")
flash(gettext("An error occurred, please see below."), "error")
# render_template needs to be imported from Flask for this to work
from flask import render_template as flask_render_template
return render_form(
@@ -127,5 +128,5 @@ def process_extraction(watch, datastore, request, url_for, make_response, send_f
response.headers['Expires'] = "0"
return response
flash('No matches found while scanning all of the watch history for that RegEx.', 'error')
flash(gettext('No matches found while scanning all of the watch history for that RegEx.'), 'error')
return redirect(url_for('ui.ui_diff.diff_history_page_extract_GET', uuid=uuid))

View File

@@ -8,6 +8,7 @@ of concerns and easy backend swapping (LibVIPS, OpenCV, PIL, etc.).
import os
import json
import time
from flask_babel import gettext
from loguru import logger
from changedetectionio.processors.image_ssim_diff import SCREENSHOT_COMPARISON_THRESHOLD_OPTIONS_DEFAULT, PROCESSOR_CONFIG_NAME, \
@@ -324,7 +325,7 @@ def render(watch, datastore, request, url_for, render_template, flash, redirect)
versions = list(watch.history.keys())
if len(versions) < 2:
flash("Not enough history to compare. Need at least 2 snapshots.", "error")
flash(gettext("Not enough history to compare. Need at least 2 snapshots."), "error")
return redirect(url_for('watchlist.index'))
# Default: compare latest two versions
@@ -359,7 +360,7 @@ def render(watch, datastore, request, url_for, render_template, flash, redirect)
except Exception as e:
logger.error(f"Failed to load screenshots: {e}")
flash(f"Failed to load screenshots: {e}", "error")
flash(gettext("Failed to load screenshots: {}").format(e), "error")
return redirect(url_for('watchlist.index'))
# Calculate change percentage using isolated subprocess to prevent memory leaks (async-safe)
@@ -408,7 +409,7 @@ def render(watch, datastore, request, url_for, render_template, flash, redirect)
logger.error(f"Failed to calculate change percentage: {e}")
import traceback
logger.error(traceback.format_exc())
flash(f"Failed to calculate diff: {e}", "error")
flash(gettext("Failed to calculate diff: {}").format(e), "error")
return redirect(url_for('watchlist.index'))
# Load historical data if available (for charts/visualization)

View File

@@ -4,6 +4,7 @@ Preview rendering for SSIM screenshot processor.
Renders images properly in the browser instead of showing raw bytes.
"""
from flask_babel import gettext
from loguru import logger
@@ -87,7 +88,7 @@ def render(watch, datastore, request, url_for, render_template, flash, redirect)
versions = list(watch.history.keys())
if len(versions) == 0:
flash("Preview unavailable - No snapshots captured yet", "error")
flash(gettext("Preview unavailable - No snapshots captured yet"), "error")
return redirect(url_for('watchlist.index'))
# Get the version to display (default: latest)

View File

@@ -16,8 +16,11 @@ from ..base import difference_detection_processor, SCREENSHOT_FORMAT_PNG
# All image operations now use OpenCV via isolated_opencv subprocess handler
# Template matching temporarily disabled pending OpenCV implementation
name = 'Visual / Image screenshot change detection'
description = 'Compares screenshots using fast OpenCV algorithm, 10-100x faster than SSIM'
# Translation marker for extraction
def _(x): return x
name = _('Visual / Image screenshot change detection')
description = _('Compares screenshots using fast OpenCV algorithm, 10-100x faster than SSIM')
del _
processor_weight = 2
list_badge_text = "Visual"

View File

@@ -8,8 +8,11 @@ import time
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# 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' # _()
# Use a marker function so pybabel can extract these strings
def _(x): return x # Translation marker for extraction only
name = _('Re-stock & Price detection for pages with a SINGLE product')
description = _('Detects if the product goes back to in-stock')
del _ # Remove marker function
processor_weight = 1
list_badge_text = "Restock" # _()

View File

@@ -17,8 +17,11 @@ from changedetectionio.processors.magic import guess_stream_type
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
name = 'Webpage Text/HTML, JSON and PDF changes'
description = 'Detects all text changes where possible'
# Translation marker for extraction - allows pybabel to find these strings
def _(x): return x
name = _('Webpage Text/HTML, JSON and PDF changes')
description = _('Detects all text changes where possible')
del _ # Remove marker
processor_weight = -100
list_badge_text = "Text"

View File

@@ -1171,7 +1171,7 @@ ul#highlightSnippetActions {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
padding: 0.25rem;
border-radius: 4px;
transition: background-color 0.2s ease;
text-decoration: none;

File diff suppressed because one or more lines are too long

View File

@@ -7,6 +7,7 @@ from changedetectionio.validate_url import is_safe_valid_url
from flask import (
flash
)
from flask_babel import gettext
from .blueprint.rss import RSS_CONTENT_FORMAT_DEFAULT
from .html_tools import TRANSLATE_WHITESPACE_TABLE
@@ -382,11 +383,11 @@ class ChangeDetectionStore:
except Exception as e:
logger.error(f"Error fetching metadata for shared watch link {url} {str(e)}")
flash("Error fetching metadata for {}".format(url), 'error')
flash(gettext("Error fetching metadata for {}").format(url), 'error')
return False
if not is_safe_valid_url(url):
flash('Watch protocol is not permitted or invalid URL format', 'error')
flash(gettext('Watch protocol is not permitted or invalid URL format'), 'error')
return None

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en" data-darkmode="{{ get_darkmode_state() }}">
<html lang="{{ get_locale() }}" data-darkmode="{{ get_darkmode_state() }}">
<head>
<meta charset="utf-8" >
@@ -266,6 +266,9 @@
</a>
{% endfor %}
</div>
<div>
{{ _('Language support is in beta, please help us improve by opening a PR on GitHub with any updates.') }}
</div>
</div>
<div class="modal-footer">
<button type="button" class="pure-button" id="close-language-modal">{{ _('Cancel') }}</button>

View File

@@ -0,0 +1,112 @@
#!/usr/bin/env python3
from flask import url_for
from .util import live_server_setup
def test_language_switching(client, live_server, measure_memory_usage, datastore_path):
"""
Test that the language switching functionality works correctly.
1. Switch to Italian using the /set-language endpoint
2. Verify that Italian text appears on the page
3. Switch back to English and verify English text appears
"""
# Step 1: Set the language to Italian using the /set-language endpoint
res = client.get(
url_for("set_language", locale="it"),
follow_redirects=True
)
assert res.status_code == 200
# Step 2: Request the index page - should be in Italian
# The session cookie should be maintained by the test client
res = client.get(
url_for("watchlist.index"),
follow_redirects=True
)
assert res.status_code == 200
# Check for Italian text - "Annulla" (translation of "Cancel")
assert b"Annulla" in res.data, "Expected Italian text 'Annulla' not found after setting language to Italian"
assert b'Modifiche testo/HTML, JSON e PDF' in res.data, "Expected italian from processors.available_processors()"
# Step 3: Switch back to English
res = client.get(
url_for("set_language", locale="en"),
follow_redirects=True
)
assert res.status_code == 200
# Request the index page - should now be in English
res = client.get(
url_for("watchlist.index"),
follow_redirects=True
)
assert res.status_code == 200
# Check for English text
assert b"Cancel" in res.data, "Expected English text 'Cancel' not found after switching back to English"
def test_invalid_locale(client, live_server, measure_memory_usage, datastore_path):
"""
Test that setting an invalid locale doesn't break the application.
The app should ignore invalid locales and continue working.
"""
# First set to English
res = client.get(
url_for("set_language", locale="en"),
follow_redirects=True
)
assert res.status_code == 200
# Try to set an invalid locale
res = client.get(
url_for("set_language", locale="invalid_locale_xyz"),
follow_redirects=True
)
assert res.status_code == 200
# Should still be able to access the page (should stay in English since invalid locale was ignored)
res = client.get(
url_for("watchlist.index"),
follow_redirects=True
)
assert res.status_code == 200
assert b"Cancel" in res.data, "Should remain in English when invalid locale is provided"
def test_language_persistence_in_session(client, live_server, measure_memory_usage, datastore_path):
"""
Test that the language preference persists across multiple requests
within the same session.
"""
# Set language to Italian
res = client.get(
url_for("set_language", locale="it"),
follow_redirects=True
)
assert res.status_code == 200
# Make multiple requests - language should persist
for _ in range(3):
res = client.get(
url_for("watchlist.index"),
follow_redirects=True
)
assert res.status_code == 200
assert b"Annulla" in res.data, "Italian text should persist across requests"

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff