mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-05-02 07:40:39 +00:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 21f4b02847 | |||
| 08e55a31c0 | |||
| cedabf4ff6 | |||
| 03116fef8f | |||
| b1257dd196 | |||
| 7e61f5b663 | |||
| afa8451448 | |||
| b5023a6fda | |||
| 895368144f | |||
| 9096407fcb | |||
| df8f86ccbf | |||
| 40dc3fef7e | |||
| 5f4998960d | |||
| 7a515c4202 | |||
| 48e21226a1 | |||
| cdf34bf614 | |||
| a94560190f | |||
| fefaf40514 | |||
| 6f66c39628 | |||
| eb0f83b45b | |||
| f2284f7a9b | |||
| 4b0ad525f3 | |||
| a748a43224 | |||
| acfcaf42d4 | |||
| 6158bb48b8 | |||
| d4fc1a3b6e | |||
| f39b5e5a46 | |||
| 30ba603956 | |||
| 3147c5a3e2 | |||
| f599efacab | |||
| d7dbc50d70 | |||
| 51bb358ea7 | |||
| fe4df1d41f |
@@ -84,7 +84,6 @@ jobs:
|
||||
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_watch_model'
|
||||
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_jinja2_security'
|
||||
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_semver'
|
||||
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_html_to_text'
|
||||
|
||||
# Basic pytest tests with ancillary services
|
||||
basic-tests:
|
||||
|
||||
@@ -11,7 +11,6 @@ recursive-include changedetectionio/realtime *
|
||||
recursive-include changedetectionio/static *
|
||||
recursive-include changedetectionio/templates *
|
||||
recursive-include changedetectionio/tests *
|
||||
recursive-include changedetectionio/translations *
|
||||
recursive-include changedetectionio/widgets *
|
||||
prune changedetectionio/static/package-lock.json
|
||||
prune changedetectionio/static/styles/node_modules
|
||||
|
||||
@@ -183,9 +183,6 @@ docker compose pull && docker compose up -d
|
||||
|
||||
See the wiki for more information https://github.com/dgtlmoon/changedetection.io/wiki
|
||||
|
||||
## Different browser viewport sizes (mobile, desktop etc)
|
||||
|
||||
If you are using the recommended `sockpuppetbrowser` (which is in the docker-compose.yml as a setting to be uncommented) you can easily set different viewport sizes for your web page change detection, [see more information here about setting up different viewport sizes](https://github.com/dgtlmoon/sockpuppetbrowser?tab=readme-ov-file#setting-viewport-size).
|
||||
|
||||
## Filters
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
|
||||
# Semver means never use .01, or 00. Should be .1.
|
||||
__version__ = '0.52.6'
|
||||
__version__ = '0.51.4'
|
||||
|
||||
from changedetectionio.strtobool import strtobool
|
||||
from json.decoder import JSONDecodeError
|
||||
@@ -41,10 +41,9 @@ from loguru import logger
|
||||
#
|
||||
# IMPLEMENTATION:
|
||||
# 1. Explicit contexts everywhere (primary protection):
|
||||
# - Watch.py: ctx = multiprocessing.get_context('spawn')
|
||||
# - playwright.py: ctx = multiprocessing.get_context('spawn')
|
||||
# - puppeteer.py: ctx = multiprocessing.get_context('spawn')
|
||||
# - isolated_opencv.py: ctx = multiprocessing.get_context('spawn')
|
||||
# - isolated_libvips.py: ctx = multiprocessing.get_context('spawn')
|
||||
#
|
||||
# 2. Global default (defense-in-depth, below):
|
||||
# - Safety net if future code forgets explicit context
|
||||
|
||||
@@ -64,17 +64,8 @@ class Watch(Resource):
|
||||
@validate_openapi_request('getWatch')
|
||||
def get(self, uuid):
|
||||
"""Get information about a single watch, recheck, pause, or mute."""
|
||||
import time
|
||||
from copy import deepcopy
|
||||
watch = None
|
||||
for _ in range(20):
|
||||
try:
|
||||
watch = deepcopy(self.datastore.data['watching'].get(uuid))
|
||||
break
|
||||
except RuntimeError:
|
||||
# Incase dict changed, try again
|
||||
time.sleep(0.01)
|
||||
|
||||
watch = deepcopy(self.datastore.data['watching'].get(uuid))
|
||||
if not watch:
|
||||
abort(404, message='No watch exists with the UUID of {}'.format(uuid))
|
||||
|
||||
@@ -438,8 +429,7 @@ class CreateWatch(Resource):
|
||||
|
||||
new_uuid = self.datastore.add_watch(url=url, extras=extras, tag=tags)
|
||||
if new_uuid:
|
||||
# Dont queue because the scheduler will check that it hasnt been checked before anyway
|
||||
# worker_handler.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': new_uuid}))
|
||||
worker_handler.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': new_uuid}))
|
||||
return {'uuid': new_uuid}, 201
|
||||
else:
|
||||
return "Invalid or unsupported URL", 400
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from blinker import signal
|
||||
|
||||
from .processors.exceptions import ProcessorException
|
||||
import changedetectionio.content_fetchers.exceptions as content_fetchers_exceptions
|
||||
from changedetectionio.processors.text_json_diff.processor import FilterNotFoundInResponse
|
||||
@@ -8,7 +9,7 @@ from changedetectionio.flask_app import watch_check_update
|
||||
import asyncio
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
import queue
|
||||
import time
|
||||
|
||||
from loguru import logger
|
||||
@@ -16,51 +17,36 @@ from loguru import logger
|
||||
# Async version of update_worker
|
||||
# Processes jobs from AsyncSignalPriorityQueue instead of threaded queue
|
||||
|
||||
IN_PYTEST = "pytest" in sys.modules or "PYTEST_CURRENT_TEST" in os.environ
|
||||
DEFER_SLEEP_TIME_ALREADY_QUEUED = 0.3 if IN_PYTEST else 10.0
|
||||
|
||||
async def async_update_worker(worker_id, q, notification_q, app, datastore, executor=None):
|
||||
async def async_update_worker(worker_id, q, notification_q, app, datastore):
|
||||
"""
|
||||
Async worker function that processes watch check jobs from the queue.
|
||||
|
||||
|
||||
Args:
|
||||
worker_id: Unique identifier for this worker
|
||||
q: AsyncSignalPriorityQueue containing jobs to process
|
||||
notification_q: Standard queue for notifications
|
||||
app: Flask application instance
|
||||
datastore: Application datastore
|
||||
executor: ThreadPoolExecutor for queue operations (optional)
|
||||
"""
|
||||
# Set a descriptive name for this task
|
||||
task = asyncio.current_task()
|
||||
if task:
|
||||
task.set_name(f"async-worker-{worker_id}")
|
||||
|
||||
|
||||
logger.info(f"Starting async worker {worker_id}")
|
||||
|
||||
|
||||
while not app.config.exit.is_set():
|
||||
update_handler = None
|
||||
watch = None
|
||||
|
||||
try:
|
||||
# Use sync interface via run_in_executor since each worker has its own event loop
|
||||
loop = asyncio.get_event_loop()
|
||||
queued_item_data = await asyncio.wait_for(
|
||||
loop.run_in_executor(executor, q.get, True, 1.0), # block=True, timeout=1.0
|
||||
timeout=1.5
|
||||
)
|
||||
# Use native janus async interface - no threads needed!
|
||||
queued_item_data = await asyncio.wait_for(q.async_get(), timeout=1.0)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
# No jobs available, continue loop
|
||||
continue
|
||||
except Exception as e:
|
||||
# Handle expected Empty exception from queue timeout
|
||||
import queue
|
||||
if isinstance(e, queue.Empty):
|
||||
# Queue is empty, normal behavior - just continue
|
||||
continue
|
||||
|
||||
# Unexpected exception - log as critical
|
||||
logger.critical(f"CRITICAL: Worker {worker_id} failed to get queue item: {type(e).__name__}: {e}")
|
||||
|
||||
# Log queue health for debugging
|
||||
@@ -75,13 +61,14 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
|
||||
continue
|
||||
|
||||
uuid = queued_item_data.item.get('uuid')
|
||||
|
||||
# RACE CONDITION FIX: Check if this UUID is already being processed by another worker
|
||||
from changedetectionio import worker_handler
|
||||
from changedetectionio.queuedWatchMetaData import PrioritizedItem
|
||||
if worker_handler.is_watch_running_by_another_worker(uuid, worker_id):
|
||||
logger.trace(f"Worker {worker_id} detected UUID {uuid} already being processed by another worker - deferring")
|
||||
# Sleep to avoid tight loop and give the other worker time to finish
|
||||
await asyncio.sleep(DEFER_SLEEP_TIME_ALREADY_QUEUED)
|
||||
await asyncio.sleep(10.0)
|
||||
|
||||
# Re-queue with lower priority so it gets checked again after current processing finishes
|
||||
deferred_priority = max(1000, queued_item_data.priority * 10)
|
||||
@@ -427,13 +414,14 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
|
||||
datastore.update_watch(uuid=uuid, update_obj={'last_error': f"Worker error: {str(e)}"})
|
||||
|
||||
finally:
|
||||
|
||||
try:
|
||||
await update_handler.fetcher.quit(watch=watch)
|
||||
except Exception as e:
|
||||
logger.error(f"Exception while cleaning/quit after calling browser: {e}")
|
||||
|
||||
# Always cleanup - this runs whether there was an exception or not
|
||||
if uuid:
|
||||
try:
|
||||
if update_handler and hasattr(update_handler, 'fetcher') and update_handler.fetcher:
|
||||
await update_handler.fetcher.quit(watch=watch)
|
||||
except Exception as e:
|
||||
logger.error(f"Exception while cleaning/quit after calling browser: {e}")
|
||||
try:
|
||||
# Mark UUID as no longer being processed by this worker
|
||||
worker_handler.set_uuid_processing(uuid, worker_id=worker_id, processing=False)
|
||||
@@ -472,9 +460,7 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
|
||||
logger.debug(f"Worker {worker_id} completed watch {uuid} in {time.time()-fetch_start_time:.2f}s")
|
||||
except Exception as cleanup_error:
|
||||
logger.error(f"Worker {worker_id} error during cleanup: {cleanup_error}")
|
||||
|
||||
del(uuid)
|
||||
|
||||
|
||||
# Brief pause before continuing to avoid tight error loops (only on error)
|
||||
if 'e' in locals():
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
@@ -92,12 +92,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
|
||||
# Be sure we're written fresh
|
||||
datastore.sync_to_json()
|
||||
zip_thread = threading.Thread(
|
||||
target=create_backup,
|
||||
args=(datastore.datastore_path, datastore.data.get("watching")),
|
||||
daemon=True,
|
||||
name="BackupCreator"
|
||||
)
|
||||
zip_thread = threading.Thread(target=create_backup, args=(datastore.datastore_path, datastore.data.get("watching")))
|
||||
zip_thread.start()
|
||||
backup_threads.append(zip_thread)
|
||||
flash(gettext("Backup building in background, check back in a few minutes."))
|
||||
|
||||
@@ -21,154 +21,31 @@ from changedetectionio.flask_app import login_optionally_required
|
||||
from loguru import logger
|
||||
|
||||
browsersteps_sessions = {}
|
||||
browsersteps_watch_to_session = {} # Maps watch_uuid -> browsersteps_session_id
|
||||
io_interface_context = None
|
||||
import json
|
||||
import hashlib
|
||||
from flask import Response
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
|
||||
# Dedicated event loop for ALL browser steps sessions
|
||||
_browser_steps_loop = None
|
||||
_browser_steps_thread = None
|
||||
_browser_steps_loop_lock = threading.Lock()
|
||||
|
||||
def _start_browser_steps_loop():
|
||||
"""Start a dedicated event loop for browser steps in its own thread"""
|
||||
global _browser_steps_loop
|
||||
|
||||
# Create and set the event loop for this thread
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
_browser_steps_loop = loop
|
||||
|
||||
logger.debug("Browser steps event loop started")
|
||||
|
||||
try:
|
||||
# Run the loop forever - handles all browsersteps sessions
|
||||
loop.run_forever()
|
||||
except Exception as e:
|
||||
logger.error(f"Browser steps event loop error: {e}")
|
||||
finally:
|
||||
try:
|
||||
# Cancel all remaining tasks
|
||||
pending = asyncio.all_tasks(loop)
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
|
||||
# Wait for tasks to finish cancellation
|
||||
if pending:
|
||||
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
||||
except Exception as e:
|
||||
logger.debug(f"Error during browser steps loop cleanup: {e}")
|
||||
finally:
|
||||
loop.close()
|
||||
logger.debug("Browser steps event loop closed")
|
||||
|
||||
def _ensure_browser_steps_loop():
|
||||
"""Ensure the browser steps event loop is running"""
|
||||
global _browser_steps_loop, _browser_steps_thread
|
||||
|
||||
with _browser_steps_loop_lock:
|
||||
if _browser_steps_thread is None or not _browser_steps_thread.is_alive():
|
||||
logger.debug("Starting browser steps event loop thread")
|
||||
_browser_steps_thread = threading.Thread(
|
||||
target=_start_browser_steps_loop,
|
||||
daemon=True,
|
||||
name="BrowserStepsEventLoop"
|
||||
)
|
||||
_browser_steps_thread.start()
|
||||
|
||||
# Wait for the loop to be ready
|
||||
timeout = 5.0
|
||||
start_time = time.time()
|
||||
while _browser_steps_loop is None:
|
||||
if time.time() - start_time > timeout:
|
||||
raise RuntimeError("Browser steps event loop failed to start")
|
||||
time.sleep(0.01)
|
||||
|
||||
logger.debug("Browser steps event loop thread started and ready")
|
||||
|
||||
def run_async_in_browser_loop(coro):
|
||||
"""Run async coroutine using the dedicated browser steps event loop"""
|
||||
_ensure_browser_steps_loop()
|
||||
|
||||
if _browser_steps_loop and not _browser_steps_loop.is_closed():
|
||||
logger.debug("Browser steps using dedicated event loop")
|
||||
future = asyncio.run_coroutine_threadsafe(coro, _browser_steps_loop)
|
||||
"""Run async coroutine using the existing async worker event loop"""
|
||||
from changedetectionio import worker_handler
|
||||
|
||||
# Use the existing async worker event loop instead of creating a new one
|
||||
if worker_handler.USE_ASYNC_WORKERS and worker_handler.async_loop and not worker_handler.async_loop.is_closed():
|
||||
logger.debug("Browser steps using existing async worker event loop")
|
||||
future = asyncio.run_coroutine_threadsafe(coro, worker_handler.async_loop)
|
||||
return future.result()
|
||||
else:
|
||||
raise RuntimeError("Browser steps event loop is not available")
|
||||
|
||||
def cleanup_expired_sessions():
|
||||
"""Remove expired browsersteps sessions and cleanup their resources"""
|
||||
global browsersteps_sessions, browsersteps_watch_to_session
|
||||
|
||||
expired_session_ids = []
|
||||
|
||||
# Find expired sessions
|
||||
for session_id, session_data in browsersteps_sessions.items():
|
||||
browserstepper = session_data.get('browserstepper')
|
||||
if browserstepper and browserstepper.has_expired:
|
||||
expired_session_ids.append(session_id)
|
||||
|
||||
# Cleanup expired sessions
|
||||
for session_id in expired_session_ids:
|
||||
logger.debug(f"Cleaning up expired browsersteps session {session_id}")
|
||||
session_data = browsersteps_sessions[session_id]
|
||||
|
||||
# Cleanup playwright resources asynchronously
|
||||
browserstepper = session_data.get('browserstepper')
|
||||
if browserstepper:
|
||||
try:
|
||||
run_async_in_browser_loop(browserstepper.cleanup())
|
||||
except Exception as e:
|
||||
logger.error(f"Error cleaning up session {session_id}: {e}")
|
||||
|
||||
# Remove from sessions dict
|
||||
del browsersteps_sessions[session_id]
|
||||
|
||||
# Remove from watch mapping
|
||||
for watch_uuid, mapped_session_id in list(browsersteps_watch_to_session.items()):
|
||||
if mapped_session_id == session_id:
|
||||
del browsersteps_watch_to_session[watch_uuid]
|
||||
break
|
||||
|
||||
if expired_session_ids:
|
||||
logger.info(f"Cleaned up {len(expired_session_ids)} expired browsersteps session(s)")
|
||||
|
||||
def cleanup_session_for_watch(watch_uuid):
|
||||
"""Cleanup a specific browsersteps session for a watch UUID"""
|
||||
global browsersteps_sessions, browsersteps_watch_to_session
|
||||
|
||||
session_id = browsersteps_watch_to_session.get(watch_uuid)
|
||||
if not session_id:
|
||||
logger.debug(f"No browsersteps session found for watch {watch_uuid}")
|
||||
return
|
||||
|
||||
logger.debug(f"Cleaning up browsersteps session {session_id} for watch {watch_uuid}")
|
||||
|
||||
session_data = browsersteps_sessions.get(session_id)
|
||||
if session_data:
|
||||
browserstepper = session_data.get('browserstepper')
|
||||
if browserstepper:
|
||||
try:
|
||||
run_async_in_browser_loop(browserstepper.cleanup())
|
||||
except Exception as e:
|
||||
logger.error(f"Error cleaning up session {session_id} for watch {watch_uuid}: {e}")
|
||||
|
||||
# Remove from sessions dict
|
||||
del browsersteps_sessions[session_id]
|
||||
|
||||
# Remove from watch mapping
|
||||
del browsersteps_watch_to_session[watch_uuid]
|
||||
|
||||
logger.debug(f"Cleaned up session for watch {watch_uuid}")
|
||||
|
||||
# Opportunistically cleanup any other expired sessions
|
||||
cleanup_expired_sessions()
|
||||
# Fallback: create a new event loop (for sync workers or if async loop not available)
|
||||
logger.debug("Browser steps creating temporary event loop")
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
return loop.run_until_complete(coro)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
browser_steps_blueprint = Blueprint('browser_steps', __name__, template_folder="templates")
|
||||
@@ -246,9 +123,6 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
if not watch_uuid:
|
||||
return make_response('No Watch UUID specified', 500)
|
||||
|
||||
# Cleanup any existing session for this watch
|
||||
cleanup_session_for_watch(watch_uuid)
|
||||
|
||||
logger.debug("Starting connection with playwright")
|
||||
logger.debug("browser_steps.py connecting")
|
||||
|
||||
@@ -257,10 +131,6 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
browsersteps_sessions[browsersteps_session_id] = run_async_in_browser_loop(
|
||||
start_browsersteps_session(watch_uuid)
|
||||
)
|
||||
|
||||
# Store the mapping of watch_uuid -> browsersteps_session_id
|
||||
browsersteps_watch_to_session[watch_uuid] = browsersteps_session_id
|
||||
|
||||
except Exception as e:
|
||||
if 'ECONNREFUSED' in str(e):
|
||||
return make_response('Unable to start the Playwright Browser session, is sockpuppetbrowser running? Network configuration is OK?', 401)
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
from flask import Blueprint, request, redirect, url_for, flash, render_template
|
||||
from loguru import logger
|
||||
|
||||
from changedetectionio.store import ChangeDetectionStore
|
||||
from changedetectionio.auth_decorator import login_optionally_required
|
||||
from changedetectionio import worker_handler
|
||||
from changedetectionio.blueprint.imports.importer import (
|
||||
import_url_list,
|
||||
import_distill_io_json,
|
||||
import_xlsx_wachete,
|
||||
import_xlsx_custom
|
||||
)
|
||||
|
||||
def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData):
|
||||
import_blueprint = Blueprint('imports', __name__, template_folder="templates")
|
||||
@@ -12,26 +17,15 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
||||
def import_page():
|
||||
remaining_urls = []
|
||||
from changedetectionio import forms
|
||||
#
|
||||
|
||||
if request.method == 'POST':
|
||||
# from changedetectionio import worker_handler
|
||||
|
||||
from changedetectionio.blueprint.imports.importer import (
|
||||
import_url_list,
|
||||
import_distill_io_json,
|
||||
import_xlsx_wachete,
|
||||
import_xlsx_custom
|
||||
)
|
||||
|
||||
# URL List import
|
||||
if request.values.get('urls') and len(request.values.get('urls').strip()):
|
||||
# Import and push into the queue for immediate update check
|
||||
importer_handler = import_url_list()
|
||||
importer_handler.run(data=request.values.get('urls'), flash=flash, datastore=datastore, processor=request.values.get('processor', 'text_json_diff'))
|
||||
logger.debug(f"Imported {len(importer_handler.new_uuids)} new UUIDs")
|
||||
# Dont' add to queue because scheduler can see that they haven't been checked and will add them to the queue
|
||||
# for uuid in importer_handler.new_uuids:
|
||||
# worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
|
||||
for uuid in importer_handler.new_uuids:
|
||||
worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
|
||||
|
||||
if len(importer_handler.remaining_data) == 0:
|
||||
return redirect(url_for('watchlist.index'))
|
||||
@@ -43,10 +37,8 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
||||
# Import and push into the queue for immediate update check
|
||||
d_importer = import_distill_io_json()
|
||||
d_importer.run(data=request.values.get('distill-io'), flash=flash, datastore=datastore)
|
||||
# Dont' add to queue because scheduler can see that they haven't been checked and will add them to the queue
|
||||
# for uuid in importer_handler.new_uuids:
|
||||
# worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
|
||||
|
||||
for uuid in d_importer.new_uuids:
|
||||
worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
|
||||
|
||||
# XLSX importer
|
||||
if request.files and request.files.get('xlsx_file'):
|
||||
@@ -68,10 +60,8 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
||||
w_importer.import_profile = map
|
||||
w_importer.run(data=file, flash=flash, datastore=datastore)
|
||||
|
||||
# Dont' add to queue because scheduler can see that they haven't been checked and will add them to the queue
|
||||
# for uuid in importer_handler.new_uuids:
|
||||
# worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
|
||||
|
||||
for uuid in w_importer.new_uuids:
|
||||
worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
|
||||
|
||||
# Could be some remaining, or we could be on GET
|
||||
form = forms.importForm(formdata=request.form if request.method == 'POST' else None)
|
||||
|
||||
@@ -47,6 +47,9 @@ def construct_single_watch_routes(rss_blueprint, datastore):
|
||||
if len(dates) < 2:
|
||||
return f"Watch {uuid} does not have enough history snapshots to show changes (need at least 2)", 400
|
||||
|
||||
# Add uuid to watch for proper functioning
|
||||
watch['uuid'] = uuid
|
||||
|
||||
# Get the number of diffs to include (default: 5)
|
||||
rss_diff_length = datastore.data['settings']['application'].get('rss_diff_length', 5)
|
||||
|
||||
@@ -98,7 +101,7 @@ def construct_single_watch_routes(rss_blueprint, datastore):
|
||||
date_index_from, date_index_to)
|
||||
|
||||
# Create and populate feed entry
|
||||
guid = f"{uuid}/{timestamp_to}"
|
||||
guid = f"{watch['uuid']}/{timestamp_to}"
|
||||
fe = fg.add_entry()
|
||||
title_suffix = f"Change @ {res['original_context']['change_datetime']}"
|
||||
populate_feed_entry(fe, watch, res.get('body', ''), guid, timestamp_to,
|
||||
|
||||
@@ -63,8 +63,11 @@ def construct_tag_routes(rss_blueprint, datastore):
|
||||
|
||||
# Only include unviewed watches
|
||||
if not watch.viewed:
|
||||
# Include a link to the diff page (use uuid from loop, don't modify watch dict)
|
||||
diff_link = {'href': url_for('ui.ui_diff.diff_history_page', uuid=uuid, _external=True)}
|
||||
# Add uuid to watch for proper functioning
|
||||
watch['uuid'] = uuid
|
||||
|
||||
# Include a link to the diff page
|
||||
diff_link = {'href': url_for('ui.ui_diff.diff_history_page', uuid=watch['uuid'], _external=True)}
|
||||
|
||||
# Get watch label
|
||||
watch_label = get_watch_label(datastore, watch)
|
||||
|
||||
@@ -85,7 +85,9 @@
|
||||
|
||||
<div class="tab-pane-inner" id="notifications">
|
||||
<fieldset>
|
||||
{{ render_common_settings_form(form.application.form, emailprefix, settings_application, extra_notification_token_placeholder_info) }}
|
||||
<div class="field-group">
|
||||
{{ render_common_settings_form(form.application.form, emailprefix, settings_application, extra_notification_token_placeholder_info) }}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="pure-control-group" id="notification-base-url">
|
||||
{{ render_field(form.application.form.base_url, class="m-d") }}
|
||||
@@ -126,7 +128,7 @@
|
||||
</div>
|
||||
<div class="pure-control-group">
|
||||
{{ render_field(form.requests.form.timeout) }}
|
||||
<span class="pure-form-message-inline">For regular plain requests (not chrome based), maximum number of seconds until timeout, 1-999.</span><br>
|
||||
<span class="pure-form-message-inline">For regular plain requests (not chrome based), maximum number of seconds until timeout, 1-999.<br>
|
||||
</div>
|
||||
<div class="pure-control-group inline-radio">
|
||||
{{ render_field(form.requests.form.default_ua) }}
|
||||
@@ -217,7 +219,7 @@ nav
|
||||
<a id="chrome-extension-link"
|
||||
title="Try our new Chrome Extension!"
|
||||
href="https://chromewebstore.google.com/detail/changedetectionio-website/kefcfmgmlhmankjmnbijimhofdjekbop">
|
||||
<img alt="Chrome store icon" src="{{ url_for('static_content', group='images', filename='google-chrome-icon.png') }}" >
|
||||
<img alt="Chrome store icon" src="{{ url_for('static_content', group='images', filename='google-chrome-icon.png') }}" alt="Chrome">
|
||||
Chrome Webstore
|
||||
</a>
|
||||
</p>
|
||||
@@ -258,14 +260,14 @@ nav
|
||||
Ensure the settings below are correct, they are used to manage the time schedule for checking your web page watches.
|
||||
</div>
|
||||
<div class="pure-control-group">
|
||||
<p><strong>UTC Time & Date from Server:</strong> <span id="utc-time" >{{ utc_time }}</span></p>
|
||||
<p><strong>Local Time & Date in Browser:</strong> <span class="local-time" data-utc="{{ utc_time }}"></span></p>
|
||||
<div>
|
||||
<p><strong>UTC Time & Date from Server:</strong> <span id="utc-time" >{{ utc_time }}</span></p>
|
||||
<p><strong>Local Time & Date in Browser:</strong> <span class="local-time" data-utc="{{ utc_time }}"></span></p>
|
||||
<p>
|
||||
{{ render_field(form.application.form.scheduler_timezone_default) }}
|
||||
<datalist id="timezones" style="display: none;">
|
||||
{%- for timezone in available_timezones -%}<option value="{{ timezone }}">{{ timezone }}</option>{%- endfor -%}
|
||||
</datalist>
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane-inner" id="ui-options">
|
||||
@@ -334,7 +336,7 @@ nav
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p><strong>Tip</strong>: "Residential" and "Mobile" proxy type can be more successfull than "Data Center" for blocked websites.</p>
|
||||
<p><strong>Tip</strong>: "Residential" and "Mobile" proxy type can be more successfull than "Data Center" for blocked websites.
|
||||
|
||||
<div class="pure-control-group" id="extra-proxies-setting">
|
||||
{{ render_fieldlist_with_inline_errors(form.requests.form.extra_proxies) }}
|
||||
|
||||
@@ -50,8 +50,7 @@
|
||||
<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>
|
||||
<a href="{{ url_for('ui.form_watch_checknow', tag=uuid) }}" class="pure-button pure-button-primary" >{{ _('Recheck') }}</a>
|
||||
<a class="pure-button pure-button-primary" href="{{ url_for('tags.form_tag_edit', uuid=uuid) }}">{{ _('Edit') }}</a>
|
||||
<a class="pure-button button-error"
|
||||
href="{{ url_for('tags.delete', uuid=uuid) }}"
|
||||
data-requires-confirm
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import time
|
||||
import threading
|
||||
from flask import Blueprint, request, redirect, url_for, flash, render_template, session
|
||||
from flask_babel import gettext
|
||||
from loguru import logger
|
||||
@@ -152,24 +151,9 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_handle
|
||||
confirmtext = request.form.get('confirmtext')
|
||||
|
||||
if confirmtext == 'clear':
|
||||
# Run in background thread to avoid blocking
|
||||
def clear_history_background():
|
||||
# Capture UUIDs first to avoid race conditions
|
||||
watch_uuids = list(datastore.data['watching'].keys())
|
||||
logger.info(f"Background: Clearing history for {len(watch_uuids)} watches")
|
||||
|
||||
for uuid in watch_uuids:
|
||||
try:
|
||||
datastore.clear_watch_history(uuid)
|
||||
except Exception as e:
|
||||
logger.error(f"Error clearing history for watch {uuid}: {e}")
|
||||
|
||||
logger.info("Background: Completed clearing history")
|
||||
|
||||
# Start daemon thread
|
||||
threading.Thread(target=clear_history_background, daemon=True).start()
|
||||
|
||||
flash(gettext("History clearing started in background"))
|
||||
for uuid in datastore.data['watching'].keys():
|
||||
datastore.clear_watch_history(uuid)
|
||||
flash(gettext("Cleared snapshot history for all watches"))
|
||||
else:
|
||||
flash(gettext('Incorrect confirmation text.'), 'error')
|
||||
|
||||
@@ -243,9 +227,12 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_handle
|
||||
|
||||
i = 0
|
||||
|
||||
running_uuids = worker_handler.get_running_uuids()
|
||||
|
||||
if uuid:
|
||||
worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
|
||||
i += 1
|
||||
if uuid not in running_uuids:
|
||||
worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
|
||||
i += 1
|
||||
|
||||
else:
|
||||
# Recheck all, including muted
|
||||
@@ -254,7 +241,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_handle
|
||||
watch_uuid = k[0]
|
||||
watch = k[1]
|
||||
if not watch['paused']:
|
||||
if watch_uuid:
|
||||
if watch_uuid not in running_uuids:
|
||||
if with_errors and not watch.get('last_error'):
|
||||
continue
|
||||
|
||||
|
||||
@@ -52,13 +52,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
||||
redirect(url_for('ui_edit.edit_page', uuid=uuid))
|
||||
|
||||
# be sure we update with a copy instead of accidently editing the live object by reference
|
||||
default = None
|
||||
while not default:
|
||||
try:
|
||||
default = deepcopy(datastore.data['watching'][uuid])
|
||||
except RuntimeError as e:
|
||||
# Dictionary changed
|
||||
continue
|
||||
default = deepcopy(datastore.data['watching'][uuid])
|
||||
|
||||
# Defaults for proxy choice
|
||||
if datastore.proxy_list is not None: # When enabled
|
||||
@@ -244,13 +238,6 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
||||
datastore.data['watching'][uuid] = watch_class(datastore_path=datastore.datastore_path, default=datastore.data['watching'][uuid])
|
||||
flash(gettext("Updated watch - unpaused!") if request.args.get('unpause_on_save') else gettext("Updated watch."))
|
||||
|
||||
# Cleanup any browsersteps session for this watch
|
||||
try:
|
||||
from changedetectionio.blueprint.browser_steps import cleanup_session_for_watch
|
||||
cleanup_session_for_watch(uuid)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error cleaning up browsersteps session: {e}")
|
||||
|
||||
# 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
|
||||
datastore.needs_write_urgent = True
|
||||
@@ -338,6 +325,8 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
||||
'url': url_for('rss.rss_single_watch', uuid=watch['uuid'], token=app_rss_token)
|
||||
},
|
||||
'settings_application': datastore.data['settings']['application'],
|
||||
'system_has_playwright_configured': os.getenv('PLAYWRIGHT_DRIVER_URL'),
|
||||
'system_has_webdriver_configured': os.getenv('WEBDRIVER_URL'),
|
||||
'ui_edit_stats_extras': collect_ui_edit_stats_extras(watch),
|
||||
'visual_selector_data_ready': datastore.visualselector_data_is_ready(watch_uuid=uuid),
|
||||
'timezone_default_config': datastore.data['settings']['application'].get('scheduler_timezone_default'),
|
||||
|
||||
@@ -118,7 +118,6 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
sent_obj = process_notification(n_object, datastore)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
e_str = str(e)
|
||||
# Remove this text which is not important and floods the container
|
||||
e_str = e_str.replace(
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="diff-jump" style="display:none;"><!-- disabled for now -->
|
||||
<div id="diff-jump">
|
||||
<a id="jump-next-diff" title="{{ _('Jump to next difference') }}">{{ _('Jump') }}</a>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ Math: {{ 1 + 1 }}") }}
|
||||
|
||||
<div class="tab-pane-inner" id="browser-steps">
|
||||
{% if capabilities.supports_browser_steps %}
|
||||
{% if true %}
|
||||
{% if visual_selector_data_ready %}
|
||||
<img class="beta-logo" src="{{url_for('static_content', group='images', filename='beta-logo.png')}}" alt="New beta functionality">
|
||||
<fieldset>
|
||||
<div class="pure-control-group">
|
||||
|
||||
@@ -2,6 +2,7 @@ import os
|
||||
import time
|
||||
|
||||
from flask import Blueprint, request, make_response, render_template, redirect, url_for, flash, session
|
||||
from flask_login import current_user
|
||||
from flask_paginate import Pagination, get_page_parameter
|
||||
|
||||
from changedetectionio import forms
|
||||
@@ -84,19 +85,16 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
||||
app_rss_token=datastore.data['settings']['application'].get('rss_access_token'),
|
||||
datastore=datastore,
|
||||
errored_count=errored_count,
|
||||
extra_classes='has-queue' if not update_q.empty() else '',
|
||||
form=form,
|
||||
generate_tag_colors=processors.generate_processor_badge_colors,
|
||||
guid=datastore.data['app_guid'],
|
||||
has_proxies=datastore.proxy_list,
|
||||
hosted_sticky=os.getenv("SALTED_PASS", False) == False,
|
||||
now_time_server=round(time.time()),
|
||||
pagination=pagination,
|
||||
processor_badge_css=processors.get_processor_badge_css(),
|
||||
processor_badge_texts=processors.get_processor_badge_texts(),
|
||||
processor_descriptions=processors.get_processor_descriptions(),
|
||||
queue_size=update_q.qsize(),
|
||||
queued_uuids=update_q.get_queued_uuids(),
|
||||
processor_badge_css=processors.get_processor_badge_css(),
|
||||
queued_uuids=[q_uuid.item['uuid'] for q_uuid in update_q.queue],
|
||||
search_q=request.args.get('q', '').strip(),
|
||||
sort_attribute=request.args.get('sort') if request.args.get('sort') else request.cookies.get('sort'),
|
||||
sort_order=request.args.get('order') if request.args.get('order') else request.cookies.get('order'),
|
||||
|
||||
@@ -22,33 +22,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
/* Auto-generated processor badge colors */
|
||||
{{ processor_badge_css|safe }}
|
||||
|
||||
/* Auto-generated tag colors */
|
||||
{%- for uuid, tag in tags -%}
|
||||
{%- if tag and tag.title -%}
|
||||
{%- set class_name = tag.title|sanitize_tag_class -%}
|
||||
{%- set colors = generate_tag_colors(tag.title) -%}
|
||||
.button-tag.tag-{{ class_name }} {
|
||||
background-color: {{ colors['light']['bg'] }};
|
||||
color: {{ colors['light']['color'] }};
|
||||
}
|
||||
|
||||
.watch-tag-list.tag-{{ class_name }} {
|
||||
background-color: {{ colors['light']['bg'] }};
|
||||
color: {{ colors['light']['color'] }};
|
||||
}
|
||||
|
||||
html[data-darkmode="true"] .button-tag.tag-{{ class_name }} {
|
||||
background-color: {{ colors['dark']['bg'] }};
|
||||
color: {{ colors['dark']['color'] }};
|
||||
}
|
||||
|
||||
html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} {
|
||||
background-color: {{ colors['dark']['bg'] }};
|
||||
color: {{ colors['dark']['color'] }};
|
||||
}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
</style>
|
||||
<div class="box" id="form-quick-watch-add">
|
||||
|
||||
@@ -99,14 +72,9 @@ html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} {
|
||||
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>
|
||||
|
||||
<div id="stats_row">
|
||||
<div class="left">{%- if watches|length >= pagination.per_page -%}{{ pagination.info }}{%- endif -%}</div>
|
||||
<div class="right" >{{ _('Queued size') }}: <span id="queue-size-int">{{ queue_size }}</span></div>
|
||||
</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 -%}
|
||||
<div>
|
||||
<a href="{{url_for('watchlist.index')}}" class="pure-button button-tag {{'active' if not active_tag_uuid }}">{{ _('All') }}</a>
|
||||
@@ -114,7 +82,7 @@ html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} {
|
||||
<!-- tag list -->
|
||||
{%- for uuid, tag in tags -%}
|
||||
{%- if tag != "" -%}
|
||||
<a href="{{url_for('watchlist.index', tag=uuid) }}" class="pure-button button-tag tag-{{ tag.title|sanitize_tag_class }} {{'active' if active_tag_uuid == uuid }}">{{ tag.title }}</a>
|
||||
<a href="{{url_for('watchlist.index', tag=uuid) }}" class="pure-button button-tag {{'active' if active_tag_uuid == uuid }}">{{ tag.title }}</a>
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
@@ -159,7 +127,7 @@ html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} {
|
||||
<tbody>
|
||||
{%- if not watches|length -%}
|
||||
<tr>
|
||||
<td colspan="{{ cols_required }}" style="text-wrap: wrap;">{{ _('No web page change detection 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 -%}
|
||||
|
||||
@@ -201,7 +169,7 @@ html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} {
|
||||
<div class="flex-wrapper">
|
||||
{% if 'favicons_enabled' not in ui_settings or ui_settings['favicons_enabled'] %}
|
||||
<div>{# A page might have hundreds of these images, set IMG options for lazy loading, don't set SRC if we dont have it so it doesnt fetch the placeholder' #}
|
||||
<img alt="Favicon thumbnail" class="favicon" loading="lazy" decoding="async" fetchpriority="low" {% if favicon %} src="{{url_for('static_content', group='favicon', filename=watch.uuid)}}" {% else %} src='data:image/svg+xml;utf8,%3Csvg xmlns="http://www.w3.org/2000/svg" width="7.087" height="7.087" viewBox="0 0 7.087 7.087"%3E%3Ccircle cx="3.543" cy="3.543" r="3.279" stroke="%23e1e1e1" stroke-width="0.45" fill="none" opacity="0.74"/%3E%3C/svg%3E' {% endif %} >
|
||||
<img alt="Favicon thumbnail" class="favicon" loading="lazy" decoding="async" fetchpriority="low" {% if favicon %} src="{{url_for('static_content', group='favicon', filename=watch.uuid)}}" {% else %} src='data:image/svg+xml;utf8,%3Csvg xmlns="http://www.w3.org/2000/svg" width="7.087" height="7.087" viewBox="0 0 7.087 7.087"%3E%3Ccircle cx="3.543" cy="3.543" r="3.279" stroke="%23e1e1e1" stroke-width="0.45" fill="none" opacity="0.74"/%3E%3C/svg%3E' {% endif %} />
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
@@ -223,7 +191,7 @@ html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} {
|
||||
<span class="processor-badge processor-badge-{{ watch['processor'] }}" title="{{ processor_descriptions.get(watch['processor'], watch['processor']) }}">{{ processor_badge_texts[watch['processor']] }}</span>
|
||||
{%- endif -%}
|
||||
{%- for watch_tag_uuid, watch_tag in datastore.get_all_tags_for_watch(watch['uuid']).items() -%}
|
||||
<span class="watch-tag-list tag-{{ watch_tag.title|sanitize_tag_class }}">{{ watch_tag.title }}</span>
|
||||
<span class="watch-tag-list">{{ watch_tag.title }}</span>
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
<div class="status-icons">
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import gc
|
||||
import json
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
@@ -186,33 +185,20 @@ class fetcher(Fetcher):
|
||||
super().screenshot_step(step_n=step_n)
|
||||
screenshot = await capture_full_page_async(page=self.page, screenshot_format=self.screenshot_format)
|
||||
|
||||
# Request GC immediately after screenshot to free memory
|
||||
# Screenshots can be large and browser steps take many of them
|
||||
await self.page.request_gc()
|
||||
|
||||
if self.browser_steps_screenshot_path is not None:
|
||||
destination = os.path.join(self.browser_steps_screenshot_path, 'step_{}.jpeg'.format(step_n))
|
||||
logger.debug(f"Saving step screenshot to {destination}")
|
||||
with open(destination, 'wb') as f:
|
||||
f.write(screenshot)
|
||||
# Clear local reference to allow screenshot bytes to be collected
|
||||
del screenshot
|
||||
gc.collect()
|
||||
|
||||
async def save_step_html(self, step_n):
|
||||
super().save_step_html(step_n=step_n)
|
||||
content = await self.page.content()
|
||||
|
||||
# Request GC after getting page content
|
||||
await self.page.request_gc()
|
||||
|
||||
destination = os.path.join(self.browser_steps_screenshot_path, 'step_{}.html'.format(step_n))
|
||||
logger.debug(f"Saving step HTML to {destination}")
|
||||
with open(destination, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
# Clear local reference
|
||||
del content
|
||||
gc.collect()
|
||||
|
||||
async def run(self,
|
||||
fetch_favicon=True,
|
||||
@@ -319,12 +305,6 @@ class fetcher(Fetcher):
|
||||
|
||||
if self.status_code != 200 and not ignore_status_codes:
|
||||
screenshot = await capture_full_page_async(self.page, screenshot_format=self.screenshot_format)
|
||||
# Cleanup before raising to prevent memory leak
|
||||
await self.page.close()
|
||||
await context.close()
|
||||
await browser.close()
|
||||
# Force garbage collection to release Playwright resources immediately
|
||||
gc.collect()
|
||||
raise Non200ErrorCodeReceived(url=url, status_code=self.status_code, screenshot=screenshot)
|
||||
|
||||
if not empty_pages_are_a_change and len((await self.page.content()).strip()) == 0:
|
||||
@@ -333,52 +313,48 @@ class fetcher(Fetcher):
|
||||
await browser.close()
|
||||
raise EmptyReply(url=url, status_code=response.status)
|
||||
|
||||
# Wrap remaining operations in try/finally to ensure cleanup
|
||||
# Run Browser Steps here
|
||||
if self.browser_steps_get_valid_steps():
|
||||
await self.iterate_browser_steps(start_url=url)
|
||||
|
||||
await self.page.wait_for_timeout(extra_wait * 1000)
|
||||
|
||||
now = time.time()
|
||||
# So we can find an element on the page where its selector was entered manually (maybe not xPath etc)
|
||||
if current_include_filters is not None:
|
||||
await self.page.evaluate("var include_filters={}".format(json.dumps(current_include_filters)))
|
||||
else:
|
||||
await self.page.evaluate("var include_filters=''")
|
||||
await self.page.request_gc()
|
||||
|
||||
# request_gc before and after evaluate to free up memory
|
||||
# @todo browsersteps etc
|
||||
MAX_TOTAL_HEIGHT = int(os.getenv("SCREENSHOT_MAX_HEIGHT", SCREENSHOT_MAX_HEIGHT_DEFAULT))
|
||||
self.xpath_data = await self.page.evaluate(XPATH_ELEMENT_JS, {
|
||||
"visualselector_xpath_selectors": visualselector_xpath_selectors,
|
||||
"max_height": MAX_TOTAL_HEIGHT
|
||||
})
|
||||
await self.page.request_gc()
|
||||
|
||||
self.instock_data = await self.page.evaluate(INSTOCK_DATA_JS)
|
||||
await self.page.request_gc()
|
||||
|
||||
self.content = await self.page.content()
|
||||
await self.page.request_gc()
|
||||
logger.debug(f"Scrape xPath element data in browser done in {time.time() - now:.2f}s")
|
||||
|
||||
|
||||
# Bug 3 in Playwright screenshot handling
|
||||
# Some bug where it gives the wrong screenshot size, but making a request with the clip set first seems to solve it
|
||||
# JPEG is better here because the screenshots can be very very large
|
||||
|
||||
# Screenshots also travel via the ws:// (websocket) meaning that the binary data is base64 encoded
|
||||
# which will significantly increase the IO size between the server and client, it's recommended to use the lowest
|
||||
# acceptable screenshot quality here
|
||||
try:
|
||||
# Run Browser Steps here
|
||||
if self.browser_steps_get_valid_steps():
|
||||
await self.iterate_browser_steps(start_url=url)
|
||||
|
||||
await self.page.wait_for_timeout(extra_wait * 1000)
|
||||
|
||||
now = time.time()
|
||||
# So we can find an element on the page where its selector was entered manually (maybe not xPath etc)
|
||||
if current_include_filters is not None:
|
||||
await self.page.evaluate("var include_filters={}".format(json.dumps(current_include_filters)))
|
||||
else:
|
||||
await self.page.evaluate("var include_filters=''")
|
||||
await self.page.request_gc()
|
||||
|
||||
# request_gc before and after evaluate to free up memory
|
||||
# @todo browsersteps etc
|
||||
MAX_TOTAL_HEIGHT = int(os.getenv("SCREENSHOT_MAX_HEIGHT", SCREENSHOT_MAX_HEIGHT_DEFAULT))
|
||||
self.xpath_data = await self.page.evaluate(XPATH_ELEMENT_JS, {
|
||||
"visualselector_xpath_selectors": visualselector_xpath_selectors,
|
||||
"max_height": MAX_TOTAL_HEIGHT
|
||||
})
|
||||
await self.page.request_gc()
|
||||
|
||||
self.instock_data = await self.page.evaluate(INSTOCK_DATA_JS)
|
||||
await self.page.request_gc()
|
||||
|
||||
self.content = await self.page.content()
|
||||
await self.page.request_gc()
|
||||
logger.debug(f"Scrape xPath element data in browser done in {time.time() - now:.2f}s")
|
||||
|
||||
|
||||
# Bug 3 in Playwright screenshot handling
|
||||
# Some bug where it gives the wrong screenshot size, but making a request with the clip set first seems to solve it
|
||||
# JPEG is better here because the screenshots can be very very large
|
||||
|
||||
# Screenshots also travel via the ws:// (websocket) meaning that the binary data is base64 encoded
|
||||
# which will significantly increase the IO size between the server and client, it's recommended to use the lowest
|
||||
# acceptable screenshot quality here
|
||||
# The actual screenshot - this always base64 and needs decoding! horrible! huge CPU usage
|
||||
self.screenshot = await capture_full_page_async(page=self.page, screenshot_format=self.screenshot_format)
|
||||
|
||||
except ScreenshotUnavailable:
|
||||
# Re-raise screenshot unavailable exceptions
|
||||
raise
|
||||
except Exception as e:
|
||||
# It's likely the screenshot was too long/big and something crashed
|
||||
raise ScreenshotUnavailable(url=url, status_code=self.status_code)
|
||||
@@ -413,10 +389,6 @@ class fetcher(Fetcher):
|
||||
pass
|
||||
browser = None
|
||||
|
||||
# Force Python GC to release Playwright resources immediately
|
||||
# Playwright objects can have circular references that delay cleanup
|
||||
gc.collect()
|
||||
|
||||
|
||||
# Plugin registration for built-in fetcher
|
||||
class PlaywrightFetcherPlugin:
|
||||
|
||||
@@ -15,7 +15,7 @@ class fetcher(Fetcher):
|
||||
proxy_url = None
|
||||
|
||||
# Capability flags
|
||||
supports_browser_steps = False
|
||||
supports_browser_steps = True
|
||||
supports_screenshots = True
|
||||
supports_xpath_element_data = True
|
||||
|
||||
@@ -156,19 +156,6 @@ class fetcher(Fetcher):
|
||||
from PIL import Image
|
||||
import io
|
||||
img = Image.open(io.BytesIO(screenshot_png))
|
||||
# Convert to RGB if needed (JPEG doesn't support transparency)
|
||||
# Always convert non-RGB modes to RGB to ensure JPEG compatibility
|
||||
if img.mode in ('RGBA', 'LA', 'P', 'PA'):
|
||||
# Handle transparency by compositing onto white background
|
||||
if img.mode == 'P':
|
||||
img = img.convert('RGBA')
|
||||
background = Image.new('RGB', img.size, (255, 255, 255))
|
||||
if img.mode in ('RGBA', 'LA', 'PA'):
|
||||
background.paste(img, mask=img.split()[-1]) # Use alpha channel as mask
|
||||
img = background
|
||||
elif img.mode != 'RGB':
|
||||
# For other modes, direct conversion
|
||||
img = img.convert('RGB')
|
||||
jpeg_buffer = io.BytesIO()
|
||||
img.save(jpeg_buffer, format='JPEG', quality=int(os.getenv("SCREENSHOT_QUALITY", 72)))
|
||||
self.screenshot = jpeg_buffer.getvalue()
|
||||
|
||||
@@ -57,15 +57,14 @@ class SignalPriorityQueue(queue.PriorityQueue):
|
||||
def put(self, item, block=True, timeout=None):
|
||||
# Call the parent's put method first
|
||||
super().put(item, block, timeout)
|
||||
|
||||
|
||||
# After putting the item in the queue, check if it has a UUID and emit signal
|
||||
if hasattr(item, 'item') and isinstance(item.item, dict) and 'uuid' in item.item:
|
||||
uuid = item.item['uuid']
|
||||
# Get the signal and send it if it exists
|
||||
watch_check_update = signal('watch_check_update')
|
||||
if watch_check_update:
|
||||
# NOTE: This would block other workers from .put/.get while this signal sends
|
||||
# Signal handlers may iterate the queue/datastore while holding locks
|
||||
# Send the watch_uuid parameter
|
||||
watch_check_update.send(watch_uuid=uuid)
|
||||
|
||||
# Send queue_length signal with current queue size
|
||||
@@ -313,15 +312,14 @@ class AsyncSignalPriorityQueue(asyncio.PriorityQueue):
|
||||
async def put(self, item):
|
||||
# Call the parent's put method first
|
||||
await super().put(item)
|
||||
|
||||
|
||||
# After putting the item in the queue, check if it has a UUID and emit signal
|
||||
if hasattr(item, 'item') and isinstance(item.item, dict) and 'uuid' in item.item:
|
||||
uuid = item.item['uuid']
|
||||
# Get the signal and send it if it exists
|
||||
watch_check_update = signal('watch_check_update')
|
||||
if watch_check_update:
|
||||
# NOTE: This would block other workers from .put/.get while this signal sends
|
||||
# Signal handlers may iterate the queue/datastore while holding locks
|
||||
# Send the watch_uuid parameter
|
||||
watch_check_update.send(watch_uuid=uuid)
|
||||
|
||||
# Send queue_length signal with current queue size
|
||||
|
||||
@@ -9,7 +9,6 @@ import threading
|
||||
import time
|
||||
import timeago
|
||||
from blinker import signal
|
||||
from pathlib import Path
|
||||
|
||||
from changedetectionio.strtobool import strtobool
|
||||
from threading import Event
|
||||
@@ -27,7 +26,9 @@ from flask import (
|
||||
session,
|
||||
url_for,
|
||||
)
|
||||
from urllib.parse import urlparse
|
||||
from flask_compress import Compress as FlaskCompress
|
||||
from flask_login import current_user
|
||||
from flask_restful import abort, Api
|
||||
from flask_cors import CORS
|
||||
|
||||
@@ -44,7 +45,6 @@ from changedetectionio.api import Watch, WatchHistory, WatchSingleHistory, Watch
|
||||
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
|
||||
IN_PYTEST = "pytest" in sys.modules or "PYTEST_CURRENT_TEST" in os.environ
|
||||
|
||||
datastore = None
|
||||
|
||||
@@ -55,7 +55,7 @@ extra_stylesheets = []
|
||||
# Use bulletproof janus-based queues for sync/async reliability
|
||||
update_q = RecheckPriorityQueue()
|
||||
notification_q = NotificationQueue()
|
||||
MAX_QUEUE_SIZE = 5000
|
||||
MAX_QUEUE_SIZE = 2000
|
||||
|
||||
app = Flask(__name__,
|
||||
static_url_path="",
|
||||
@@ -84,10 +84,6 @@ app.config['NEW_VERSION_AVAILABLE'] = False
|
||||
if os.getenv('FLASK_SERVER_NAME'):
|
||||
app.config['SERVER_NAME'] = os.getenv('FLASK_SERVER_NAME')
|
||||
|
||||
# Babel/i18n configuration
|
||||
app.config['BABEL_TRANSLATION_DIRECTORIES'] = str(Path(__file__).parent / 'translations')
|
||||
app.config['BABEL_DEFAULT_LOCALE'] = 'en_GB'
|
||||
|
||||
#app.config["EXPLAIN_TEMPLATE_LOADING"] = True
|
||||
|
||||
|
||||
@@ -301,25 +297,6 @@ def _jinja2_filter_fetcher_status_icons(fetcher_name):
|
||||
|
||||
return ''
|
||||
|
||||
@app.template_filter('sanitize_tag_class')
|
||||
def _jinja2_filter_sanitize_tag_class(tag_title):
|
||||
"""Sanitize a tag title to create a valid CSS class name.
|
||||
Removes all non-alphanumeric characters and converts to lowercase.
|
||||
|
||||
Args:
|
||||
tag_title: The tag title string
|
||||
|
||||
Returns:
|
||||
str: A sanitized string suitable for use as a CSS class name
|
||||
"""
|
||||
import re
|
||||
# Remove all non-alphanumeric characters and convert to lowercase
|
||||
sanitized = re.sub(r'[^a-zA-Z0-9]', '', tag_title).lower()
|
||||
# Ensure it starts with a letter (CSS requirement)
|
||||
if sanitized and not sanitized[0].isalpha():
|
||||
sanitized = 'tag' + sanitized
|
||||
return sanitized if sanitized else 'tag'
|
||||
|
||||
# Import login_optionally_required from auth_decorator
|
||||
from changedetectionio.auth_decorator import login_optionally_required
|
||||
|
||||
@@ -399,9 +376,13 @@ def changedetection_app(config=None, datastore_o=None):
|
||||
def get_locale():
|
||||
# 1. Try to get locale from session (user explicitly selected)
|
||||
if 'locale' in session:
|
||||
return session['locale']
|
||||
locale = session['locale']
|
||||
logger.trace(f"DEBUG: get_locale() returning from session: {locale}")
|
||||
return locale
|
||||
# 2. Fall back to Accept-Language header
|
||||
return request.accept_languages.best_match(language_codes)
|
||||
locale = request.accept_languages.best_match(language_codes)
|
||||
logger.trace(f"DEBUG: get_locale() returning from Accept-Language: {locale}")
|
||||
return locale
|
||||
|
||||
# Initialize Babel with locale selector
|
||||
babel = Babel(app, locale_selector=get_locale)
|
||||
@@ -518,20 +499,9 @@ def changedetection_app(config=None, datastore_o=None):
|
||||
@app.route('/set-language/<locale>')
|
||||
def set_language(locale):
|
||||
"""Set the user's preferred language in the session"""
|
||||
if not request.cookies:
|
||||
logger.error("Cannot set language without session cookie")
|
||||
flash("Cannot set language without session cookie", 'error')
|
||||
return redirect(url_for('watchlist.index'))
|
||||
|
||||
# Validate the locale against available languages
|
||||
if locale in language_codes:
|
||||
session['locale'] = locale
|
||||
|
||||
# CRITICAL: Flask-Babel caches the locale in the request context (ctx.babel_locale)
|
||||
# We must refresh to clear this cache so the new locale takes effect immediately
|
||||
# This is especially important for tests where multiple requests happen rapidly
|
||||
from flask_babel import refresh
|
||||
refresh()
|
||||
else:
|
||||
logger.error(f"Invalid locale {locale}, available: {language_codes}")
|
||||
|
||||
@@ -874,13 +844,13 @@ def changedetection_app(config=None, datastore_o=None):
|
||||
worker_handler.start_workers(n_workers, update_q, notification_q, app, datastore)
|
||||
|
||||
# @todo handle ctrl break
|
||||
ticker_thread = threading.Thread(target=ticker_thread_check_time_launch_checks, daemon=True, name="TickerThread-ScheduleChecker").start()
|
||||
threading.Thread(target=notification_runner, daemon=True, name="NotificationRunner").start()
|
||||
ticker_thread = threading.Thread(target=ticker_thread_check_time_launch_checks).start()
|
||||
threading.Thread(target=notification_runner).start()
|
||||
|
||||
in_pytest = "pytest" in sys.modules or "PYTEST_CURRENT_TEST" in os.environ
|
||||
# Check for new release version, but not when running in test/build or pytest
|
||||
if not os.getenv("GITHUB_REF", False) and not strtobool(os.getenv('DISABLE_VERSION_CHECK', 'no')) and not in_pytest:
|
||||
threading.Thread(target=check_for_new_version, daemon=True, name="VersionChecker").start()
|
||||
threading.Thread(target=check_for_new_version).start()
|
||||
|
||||
# Return the Flask app - the Socket.IO will be attached to it but initialized separately
|
||||
# This avoids circular dependencies
|
||||
@@ -925,7 +895,7 @@ def notification_runner():
|
||||
# At the moment only one thread runs (single runner)
|
||||
n_object = notification_q.get(block=False)
|
||||
except queue.Empty:
|
||||
app.config.exit.wait(1)
|
||||
time.sleep(1)
|
||||
|
||||
else:
|
||||
|
||||
@@ -962,7 +932,7 @@ def notification_runner():
|
||||
app.config['watch_check_update_SIGNAL'].send(app_context=app, watch_uuid=n_object.get('uuid'))
|
||||
|
||||
# Process notifications
|
||||
notification_debug_log+= ["{} - SENDING - {}".format(now.strftime("%c"), json.dumps(sent_obj))]
|
||||
notification_debug_log+= ["{} - SENDING - {}".format(now.strftime("%Y/%m/%d %H:%M:%S,000"), json.dumps(sent_obj))]
|
||||
# Trim the log length
|
||||
notification_debug_log = notification_debug_log[-100:]
|
||||
|
||||
@@ -978,10 +948,6 @@ def ticker_thread_check_time_launch_checks():
|
||||
logger.debug(f"System env MINIMUM_SECONDS_RECHECK_TIME {recheck_time_minimum_seconds}")
|
||||
|
||||
# Workers are now started during app initialization, not here
|
||||
WAIT_TIME_BETWEEN_LOOP = 1.0 if not IN_PYTEST else 0.01
|
||||
if IN_PYTEST:
|
||||
# The time between loops should be less than the first .sleep/wait in def wait_for_all_checks() of tests/util.py
|
||||
logger.warning(f"Looks like we're in PYTEST! Setting time between searching for items to add to the queue to {WAIT_TIME_BETWEEN_LOOP}s")
|
||||
|
||||
while not app.config.exit.is_set():
|
||||
|
||||
@@ -1005,9 +971,6 @@ def ticker_thread_check_time_launch_checks():
|
||||
# Get a list of watches by UUID that are currently fetching data
|
||||
running_uuids = worker_handler.get_running_uuids()
|
||||
|
||||
# Build set of queued UUIDs once for O(1) lookup instead of O(n) per watch
|
||||
queued_uuids = {q_item.item['uuid'] for q_item in update_q.queue}
|
||||
|
||||
# Re #232 - Deepcopy the data incase it changes while we're iterating through it all
|
||||
watch_uuid_list = []
|
||||
while True:
|
||||
@@ -1024,17 +987,16 @@ def ticker_thread_check_time_launch_checks():
|
||||
else:
|
||||
break
|
||||
|
||||
# Re #438 - Don't place more watches in the queue to be checked if the queue is already large
|
||||
while update_q.qsize() >= 2000:
|
||||
logger.warning(f"Recheck watches queue size limit reached ({MAX_QUEUE_SIZE}), skipping adding more items")
|
||||
time.sleep(3)
|
||||
|
||||
|
||||
recheck_time_system_seconds = int(datastore.threshold_seconds)
|
||||
|
||||
# Check for watches outside of the time threshold to put in the thread queue.
|
||||
for watch_index, uuid in enumerate(watch_uuid_list):
|
||||
# Re #438 - Check queue size every 100 watches for CPU efficiency (not every watch)
|
||||
if watch_index % 100 == 0:
|
||||
current_queue_size = update_q.qsize()
|
||||
if current_queue_size >= MAX_QUEUE_SIZE:
|
||||
logger.debug(f"Queue size limit reached ({current_queue_size}/{MAX_QUEUE_SIZE}), stopping scheduler this iteration.")
|
||||
break
|
||||
|
||||
for uuid in watch_uuid_list:
|
||||
now = time.time()
|
||||
watch = datastore.data['watching'].get(uuid)
|
||||
if not watch:
|
||||
@@ -1084,7 +1046,7 @@ def ticker_thread_check_time_launch_checks():
|
||||
seconds_since_last_recheck = now - watch['last_checked']
|
||||
|
||||
if seconds_since_last_recheck >= (threshold + watch.jitter_seconds) and seconds_since_last_recheck >= recheck_time_minimum_seconds:
|
||||
if not uuid in running_uuids and uuid not in queued_uuids:
|
||||
if not uuid in running_uuids and uuid not in [q_uuid.item['uuid'] for q_uuid in update_q.queue]:
|
||||
|
||||
# Proxies can be set to have a limit on seconds between which they can be called
|
||||
watch_proxy = datastore.get_preferred_proxy_for_watch(uuid=uuid)
|
||||
@@ -1126,5 +1088,8 @@ def ticker_thread_check_time_launch_checks():
|
||||
# Reset for next time
|
||||
watch.jitter_seconds = 0
|
||||
|
||||
# Wait before checking the list again - saves CPU
|
||||
time.sleep(1)
|
||||
|
||||
# Should be low so we can break this out in testing
|
||||
app.config.exit.wait(WAIT_TIME_BETWEEN_LOOP)
|
||||
app.config.exit.wait(1)
|
||||
|
||||
@@ -781,8 +781,8 @@ class SingleBrowserStep(Form):
|
||||
|
||||
class processor_text_json_diff_form(commonSettingsForm):
|
||||
|
||||
url = fields.URLField('Web Page URL', validators=[validateURL()])
|
||||
tags = StringTagUUID('Group Tag', [validators.Optional()], default='')
|
||||
url = fields.URLField('URL', validators=[validateURL()])
|
||||
tags = StringTagUUID('Group tag', [validators.Optional()], default='')
|
||||
|
||||
time_between_check = EnhancedFormField(
|
||||
TimeBetweenCheckForm,
|
||||
|
||||
@@ -539,18 +539,6 @@ def cdata_in_document_to_text(html_content: str, render_anchor_tag_content=False
|
||||
|
||||
|
||||
def html_to_text(html_content: str, render_anchor_tag_content=False, is_rss=False, timeout=10) -> str:
|
||||
"""
|
||||
Convert HTML content to plain text using inscriptis.
|
||||
|
||||
Thread-Safety: This function uses inscriptis.get_text() which internally calls
|
||||
lxml.html.fromstring() with the default parser. Testing with 50 concurrent threads
|
||||
confirms this approach is thread-safe and produces deterministic output.
|
||||
|
||||
Alternative Approach Rejected: An explicit HTMLParser instance (thread-local or fresh)
|
||||
would also be thread-safe, but was found to break change detection logic in subtle ways
|
||||
(test_check_basic_change_detection_functionality). The default parser provides correct
|
||||
and reliable behavior.
|
||||
"""
|
||||
from inscriptis import get_text
|
||||
from inscriptis.model.config import ParserConfig
|
||||
|
||||
|
||||
@@ -29,24 +29,18 @@ def get_timeago_locale(flask_locale):
|
||||
"""
|
||||
locale_map = {
|
||||
'zh': 'zh_CN', # Chinese Simplified
|
||||
# timeago library just hasn't been updated to use the more modern locale naming convention, before BCP 47 / RFC 5646.
|
||||
'zh_TW': 'zh_TW', # Chinese Traditional (timeago uses zh_TW)
|
||||
'zh_Hant_TW': 'zh_TW', # Flask-Babel normalizes zh_TW to zh_Hant_TW, map back to timeago's zh_TW
|
||||
'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
|
||||
'en_GB': 'en', # British English - timeago uses 'en'
|
||||
'en_US': 'en', # American English - timeago uses 'en'
|
||||
}
|
||||
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_GB': {'flag': 'fi fi-gb fis', 'name': 'English (UK)'},
|
||||
'en_US': {'flag': 'fi fi-us fis', 'name': 'English (US)'},
|
||||
'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': '한국어'},
|
||||
@@ -56,7 +50,7 @@ LANGUAGE_DATA = {
|
||||
'it': {'flag': 'fi fi-it fis', 'name': 'Italiano'},
|
||||
'ja': {'flag': 'fi fi-jp fis', 'name': '日本語'},
|
||||
'zh': {'flag': 'fi fi-cn fis', 'name': '中文 (简体)'},
|
||||
'zh_Hant_TW': {'flag': 'fi fi-tw 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'},
|
||||
@@ -77,7 +71,10 @@ def get_available_languages():
|
||||
"""
|
||||
translations_dir = Path(__file__).parent / 'translations'
|
||||
|
||||
available = {}
|
||||
# Always include English as base language
|
||||
available = {
|
||||
'en': LANGUAGE_DATA['en']
|
||||
}
|
||||
|
||||
# Scan for translation directories
|
||||
if translations_dir.exists():
|
||||
@@ -88,10 +85,6 @@ def get_available_languages():
|
||||
if po_file.exists():
|
||||
available[lang_dir.name] = LANGUAGE_DATA[lang_dir.name]
|
||||
|
||||
# If no English variants found, fall back to adding en_GB as default
|
||||
if 'en_GB' not in available and 'en_US' not in available:
|
||||
available['en_GB'] = LANGUAGE_DATA['en_GB']
|
||||
|
||||
return available
|
||||
|
||||
|
||||
|
||||
@@ -10,40 +10,25 @@ from pathlib import Path
|
||||
from loguru import logger
|
||||
|
||||
from .. import jinja2_custom as safe_jinja
|
||||
from ..diff import ADDED_PLACEMARKER_OPEN
|
||||
from ..html_tools import TRANSLATE_WHITESPACE_TABLE
|
||||
|
||||
FAVICON_RESAVE_THRESHOLD_SECONDS=86400
|
||||
BROTLI_COMPRESS_SIZE_THRESHOLD = int(os.getenv('SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD', 1024))
|
||||
|
||||
minimum_seconds_recheck_time = int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 3))
|
||||
mtable = {'seconds': 1, 'minutes': 60, 'hours': 3600, 'days': 86400, 'weeks': 86400 * 7}
|
||||
|
||||
def _brotli_save(contents, filepath, mode=None, fallback_uncompressed=False):
|
||||
def _brotli_compress_worker(conn, filepath, mode=None):
|
||||
"""
|
||||
Save compressed data using native brotli.
|
||||
Testing shows no memory leak when using gc.collect() after compression.
|
||||
Worker function to compress data with brotli in a separate process.
|
||||
This isolates memory - when process exits, OS reclaims all memory.
|
||||
|
||||
Args:
|
||||
contents: data to compress (str or bytes)
|
||||
conn: multiprocessing.Pipe connection to receive data
|
||||
filepath: destination file path
|
||||
mode: brotli compression mode (e.g., brotli.MODE_TEXT)
|
||||
fallback_uncompressed: if True, save uncompressed on failure; if False, raise exception
|
||||
|
||||
Returns:
|
||||
str: actual filepath saved (may differ from input if fallback used)
|
||||
|
||||
Raises:
|
||||
Exception: if compression fails and fallback_uncompressed is False
|
||||
"""
|
||||
import brotli
|
||||
import gc
|
||||
|
||||
# Ensure contents are bytes
|
||||
if isinstance(contents, str):
|
||||
contents = contents.encode('utf-8')
|
||||
|
||||
try:
|
||||
logger.debug(f"Starting brotli compression of {len(contents)} bytes.")
|
||||
# Receive data from parent process via pipe (avoids pickle overhead)
|
||||
contents = conn.recv()
|
||||
|
||||
if mode is not None:
|
||||
compressed_data = brotli.compress(contents, mode=mode)
|
||||
@@ -53,26 +38,117 @@ def _brotli_save(contents, filepath, mode=None, fallback_uncompressed=False):
|
||||
with open(filepath, 'wb') as f:
|
||||
f.write(compressed_data)
|
||||
|
||||
logger.debug(f"Finished brotli compression - From {len(contents)} to {len(compressed_data)} bytes.")
|
||||
# Send success status back
|
||||
conn.send(True)
|
||||
# No need for explicit cleanup - process exit frees all memory
|
||||
except Exception as e:
|
||||
logger.error(f"Brotli compression worker failed: {e}")
|
||||
conn.send(False)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# Force garbage collection to prevent memory buildup
|
||||
gc.collect()
|
||||
|
||||
return filepath
|
||||
def _brotli_subprocess_save(contents, filepath, mode=None, timeout=30, fallback_uncompressed=False):
|
||||
"""
|
||||
Save compressed data using subprocess to isolate memory.
|
||||
Uses Pipe to avoid pickle overhead for large data.
|
||||
|
||||
Args:
|
||||
contents: data to compress (str or bytes)
|
||||
filepath: destination file path
|
||||
mode: brotli compression mode (e.g., brotli.MODE_TEXT)
|
||||
timeout: subprocess timeout in seconds
|
||||
fallback_uncompressed: if True, save uncompressed on failure; if False, raise exception
|
||||
|
||||
Returns:
|
||||
str: actual filepath saved (may differ from input if fallback used)
|
||||
|
||||
Raises:
|
||||
Exception: if compression fails and fallback_uncompressed is False
|
||||
"""
|
||||
import brotli
|
||||
import multiprocessing
|
||||
import sys
|
||||
|
||||
# Ensure contents are bytes
|
||||
if isinstance(contents, str):
|
||||
contents = contents.encode('utf-8')
|
||||
|
||||
# Use explicit spawn context for thread safety (avoids fork() with multi-threaded parent)
|
||||
# Always use spawn - consistent behavior in tests and production
|
||||
ctx = multiprocessing.get_context('spawn')
|
||||
parent_conn, child_conn = ctx.Pipe()
|
||||
|
||||
# Run compression in subprocess using spawn (not fork)
|
||||
proc = ctx.Process(target=_brotli_compress_worker, args=(child_conn, filepath, mode))
|
||||
|
||||
# Windows-safe: Set daemon=False explicitly to avoid issues with process cleanup
|
||||
proc.daemon = False
|
||||
proc.start()
|
||||
|
||||
try:
|
||||
# Send data to subprocess via pipe (avoids pickle)
|
||||
parent_conn.send(contents)
|
||||
|
||||
# Wait for result with timeout
|
||||
if parent_conn.poll(timeout):
|
||||
success = parent_conn.recv()
|
||||
else:
|
||||
success = False
|
||||
logger.warning(f"Brotli compression subprocess timed out after {timeout}s")
|
||||
# Graceful termination with platform-aware cleanup
|
||||
try:
|
||||
proc.terminate()
|
||||
except Exception as term_error:
|
||||
logger.debug(f"Process termination issue (may be normal on Windows): {term_error}")
|
||||
|
||||
parent_conn.close()
|
||||
proc.join(timeout=5)
|
||||
|
||||
# Force kill if still alive after graceful termination
|
||||
if proc.is_alive():
|
||||
try:
|
||||
if sys.platform == 'win32':
|
||||
# Windows: use kill() which is more forceful
|
||||
proc.kill()
|
||||
else:
|
||||
# Unix: terminate() already sent SIGTERM, now try SIGKILL
|
||||
proc.kill()
|
||||
proc.join(timeout=2)
|
||||
except Exception as kill_error:
|
||||
logger.warning(f"Failed to kill brotli compression process: {kill_error}")
|
||||
|
||||
# Check if file was created successfully
|
||||
if success and os.path.exists(filepath):
|
||||
return filepath
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Brotli compression error: {e}")
|
||||
try:
|
||||
parent_conn.close()
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.join(timeout=2)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Compression failed
|
||||
if fallback_uncompressed:
|
||||
logger.warning(f"Brotli compression failed for {filepath}, saving uncompressed")
|
||||
fallback_path = filepath.replace('.br', '')
|
||||
with open(fallback_path, 'wb') as f:
|
||||
f.write(contents)
|
||||
return fallback_path
|
||||
else:
|
||||
raise Exception(f"Brotli compression failed for {filepath}: {e}")
|
||||
# Compression failed
|
||||
if fallback_uncompressed:
|
||||
logger.warning(f"Brotli compression failed for {filepath}, saving uncompressed")
|
||||
fallback_path = filepath.replace('.br', '')
|
||||
with open(fallback_path, 'wb') as f:
|
||||
f.write(contents)
|
||||
return fallback_path
|
||||
else:
|
||||
raise Exception(f"Brotli compression subprocess failed for {filepath}")
|
||||
|
||||
FAVICON_RESAVE_THRESHOLD_SECONDS=86400
|
||||
|
||||
|
||||
minimum_seconds_recheck_time = int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 3))
|
||||
mtable = {'seconds': 1, 'minutes': 60, 'hours': 3600, 'days': 86400, 'weeks': 86400 * 7}
|
||||
|
||||
class model(watch_base):
|
||||
__newest_history_key = None
|
||||
@@ -416,6 +492,7 @@ class model(watch_base):
|
||||
|
||||
self.ensure_data_dir_exists()
|
||||
|
||||
threshold = int(os.getenv('SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD', 1024))
|
||||
skip_brotli = strtobool(os.getenv('DISABLE_BROTLI_TEXT_SNAPSHOT', 'False'))
|
||||
|
||||
# Binary data - detect file type and save without compression
|
||||
@@ -439,7 +516,7 @@ class model(watch_base):
|
||||
|
||||
# Text data - use brotli compression if enabled and above threshold
|
||||
else:
|
||||
if not skip_brotli and len(contents) > BROTLI_COMPRESS_SIZE_THRESHOLD:
|
||||
if not skip_brotli and len(contents) > threshold:
|
||||
# Compressed text
|
||||
import brotli
|
||||
snapshot_fname = f"{snapshot_id}.txt.br"
|
||||
@@ -447,7 +524,7 @@ class model(watch_base):
|
||||
|
||||
if not os.path.exists(dest):
|
||||
try:
|
||||
actual_dest = _brotli_save(contents, dest, mode=brotli.MODE_TEXT, fallback_uncompressed=True)
|
||||
actual_dest = _brotli_subprocess_save(contents, dest, mode=brotli.MODE_TEXT, fallback_uncompressed=True)
|
||||
if actual_dest != dest:
|
||||
snapshot_fname = os.path.basename(actual_dest)
|
||||
except Exception as e:
|
||||
@@ -873,13 +950,13 @@ class model(watch_base):
|
||||
def save_last_text_fetched_before_filters(self, contents):
|
||||
import brotli
|
||||
filepath = os.path.join(self.watch_data_dir, 'last-fetched.br')
|
||||
_brotli_save(contents, filepath, mode=brotli.MODE_TEXT, fallback_uncompressed=False)
|
||||
_brotli_subprocess_save(contents, filepath, mode=brotli.MODE_TEXT, fallback_uncompressed=False)
|
||||
|
||||
def save_last_fetched_html(self, timestamp, contents):
|
||||
self.ensure_data_dir_exists()
|
||||
snapshot_fname = f"{timestamp}.html.br"
|
||||
filepath = os.path.join(self.watch_data_dir, snapshot_fname)
|
||||
_brotli_save(contents, filepath, mode=None, fallback_uncompressed=True)
|
||||
_brotli_subprocess_save(contents, filepath, mode=None, fallback_uncompressed=True)
|
||||
self._prune_last_fetched_html_snapshots()
|
||||
|
||||
def get_fetched_html(self, timestamp):
|
||||
|
||||
@@ -13,9 +13,14 @@ Research: https://github.com/libvips/pyvips/issues/234
|
||||
|
||||
import multiprocessing
|
||||
|
||||
# CRITICAL: Use 'spawn' context instead of 'fork' to avoid inheriting parent's
|
||||
# CRITICAL: Use 'spawn' instead of 'fork' to avoid inheriting parent's
|
||||
# LibVIPS threading state which can cause hangs in gaussblur operations
|
||||
# https://docs.python.org/3/library/multiprocessing.html#contexts-and-start-methods
|
||||
try:
|
||||
multiprocessing.set_start_method('spawn', force=False)
|
||||
except RuntimeError:
|
||||
# Already set, ignore
|
||||
pass
|
||||
|
||||
|
||||
def _worker_generate_diff(conn, img_bytes_from, img_bytes_to, threshold, blur_sigma, max_width, max_height):
|
||||
@@ -90,10 +95,9 @@ def generate_diff_isolated(img_bytes_from, img_bytes_to, threshold, blur_sigma,
|
||||
Returns:
|
||||
bytes: JPEG diff image or None on failure
|
||||
"""
|
||||
ctx = multiprocessing.get_context('spawn')
|
||||
parent_conn, child_conn = ctx.Pipe()
|
||||
parent_conn, child_conn = multiprocessing.Pipe()
|
||||
|
||||
p = ctx.Process(
|
||||
p = multiprocessing.Process(
|
||||
target=_worker_generate_diff,
|
||||
args=(child_conn, img_bytes_from, img_bytes_to, threshold, blur_sigma, max_width, max_height)
|
||||
)
|
||||
@@ -136,8 +140,7 @@ def calculate_change_percentage_isolated(img_bytes_from, img_bytes_to, threshold
|
||||
Returns:
|
||||
float: Change percentage
|
||||
"""
|
||||
ctx = multiprocessing.get_context('spawn')
|
||||
parent_conn, child_conn = ctx.Pipe()
|
||||
parent_conn, child_conn = multiprocessing.Pipe()
|
||||
|
||||
def _worker_calculate(conn):
|
||||
try:
|
||||
@@ -182,7 +185,7 @@ def calculate_change_percentage_isolated(img_bytes_from, img_bytes_to, threshold
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
p = ctx.Process(target=_worker_calculate, args=(child_conn,))
|
||||
p = multiprocessing.Process(target=_worker_calculate, args=(child_conn,))
|
||||
p.start()
|
||||
|
||||
result = 0.0
|
||||
@@ -230,8 +233,7 @@ def compare_images_isolated(img_bytes_from, img_bytes_to, threshold, blur_sigma,
|
||||
tuple: (changed_detected, change_percentage)
|
||||
"""
|
||||
print(f"[Parent] Starting compare_images_isolated subprocess", flush=True)
|
||||
ctx = multiprocessing.get_context('spawn')
|
||||
parent_conn, child_conn = ctx.Pipe()
|
||||
parent_conn, child_conn = multiprocessing.Pipe()
|
||||
|
||||
def _worker_compare(conn):
|
||||
try:
|
||||
@@ -299,7 +301,7 @@ def compare_images_isolated(img_bytes_from, img_bytes_to, threshold, blur_sigma,
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
p = ctx.Process(target=_worker_compare, args=(child_conn,))
|
||||
p = multiprocessing.Process(target=_worker_compare, args=(child_conn,))
|
||||
print(f"[Parent] Starting subprocess (pid will be assigned)", flush=True)
|
||||
p.start()
|
||||
print(f"[Parent] Subprocess started (pid={p.pid}), waiting for result (30s timeout)", flush=True)
|
||||
|
||||
@@ -86,7 +86,6 @@ class RecheckPriorityQueue:
|
||||
|
||||
def get(self, block: bool = True, timeout: Optional[float] = None):
|
||||
"""Thread-safe sync get with priority ordering"""
|
||||
import queue
|
||||
try:
|
||||
# Wait for notification
|
||||
self.sync_q.get(block=block, timeout=timeout)
|
||||
@@ -104,11 +103,8 @@ class RecheckPriorityQueue:
|
||||
logger.debug(f"Successfully retrieved item: {self._get_item_uuid(item)}")
|
||||
return item
|
||||
|
||||
except queue.Empty:
|
||||
# Queue is empty with timeout - expected behavior, re-raise without logging
|
||||
raise
|
||||
except Exception as e:
|
||||
# Re-raise without logging - caller (worker) will handle and log appropriately
|
||||
logger.critical(f"CRITICAL: Failed to get item from queue: {str(e)}")
|
||||
raise
|
||||
|
||||
# ASYNC INTERFACE (for workers)
|
||||
@@ -176,16 +172,7 @@ class RecheckPriorityQueue:
|
||||
def empty(self) -> bool:
|
||||
"""Check if queue is empty"""
|
||||
return self.qsize() == 0
|
||||
|
||||
def get_queued_uuids(self) -> list:
|
||||
"""Get list of all queued UUIDs efficiently with single lock"""
|
||||
try:
|
||||
with self._lock:
|
||||
return [item.item['uuid'] for item in self._priority_items if hasattr(item, 'item') and 'uuid' in item.item]
|
||||
except Exception as e:
|
||||
logger.critical(f"CRITICAL: Failed to get queued UUIDs: {str(e)}")
|
||||
return []
|
||||
|
||||
|
||||
def close(self):
|
||||
"""Close the janus queue"""
|
||||
try:
|
||||
|
||||
@@ -150,8 +150,11 @@ def handle_watch_update(socketio, **kwargs):
|
||||
# Get list of watches that are currently running
|
||||
running_uuids = worker_handler.get_running_uuids()
|
||||
|
||||
# Get list of watches in the queue (efficient single-lock method)
|
||||
queue_list = update_q.get_queued_uuids()
|
||||
# Get list of watches in the queue
|
||||
queue_list = []
|
||||
for q_item in update_q.queue:
|
||||
if hasattr(q_item, 'item') and 'uuid' in q_item.item:
|
||||
queue_list.append(q_item.item['uuid'])
|
||||
|
||||
# Get the error texts from the watch
|
||||
error_texts = watch.compile_error_texts()
|
||||
|
||||
@@ -82,22 +82,27 @@ echo "RUNNING WITH BASE_URL SET"
|
||||
# Re #65 - Ability to include a link back to the installation, in the notification.
|
||||
export BASE_URL="https://really-unique-domain.io"
|
||||
|
||||
REMOVE_REQUESTS_OLD_SCREENSHOTS=false pytest -vv -s --maxfail=1 tests/test_notification.py
|
||||
|
||||
|
||||
# Re-run with HIDE_REFERER set - could affect login
|
||||
export HIDE_REFERER=True
|
||||
REMOVE_REQUESTS_OLD_SCREENSHOTS=false pytest -vv -s --maxfail=1 tests/test_notification.py tests/test_access_control.py
|
||||
|
||||
pytest -vv -s --maxfail=1 tests/test_access_control.py
|
||||
|
||||
# Re-run a few tests that will trigger brotli based storage
|
||||
# And again with brotli+screenshot attachment
|
||||
SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD=5 REMOVE_REQUESTS_OLD_SCREENSHOTS=false pytest -vv -s --maxfail=1 --dist=load tests/test_backend.py tests/test_rss.py tests/test_unique_lines.py tests/test_notification.py tests/test_access_control.py
|
||||
export SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD=5
|
||||
pytest -vv -s --maxfail=1 tests/test_access_control.py
|
||||
REMOVE_REQUESTS_OLD_SCREENSHOTS=false pytest tests/test_notification.py
|
||||
pytest -vv -s --maxfail=1 tests/test_backend.py
|
||||
pytest -vv -s --maxfail=1 tests/test_rss.py
|
||||
pytest -vv -s --maxfail=1 tests/test_unique_lines.py
|
||||
|
||||
# Try high concurrency
|
||||
FETCH_WORKERS=50 pytest tests/test_history_consistency.py -vv -l -s
|
||||
FETCH_WORKERS=130 pytest tests/test_history_consistency.py -v -l
|
||||
|
||||
# Check file:// will pickup a file when enabled
|
||||
echo "Hello world" > /tmp/test-file.txt
|
||||
ALLOW_FILE_URI=yes pytest -vv -s tests/test_security.py
|
||||
|
||||
|
||||
# Run it again so that brotli kicks in
|
||||
TEST_WITH_BROTLI=1 SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD=100 FETCH_WORKERS=20 pytest tests/test_history_consistency.py -vv -l -s
|
||||
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
/**
|
||||
* Flask Toast Bridge
|
||||
* Automatically converts Flask flash messages to toast notifications
|
||||
*
|
||||
* Maps Flask message categories to toast types:
|
||||
* - 'message' or 'info' -> info toast
|
||||
* - 'success' -> success toast
|
||||
* - 'error' or 'danger' -> error toast
|
||||
* - 'warning' -> warning toast
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Find the Flask messages container
|
||||
const messagesContainer = document.querySelector('ul.messages');
|
||||
|
||||
if (!messagesContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all flash messages
|
||||
const messages = messagesContainer.querySelectorAll('li');
|
||||
|
||||
if (messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let toastIndex = 0;
|
||||
|
||||
// Convert each message to a toast (except errors)
|
||||
messages.forEach(function(messageEl) {
|
||||
const text = messageEl.textContent.trim();
|
||||
const category = getMessageCategory(messageEl);
|
||||
|
||||
// Skip error messages - they should stay in the page
|
||||
if (category === 'error') {
|
||||
return;
|
||||
}
|
||||
|
||||
const toastType = mapCategoryToToastType(category);
|
||||
|
||||
// Stagger toast appearance for multiple messages
|
||||
setTimeout(function() {
|
||||
Toast[toastType](text, {
|
||||
duration: 6000 // 6 seconds for Flask messages
|
||||
});
|
||||
}, toastIndex * 200); // 200ms delay between each toast
|
||||
|
||||
toastIndex++;
|
||||
|
||||
// Hide this specific message element (not errors)
|
||||
messageEl.style.display = 'none';
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Extract message category from class names
|
||||
*/
|
||||
function getMessageCategory(messageEl) {
|
||||
const classes = messageEl.className.split(' ');
|
||||
|
||||
// Common Flask flash message categories
|
||||
const categoryMap = {
|
||||
'success': 'success',
|
||||
'error': 'error',
|
||||
'danger': 'error',
|
||||
'warning': 'warning',
|
||||
'info': 'info',
|
||||
'message': 'info',
|
||||
'notice': 'info'
|
||||
};
|
||||
|
||||
for (let className of classes) {
|
||||
if (categoryMap[className]) {
|
||||
return categoryMap[className];
|
||||
}
|
||||
}
|
||||
|
||||
// Default to info if no category found
|
||||
return 'info';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Flask category to Toast type
|
||||
*/
|
||||
function mapCategoryToToastType(category) {
|
||||
const typeMap = {
|
||||
'success': 'success',
|
||||
'error': 'error',
|
||||
'warning': 'warning',
|
||||
'info': 'info'
|
||||
};
|
||||
|
||||
return typeMap[category] || 'info';
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -1,69 +0,0 @@
|
||||
// Hamburger menu toggle functionality
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const hamburgerToggle = document.getElementById('hamburger-toggle');
|
||||
const mobileMenuDrawer = document.getElementById('mobile-menu-drawer');
|
||||
const mobileMenuOverlay = document.getElementById('mobile-menu-overlay');
|
||||
|
||||
if (!hamburgerToggle || !mobileMenuDrawer || !mobileMenuOverlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
function openMenu() {
|
||||
hamburgerToggle.classList.add('active');
|
||||
mobileMenuDrawer.classList.add('active');
|
||||
mobileMenuOverlay.classList.add('active');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
hamburgerToggle.classList.remove('active');
|
||||
mobileMenuDrawer.classList.remove('active');
|
||||
mobileMenuOverlay.classList.remove('active');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
if (mobileMenuDrawer.classList.contains('active')) {
|
||||
closeMenu();
|
||||
} else {
|
||||
openMenu();
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle menu on hamburger click
|
||||
hamburgerToggle.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
toggleMenu();
|
||||
});
|
||||
|
||||
// Close menu when clicking overlay
|
||||
mobileMenuOverlay.addEventListener('click', closeMenu);
|
||||
|
||||
// Close menu when clicking a menu item
|
||||
const menuItems = mobileMenuDrawer.querySelectorAll('.mobile-menu-items a');
|
||||
menuItems.forEach(function(item) {
|
||||
item.addEventListener('click', closeMenu);
|
||||
});
|
||||
|
||||
// Close menu on escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && mobileMenuDrawer.classList.contains('active')) {
|
||||
closeMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// Close menu when window is resized above mobile breakpoint
|
||||
let resizeTimer;
|
||||
window.addEventListener('resize', function() {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(function() {
|
||||
if (window.innerWidth > 768 && mobileMenuDrawer.classList.contains('active')) {
|
||||
closeMenu();
|
||||
}
|
||||
}, 250);
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -3,17 +3,17 @@
|
||||
* Allows users to select their preferred language
|
||||
*/
|
||||
|
||||
$(document).ready(function() {
|
||||
const $languageButton = $('.language-selector');
|
||||
const $languageModal = $('#language-modal');
|
||||
const $closeButton = $('#close-language-modal');
|
||||
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.length || !$languageModal.length) {
|
||||
if (!languageButton || !languageModal) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Open modal when language button is clicked
|
||||
$languageButton.on('click', function(e) {
|
||||
languageButton.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Update all language links to include current hash in the redirect parameter
|
||||
@@ -21,53 +21,51 @@ $(document).ready(function() {
|
||||
const currentHash = window.location.hash;
|
||||
|
||||
if (currentHash) {
|
||||
const $languageOptions = $languageModal.find('.language-option');
|
||||
$languageOptions.each(function() {
|
||||
const $option = $(this);
|
||||
const url = new URL($option.attr('href'), window.location.origin);
|
||||
const languageOptions = languageModal.querySelectorAll('.language-option');
|
||||
languageOptions.forEach(function(option) {
|
||||
const url = new URL(option.href, window.location.origin);
|
||||
// Update the redirect parameter to include the hash
|
||||
const redirectPath = currentPath + currentHash;
|
||||
url.searchParams.set('redirect', redirectPath);
|
||||
$option.attr('href', url.pathname + url.search + url.hash);
|
||||
option.setAttribute('href', url.pathname + url.search + url.hash);
|
||||
});
|
||||
}
|
||||
|
||||
$languageModal[0].showModal();
|
||||
languageModal.showModal();
|
||||
});
|
||||
|
||||
// Close modal when cancel button is clicked
|
||||
if ($closeButton.length) {
|
||||
$closeButton.on('click', function() {
|
||||
$languageModal[0].close();
|
||||
if (closeButton) {
|
||||
closeButton.addEventListener('click', function() {
|
||||
languageModal.close();
|
||||
});
|
||||
}
|
||||
|
||||
// Close modal when clicking outside (on backdrop)
|
||||
$languageModal.on('click', function(e) {
|
||||
const rect = this.getBoundingClientRect();
|
||||
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[0].close();
|
||||
languageModal.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Close modal on Escape key
|
||||
$languageModal.on('cancel', function(e) {
|
||||
languageModal.addEventListener('cancel', function(e) {
|
||||
e.preventDefault();
|
||||
$languageModal[0].close();
|
||||
languageModal.close();
|
||||
});
|
||||
|
||||
// Highlight current language
|
||||
const currentLocale = $('html').attr('lang') || 'en';
|
||||
const $languageOptions = $languageModal.find('.language-option');
|
||||
$languageOptions.each(function() {
|
||||
const $option = $(this);
|
||||
if ($option.attr('data-locale') === currentLocale) {
|
||||
$option.addClass('active');
|
||||
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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,9 +74,6 @@ $(document).ready(function () {
|
||||
}
|
||||
|
||||
|
||||
// Cache DOM elements for performance
|
||||
const queueBubble = document.getElementById('queue-bubble');
|
||||
const queueSizePagerInfoText = document.getElementById('queue-size-int');
|
||||
// Only try to connect if authentication isn't required or user is authenticated
|
||||
// The 'is_authenticated' variable will be set in the template
|
||||
if (typeof is_authenticated !== 'undefined' ? is_authenticated : true) {
|
||||
@@ -118,44 +115,7 @@ $(document).ready(function () {
|
||||
|
||||
socket.on('queue_size', function (data) {
|
||||
console.log(`${data.event_timestamp} - Queue size update: ${data.q_length}`);
|
||||
if(queueSizePagerInfoText) {
|
||||
queueSizePagerInfoText.textContent = parseInt(data.q_length).toLocaleString() || 'None';
|
||||
}
|
||||
document.body.classList.toggle('has-queue', parseInt(data.q_length) > 0);
|
||||
|
||||
// Update queue bubble in action sidebar
|
||||
//if (queueBubble) {
|
||||
if (0) {
|
||||
const count = parseInt(data.q_length) || 0;
|
||||
const oldCount = parseInt(queueBubble.getAttribute('data-count')) || 0;
|
||||
|
||||
if (count > 0) {
|
||||
// Format number according to browser locale
|
||||
const formatter = new Intl.NumberFormat(navigator.language);
|
||||
queueBubble.textContent = formatter.format(count);
|
||||
queueBubble.setAttribute('data-count', count);
|
||||
queueBubble.classList.add('visible');
|
||||
|
||||
// Add large-number class for numbers > 999
|
||||
if (count > 999) {
|
||||
queueBubble.classList.add('large-number');
|
||||
} else {
|
||||
queueBubble.classList.remove('large-number');
|
||||
}
|
||||
|
||||
// Pulse animation if count changed
|
||||
if (count !== oldCount) {
|
||||
queueBubble.classList.remove('pulse');
|
||||
// Force reflow to restart animation
|
||||
void queueBubble.offsetWidth;
|
||||
queueBubble.classList.add('pulse');
|
||||
}
|
||||
} else {
|
||||
// Hide bubble when queue is empty
|
||||
queueBubble.classList.remove('visible', 'pulse', 'large-number');
|
||||
queueBubble.setAttribute('data-count', '0');
|
||||
}
|
||||
}
|
||||
// Update queue size display if implemented in the UI
|
||||
})
|
||||
|
||||
// Listen for operation results
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
// Search modal functionality
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const searchModal = document.getElementById('search-modal');
|
||||
const openSearchButton = document.getElementById('open-search-modal');
|
||||
const closeSearchButton = document.getElementById('close-search-modal');
|
||||
const searchForm = document.getElementById('search-form');
|
||||
const searchInput = document.getElementById('search-modal-input');
|
||||
|
||||
if (!searchModal || !openSearchButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Open modal
|
||||
function openSearchModal() {
|
||||
searchModal.showModal();
|
||||
// Focus the input after a small delay to ensure modal is rendered
|
||||
setTimeout(function() {
|
||||
if (searchInput) {
|
||||
searchInput.focus();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Close modal
|
||||
function closeSearchModal() {
|
||||
searchModal.close();
|
||||
if (searchInput) {
|
||||
searchInput.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Open search modal on button click
|
||||
openSearchButton.addEventListener('click', openSearchModal);
|
||||
|
||||
// Close modal on cancel button
|
||||
if (closeSearchButton) {
|
||||
closeSearchButton.addEventListener('click', closeSearchModal);
|
||||
}
|
||||
|
||||
// Close modal on escape key (native behavior for dialog)
|
||||
searchModal.addEventListener('cancel', function(e) {
|
||||
if (searchInput) {
|
||||
searchInput.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Close modal when clicking the backdrop
|
||||
searchModal.addEventListener('click', function(e) {
|
||||
const rect = searchModal.getBoundingClientRect();
|
||||
const isInDialog = (
|
||||
rect.top <= e.clientY &&
|
||||
e.clientY <= rect.top + rect.height &&
|
||||
rect.left <= e.clientX &&
|
||||
e.clientX <= rect.left + rect.width
|
||||
);
|
||||
if (!isInDialog) {
|
||||
closeSearchModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle Alt+S keyboard shortcut
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.altKey && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault();
|
||||
openSearchModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
if (searchForm) {
|
||||
searchForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Get form data
|
||||
const formData = new FormData(searchForm);
|
||||
const searchQuery = formData.get('q');
|
||||
const tags = formData.get('tags');
|
||||
|
||||
// Build URL
|
||||
const params = new URLSearchParams();
|
||||
if (searchQuery) {
|
||||
params.append('q', searchQuery);
|
||||
}
|
||||
if (tags) {
|
||||
params.append('tags', tags);
|
||||
}
|
||||
|
||||
// Navigate to search results
|
||||
window.location.href = '?' + params.toString();
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -1,12 +1,11 @@
|
||||
// Rewrite this is a plugin.. is all this JS really 'worth it?'
|
||||
|
||||
window.addEventListener('hashchange', function () {
|
||||
// Only remove active from tab elements, not menu items
|
||||
var tabs = document.querySelectorAll('.tabs li.active');
|
||||
tabs.forEach(function(tab) {
|
||||
tab.classList.remove('active');
|
||||
});
|
||||
document.body.classList.remove('full-width');
|
||||
var tabs = document.getElementsByClassName('active');
|
||||
while (tabs[0]) {
|
||||
tabs[0].classList.remove('active');
|
||||
document.body.classList.remove('full-width');
|
||||
}
|
||||
set_active_tab();
|
||||
}, false);
|
||||
|
||||
@@ -23,9 +22,9 @@ if (!has_errors.length) {
|
||||
|
||||
function set_active_tab() {
|
||||
document.body.classList.remove('full-width');
|
||||
var tab = document.querySelectorAll(".tabs a[href='" + location.hash + "']");
|
||||
var tab = document.querySelectorAll("a[href='" + location.hash + "']");
|
||||
if (tab.length) {
|
||||
tab[0].parentElement.classList.add("active");
|
||||
tab[0].parentElement.className = "active";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
/**
|
||||
* Toast - Modern toast notification system
|
||||
* Inspired by Toastify, Notyf, and React Hot Toast
|
||||
*
|
||||
* Usage:
|
||||
* Toast.success('Operation completed!');
|
||||
* Toast.error('Something went wrong');
|
||||
* Toast.info('Here is some information');
|
||||
* Toast.warning('Warning message');
|
||||
* Toast.show('Custom message', { type: 'success', duration: 3000 });
|
||||
*
|
||||
* License: MIT
|
||||
*/
|
||||
|
||||
(function(window) {
|
||||
'use strict';
|
||||
|
||||
// Toast configuration
|
||||
const defaultConfig = {
|
||||
duration: 5000, // Auto-dismiss after 5 seconds (0 = no auto-dismiss)
|
||||
position: 'top-center', // top-right, top-center, top-left, bottom-right, bottom-center, bottom-left
|
||||
closeButton: true, // Show close button
|
||||
progressBar: true, // Show progress bar
|
||||
pauseOnHover: true, // Pause auto-dismiss on hover
|
||||
maxToasts: 5, // Maximum toasts to show at once
|
||||
offset: '20px', // Offset from edge
|
||||
zIndex: 10000, // Z-index for toast container
|
||||
};
|
||||
|
||||
let config = { ...defaultConfig };
|
||||
let toastCount = 0;
|
||||
let container = null;
|
||||
|
||||
/**
|
||||
* Initialize toast system with custom config
|
||||
*/
|
||||
function init(userConfig = {}) {
|
||||
config = { ...defaultConfig, ...userConfig };
|
||||
createContainer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create toast container if it doesn't exist
|
||||
*/
|
||||
function createContainer() {
|
||||
if (container) return;
|
||||
|
||||
container = document.createElement('div');
|
||||
container.className = `toast-container toast-${config.position}`;
|
||||
container.style.zIndex = config.zIndex;
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a toast notification
|
||||
*/
|
||||
function show(message, options = {}) {
|
||||
createContainer();
|
||||
|
||||
const toast = createToastElement(message, options);
|
||||
|
||||
// Limit number of toasts
|
||||
const existingToasts = container.querySelectorAll('.toast');
|
||||
if (existingToasts.length >= config.maxToasts) {
|
||||
removeToast(existingToasts[0]);
|
||||
}
|
||||
|
||||
// Add to container
|
||||
container.appendChild(toast);
|
||||
|
||||
// Trigger animation
|
||||
requestAnimationFrame(() => {
|
||||
toast.classList.add('toast-show');
|
||||
});
|
||||
|
||||
// Auto-dismiss
|
||||
if (options.duration !== 0 && (options.duration || config.duration) > 0) {
|
||||
setupAutoDismiss(toast, options.duration || config.duration);
|
||||
}
|
||||
|
||||
return {
|
||||
dismiss: () => removeToast(toast)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create toast DOM element
|
||||
*/
|
||||
function createToastElement(message, options) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${options.type || 'default'}`;
|
||||
toast.setAttribute('role', 'alert');
|
||||
toast.setAttribute('aria-live', 'polite');
|
||||
|
||||
// Icon
|
||||
const icon = createIcon(options.type || 'default');
|
||||
if (icon) {
|
||||
toast.appendChild(icon);
|
||||
}
|
||||
|
||||
// Message
|
||||
const messageEl = document.createElement('div');
|
||||
messageEl.className = 'toast-message';
|
||||
messageEl.textContent = message;
|
||||
toast.appendChild(messageEl);
|
||||
|
||||
// Close button
|
||||
if (options.closeButton !== false && config.closeButton) {
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'toast-close';
|
||||
closeBtn.innerHTML = '×';
|
||||
closeBtn.setAttribute('aria-label', 'Close');
|
||||
closeBtn.onclick = () => removeToast(toast);
|
||||
toast.appendChild(closeBtn);
|
||||
}
|
||||
|
||||
// Progress bar
|
||||
if (options.progressBar !== false && config.progressBar && (options.duration || config.duration) > 0) {
|
||||
const progressBar = document.createElement('div');
|
||||
progressBar.className = 'toast-progress';
|
||||
toast.appendChild(progressBar);
|
||||
toast._progressBar = progressBar;
|
||||
}
|
||||
|
||||
return toast;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create icon based on toast type
|
||||
*/
|
||||
function createIcon(type) {
|
||||
const iconEl = document.createElement('div');
|
||||
iconEl.className = 'toast-icon';
|
||||
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('viewBox', '0 0 24 24');
|
||||
svg.setAttribute('fill', 'none');
|
||||
svg.setAttribute('stroke', 'currentColor');
|
||||
svg.setAttribute('stroke-width', '2');
|
||||
|
||||
let path = '';
|
||||
switch (type) {
|
||||
case 'success':
|
||||
path = 'M20 6L9 17l-5-5';
|
||||
break;
|
||||
case 'error':
|
||||
path = 'M18 6L6 18M6 6l12 12';
|
||||
break;
|
||||
case 'warning':
|
||||
path = 'M12 9v4m0 4h.01M12 2a10 10 0 100 20 10 10 0 000-20z';
|
||||
svg.setAttribute('stroke-width', '1.5');
|
||||
break;
|
||||
case 'info':
|
||||
path = 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z';
|
||||
svg.setAttribute('stroke-width', '1.5');
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
const pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
pathEl.setAttribute('d', path);
|
||||
pathEl.setAttribute('stroke-linecap', 'round');
|
||||
pathEl.setAttribute('stroke-linejoin', 'round');
|
||||
svg.appendChild(pathEl);
|
||||
iconEl.appendChild(svg);
|
||||
|
||||
return iconEl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup auto-dismiss with progress bar
|
||||
*/
|
||||
function setupAutoDismiss(toast, duration) {
|
||||
let startTime = Date.now();
|
||||
let remainingTime = duration;
|
||||
let isPaused = false;
|
||||
let animationFrame;
|
||||
|
||||
function updateProgress() {
|
||||
if (isPaused) return;
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
if (toast._progressBar) {
|
||||
toast._progressBar.style.transform = `scaleX(${1 - progress})`;
|
||||
}
|
||||
|
||||
if (progress >= 1) {
|
||||
removeToast(toast);
|
||||
} else {
|
||||
animationFrame = requestAnimationFrame(updateProgress);
|
||||
}
|
||||
}
|
||||
|
||||
// Pause on hover
|
||||
if (config.pauseOnHover) {
|
||||
toast.addEventListener('mouseenter', () => {
|
||||
isPaused = true;
|
||||
remainingTime = duration - (Date.now() - startTime);
|
||||
cancelAnimationFrame(animationFrame);
|
||||
});
|
||||
|
||||
toast.addEventListener('mouseleave', () => {
|
||||
isPaused = false;
|
||||
startTime = Date.now();
|
||||
duration = remainingTime;
|
||||
animationFrame = requestAnimationFrame(updateProgress);
|
||||
});
|
||||
}
|
||||
|
||||
animationFrame = requestAnimationFrame(updateProgress);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove toast with animation
|
||||
*/
|
||||
function removeToast(toast) {
|
||||
if (!toast || !toast.parentElement) return;
|
||||
|
||||
toast.classList.add('toast-hide');
|
||||
|
||||
// Remove after animation
|
||||
setTimeout(() => {
|
||||
if (toast.parentElement) {
|
||||
toast.parentElement.removeChild(toast);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Convenience methods
|
||||
function success(message, options = {}) {
|
||||
return show(message, { ...options, type: 'success' });
|
||||
}
|
||||
|
||||
function error(message, options = {}) {
|
||||
return show(message, { ...options, type: 'error' });
|
||||
}
|
||||
|
||||
function warning(message, options = {}) {
|
||||
return show(message, { ...options, type: 'warning' });
|
||||
}
|
||||
|
||||
function info(message, options = {}) {
|
||||
return show(message, { ...options, type: 'info' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all toasts
|
||||
*/
|
||||
function clear() {
|
||||
if (!container) return;
|
||||
const toasts = container.querySelectorAll('.toast');
|
||||
toasts.forEach(removeToast);
|
||||
}
|
||||
|
||||
// Public API
|
||||
window.Toast = {
|
||||
init,
|
||||
show,
|
||||
success,
|
||||
error,
|
||||
warning,
|
||||
info,
|
||||
clear,
|
||||
version: '1.0.0'
|
||||
};
|
||||
|
||||
// Auto-initialize
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
init();
|
||||
});
|
||||
|
||||
})(window);
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* Minified by jsDelivr using clean-css v5.3.3.
|
||||
* Original file: /npm/toastify-js@1.12.0/src/toastify.css
|
||||
*
|
||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||
*/
|
||||
/*!
|
||||
* Toastify js 1.12.0
|
||||
* https://github.com/apvarun/toastify-js
|
||||
* @license MIT licensed
|
||||
*
|
||||
* Copyright (C) 2018 Varun A P
|
||||
*/
|
||||
.toastify{padding:12px 20px;color:#fff;display:inline-block;box-shadow:0 3px 6px -1px rgba(0,0,0,.12),0 10px 36px -4px rgba(77,96,232,.3);background:-webkit-linear-gradient(315deg,#73a5ff,#5477f5);background:linear-gradient(135deg,#73a5ff,#5477f5);position:fixed;opacity:0;transition:all .4s cubic-bezier(.215, .61, .355, 1);border-radius:2px;cursor:pointer;text-decoration:none;max-width:calc(50% - 20px);z-index:2147483647}.toastify.on{opacity:1}.toast-close{background:0 0;border:0;color:#fff;cursor:pointer;font-family:inherit;font-size:1em;opacity:.4;padding:0 5px}.toastify-right{right:15px}.toastify-left{left:15px}.toastify-top{top:-150px}.toastify-bottom{bottom:-150px}.toastify-rounded{border-radius:25px}.toastify-avatar{width:1.5em;height:1.5em;margin:-7px 5px;border-radius:2px}.toastify-center{margin-left:auto;margin-right:auto;left:0;right:0;max-width:fit-content;max-width:-moz-fit-content}@media only screen and (max-width:360px){.toastify-left,.toastify-right{margin-left:auto;margin-right:auto;left:0;right:0;max-width:fit-content}}
|
||||
/*# sourceMappingURL=/sm/cb4335d1b03e933ed85cb59fffa60cf51f07567ed09831438c60f59afd166464.map */
|
||||
-15
File diff suppressed because one or more lines are too long
@@ -3,12 +3,14 @@
|
||||
* Toggles theme between light and dark mode.
|
||||
*/
|
||||
$(document).ready(function () {
|
||||
const button = document.getElementById("toggle-light-mode");
|
||||
|
||||
$(".toggle-light-mode").on("click", function () {
|
||||
const isDark = $("html").attr("data-darkmode") === "true";
|
||||
$("html").attr("data-darkmode", !isDark);
|
||||
setCookieValue(!isDark);
|
||||
});
|
||||
button.onclick = () => {
|
||||
const htmlElement = document.getElementsByTagName("html");
|
||||
const isDarkMode = htmlElement[0].dataset.darkmode === "true";
|
||||
htmlElement[0].dataset.darkmode = !isDarkMode;
|
||||
setCookieValue(!isDarkMode);
|
||||
};
|
||||
|
||||
const setCookieValue = (value) => {
|
||||
document.cookie = `css_dark_mode=${value};max-age=31536000;path=/`
|
||||
|
||||
@@ -1 +1 @@
|
||||
.comparison-score{padding:1em;background:var(--color-table-stripe);border-radius:4px;margin:1em 0;border:1px solid var(--color-border-table-cell);color:var(--color-text)}.change-detected{color:#d32f2f;font-weight:bold}.no-change{color:#388e3c;font-weight:bold}.comparison-grid{display:grid;grid-template-columns:1fr 1fr;gap:1em;margin:1em 1em}@media(max-width: 1200px){.comparison-grid{grid-template-columns:1fr}}.image-comparison{position:relative;width:100%;overflow:hidden;border:1px solid var(--color-border-table-cell);box-shadow:0 2px 4px rgba(0, 0, 0, 0.1);user-select:none}.image-comparison img{display:block;width:100%;height:auto;max-width:100%;border:none;box-shadow:none}.comparison-image-wrapper{position:relative;width:100%;display:flex;align-items:flex-start;justify-content:center;background-color:var(--color-background);background-image:linear-gradient(45deg, var(--color-table-stripe) 25%, transparent 25%),linear-gradient(-45deg, var(--color-table-stripe) 25%, transparent 25%),linear-gradient(45deg, transparent 75%, var(--color-table-stripe) 75%),linear-gradient(-45deg, transparent 75%, var(--color-table-stripe) 75%);background-size:20px 20px;background-position:0 0,0 10px,10px -10px,-10px 0px}.comparison-after{position:absolute;top:0;left:0;width:100%;height:100%;clip-path:inset(0 0 0 50%)}.comparison-slider{position:absolute;top:0;left:50%;width:4px;height:100%;background:#0078e7;cursor:ew-resize;transform:translateX(-2px);z-index:10}.comparison-handle{position:absolute;top:50%;left:50%;width:48px;height:48px;background:#0078e7;border:3px solid #fff;border-radius:50%;transform:translate(-50%, -50%);box-shadow:0 2px 8px rgba(0, 0, 0, 0.3);display:flex;align-items:center;justify-content:center;cursor:ew-resize;transition:top .1s ease-out}.comparison-handle::after{content:"⇄";color:#fff;font-size:24px;font-weight:bold;pointer-events:none}.comparison-labels{position:absolute;top:10px;width:100%;display:flex;justify-content:space-between;padding:0 0px;z-index:5;pointer-events:none}.comparison-label{background:rgba(0, 0, 0, 0.7);color:#fff;padding:.5em 1em;border-radius:4px;font-size:.9em;font-weight:bold}.screenshot-panel{text-align:center;background:var(--color-background);border:1px solid var(--color-border-table-cell);border-radius:4px;padding:1em;box-shadow:0 2px 4px rgba(0, 0, 0, 0.05)}.screenshot-panel h3{margin:0 0 1em 0;font-size:1.1em;color:var(--color-text);border-bottom:2px solid var(--color-background-button-primary);padding-bottom:.5em}.screenshot-panel.diff h3{border-bottom-color:#d32f2f}.screenshot-panel img{max-width:100%;height:auto;border:1px solid var(--color-border-table-cell);box-shadow:0 2px 4px rgba(0, 0, 0, 0.1)}.version-selector{display:inline-block;margin:0 .5em}.version-selector label{font-weight:bold;margin-right:.5em;color:var(--color-text)}#settings{background:var(--color-background);padding:1.5em;border-radius:4px;box-shadow:0 2px 4px rgba(0, 0, 0, 0.05);margin-bottom:2em;border:1px solid var(--color-border-table-cell)}#settings h2{margin-top:0;color:var(--color-text)}.diff-fieldset{border:none;padding:0;margin:0}.edit-link{float:right;margin-top:-0.5em}.comparison-description{color:var(--color-text-input-description);font-size:.9em;margin-bottom:1em}.download-link{color:var(--color-link);text-decoration:none;display:inline-flex;align-items:center;gap:.3em;font-size:.85em}.download-link:hover{text-decoration:underline}.diff-section-header{color:#d32f2f;font-size:.9em;margin-bottom:1em;font-weight:bold;display:flex;align-items:center;justify-content:center;gap:1em}.comparison-history-section{margin-top:3em;padding:1em;background:var(--color-background);border:1px solid var(--color-border-table-cell);border-radius:4px;box-shadow:0 2px 4px rgba(0, 0, 0, 0.05)}.comparison-history-section h3{color:var(--color-text)}.comparison-history-section p{color:var(--color-text-input-description);font-size:.9em}.history-changed-yes{color:#d32f2f;font-weight:bold}.history-changed-no{color:#388e3c}
|
||||
.comparison-score{padding:1em;background:var(--color-table-stripe);border-radius:4px;margin:1em 0;border:1px solid var(--color-border-table-cell);color:var(--color-text)}.change-detected{color:#d32f2f;font-weight:bold}.no-change{color:#388e3c;font-weight:bold}.comparison-grid{display:grid;grid-template-columns:1fr 1fr;gap:1em;margin:1em 1em}@media(max-width: 1200px){.comparison-grid{grid-template-columns:1fr}}.image-comparison{position:relative;width:100%;overflow:hidden;border:1px solid var(--color-border-table-cell);box-shadow:0 2px 4px rgba(0,0,0,.1);user-select:none}.image-comparison img{display:block;width:100%;height:auto;max-width:100%;border:none;box-shadow:none}.comparison-image-wrapper{position:relative;width:100%;display:flex;align-items:flex-start;justify-content:center;background-color:var(--color-background);background-image:linear-gradient(45deg, var(--color-table-stripe) 25%, transparent 25%),linear-gradient(-45deg, var(--color-table-stripe) 25%, transparent 25%),linear-gradient(45deg, transparent 75%, var(--color-table-stripe) 75%),linear-gradient(-45deg, transparent 75%, var(--color-table-stripe) 75%);background-size:20px 20px;background-position:0 0,0 10px,10px -10px,-10px 0px}.comparison-after{position:absolute;top:0;left:0;width:100%;height:100%;clip-path:inset(0 0 0 50%)}.comparison-slider{position:absolute;top:0;left:50%;width:4px;height:100%;background:#0078e7;cursor:ew-resize;transform:translateX(-2px);z-index:10}.comparison-handle{position:absolute;top:50%;left:50%;width:48px;height:48px;background:#0078e7;border:3px solid #fff;border-radius:50%;transform:translate(-50%, -50%);box-shadow:0 2px 8px rgba(0,0,0,.3);display:flex;align-items:center;justify-content:center;cursor:ew-resize;transition:top .1s ease-out}.comparison-handle::after{content:"⇄";color:#fff;font-size:24px;font-weight:bold;pointer-events:none}.comparison-labels{position:absolute;top:10px;width:100%;display:flex;justify-content:space-between;padding:0 0px;z-index:5;pointer-events:none}.comparison-label{background:rgba(0,0,0,.7);color:#fff;padding:.5em 1em;border-radius:4px;font-size:.9em;font-weight:bold}.screenshot-panel{text-align:center;background:var(--color-background);border:1px solid var(--color-border-table-cell);border-radius:4px;padding:1em;box-shadow:0 2px 4px rgba(0,0,0,.05)}.screenshot-panel h3{margin:0 0 1em 0;font-size:1.1em;color:var(--color-text);border-bottom:2px solid var(--color-background-button-primary);padding-bottom:.5em}.screenshot-panel.diff h3{border-bottom-color:#d32f2f}.screenshot-panel img{max-width:100%;height:auto;border:1px solid var(--color-border-table-cell);box-shadow:0 2px 4px rgba(0,0,0,.1)}.version-selector{display:inline-block;margin:0 .5em}.version-selector label{font-weight:bold;margin-right:.5em;color:var(--color-text)}#settings{background:var(--color-background);padding:1.5em;border-radius:4px;box-shadow:0 2px 4px rgba(0,0,0,.05);margin-bottom:2em;border:1px solid var(--color-border-table-cell)}#settings h2{margin-top:0;color:var(--color-text)}.diff-fieldset{border:none;padding:0;margin:0}.edit-link{float:right;margin-top:-0.5em}.comparison-description{color:var(--color-text-input-description);font-size:.9em;margin-bottom:1em}.download-link{color:var(--color-link);text-decoration:none;display:inline-flex;align-items:center;gap:.3em;font-size:.85em}.download-link:hover{text-decoration:underline}.diff-section-header{color:#d32f2f;font-size:.9em;margin-bottom:1em;font-weight:bold;display:flex;align-items:center;justify-content:center;gap:1em}.comparison-history-section{margin-top:3em;padding:1em;background:var(--color-background);border:1px solid var(--color-border-table-cell);border-radius:4px;box-shadow:0 2px 4px rgba(0,0,0,.05)}.comparison-history-section h3{color:var(--color-text)}.comparison-history-section p{color:var(--color-text-input-description);font-size:.9em}.history-changed-yes{color:#d32f2f;font-weight:bold}.history-changed-no{color:#388e3c}
|
||||
|
||||
@@ -1 +1 @@
|
||||
#diff-form{background:rgba(0, 0, 0, 0.05);padding:1em;border-radius:10px;margin-bottom:1em;color:#fff;font-size:.9rem;text-align:center}#diff-form label.from-to-label{width:4rem;text-decoration:none;padding:.5rem}#diff-form label.from-to-label#change-from{color:#b30000;background:#fadad7}#diff-form label.from-to-label#change-to{background:#eaf2c2;color:#406619}#diff-form #diff-style>span{display:inline-block;padding:.3em}#diff-form #diff-style>span label{font-weight:normal}#diff-form *{vertical-align:middle}body.difference-page section.content{padding-top:40px}#diff-ui{background:var(--color-background);padding:1rem;border-radius:5px}@media(min-width: 767px){#diff-ui{min-width:50%}}#diff-ui #text{font-size:11px}#diff-ui pre{white-space:break-spaces}#diff-ui h1{display:inline;font-size:100%}#diff-ui #result{white-space:pre-wrap;word-break:break-word;overflow-wrap:break-word}#diff-ui .source{position:absolute;right:1%;top:.2em}@-moz-document url-prefix(){#diff-ui body{height:99%}}#diff-ui td#diff-col div{text-align:justify;white-space:pre-wrap}#diff-ui .ignored{background-color:#ccc;opacity:.7}#diff-ui .triggered{background-color:#1b98f8}#diff-ui .ignored.triggered{background-color:red}#diff-ui .tab-pane-inner#screenshot{text-align:center}#diff-ui .tab-pane-inner#screenshot img{max-width:99%}#diff-ui .pure-form button.reset-margin{margin:0px}#diff-ui .diff-fieldset{display:flex;align-items:center;gap:4px;flex-wrap:wrap}#diff-ui ul#highlightSnippetActions{list-style-type:none;display:flex;align-items:center;justify-content:center;gap:1.5rem;flex-wrap:wrap;padding:0;margin:0}#diff-ui ul#highlightSnippetActions li{display:flex;flex-direction:column;align-items:center;text-align:center;padding:.5rem;gap:.3rem}#diff-ui ul#highlightSnippetActions li button,#diff-ui ul#highlightSnippetActions li a{white-space:nowrap}#diff-ui ul#highlightSnippetActions span{font-size:.8rem;color:var(--color-text-input-description)}#diff-ui #cell-diff-jump-visualiser{display:flex;flex-direction:row;gap:1px;background:var(--color-background);border-radius:3px;overflow-x:hidden;position:sticky;top:0;z-index:10;padding-top:1rem;padding-bottom:1rem;justify-content:center}#diff-ui #cell-diff-jump-visualiser>div{flex:1;min-width:1px;max-width:10px;height:10px;background:var(--color-background-button-cancel);opacity:.3;border-radius:1px;transition:opacity .2s;position:relative}#diff-ui #cell-diff-jump-visualiser>div.deletion{background:#b30000;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.insertion{background:#406619;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.note{background:#406619;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.mixed{background:linear-gradient(to right, #b30000 50%, #406619 50%);opacity:1}#diff-ui #cell-diff-jump-visualiser>div.current-position::after{content:"";position:absolute;bottom:-6px;left:50%;transform:translateX(-50%);width:0;height:0;border-left:4px solid rgba(0, 0, 0, 0);border-right:4px solid rgba(0, 0, 0, 0);border-bottom:4px solid var(--color-text)}#diff-ui #cell-diff-jump-visualiser>div:hover{opacity:.8;cursor:pointer}#text-diff-heading-area .snapshot-age{padding:4px;margin:.5rem 0;background-color:var(--color-background-snapshot-age);border-radius:3px;font-weight:bold;margin-bottom:4px}#text-diff-heading-area .snapshot-age.error{background-color:var(--color-error-background-snapshot-age);color:var(--color-error-text-snapshot-age)}#text-diff-heading-area .snapshot-age>*{padding-right:1rem}
|
||||
#diff-form{background:rgba(0,0,0,.05);padding:1em;border-radius:10px;margin-bottom:1em;color:#fff;font-size:.9rem;text-align:center}#diff-form label.from-to-label{width:4rem;text-decoration:none;padding:.5rem}#diff-form label.from-to-label#change-from{color:#b30000;background:#fadad7}#diff-form label.from-to-label#change-to{background:#eaf2c2;color:#406619}#diff-form #diff-style>span{display:inline-block;padding:.3em}#diff-form #diff-style>span label{font-weight:normal}#diff-form *{vertical-align:middle}body.difference-page section.content{padding-top:40px}#diff-ui{background:var(--color-background);padding:1rem;border-radius:5px}@media(min-width: 767px){#diff-ui{min-width:50%}}#diff-ui #text{font-size:11px}#diff-ui pre{white-space:break-spaces}#diff-ui h1{display:inline;font-size:100%}#diff-ui #result{white-space:pre-wrap;word-break:break-word;overflow-wrap:break-word}#diff-ui .source{position:absolute;right:1%;top:.2em}@-moz-document url-prefix(){#diff-ui body{height:99%}}#diff-ui td#diff-col div{text-align:justify;white-space:pre-wrap}#diff-ui .ignored{background-color:#ccc;opacity:.7}#diff-ui .triggered{background-color:#1b98f8}#diff-ui .ignored.triggered{background-color:red}#diff-ui .tab-pane-inner#screenshot{text-align:center}#diff-ui .tab-pane-inner#screenshot img{max-width:99%}#diff-ui .pure-form button.reset-margin{margin:0px}#diff-ui .diff-fieldset{display:flex;align-items:center;gap:4px;flex-wrap:wrap}#diff-ui ul#highlightSnippetActions{list-style-type:none;display:flex;align-items:center;justify-content:center;gap:1.5rem;flex-wrap:wrap;padding:0;margin:0}#diff-ui ul#highlightSnippetActions li{display:flex;flex-direction:column;align-items:center;text-align:center;padding:.5rem;gap:.3rem}#diff-ui ul#highlightSnippetActions li button,#diff-ui ul#highlightSnippetActions li a{white-space:nowrap}#diff-ui ul#highlightSnippetActions span{font-size:.8rem;color:var(--color-text-input-description)}#diff-ui #cell-diff-jump-visualiser{display:flex;flex-direction:row;gap:1px;background:var(--color-background);border-radius:3px;overflow-x:hidden;position:sticky;top:0;z-index:10;padding-top:1rem;padding-bottom:1rem;justify-content:center}#diff-ui #cell-diff-jump-visualiser>div{flex:1;min-width:1px;max-width:10px;height:10px;background:var(--color-background-button-cancel);opacity:.3;border-radius:1px;transition:opacity .2s;position:relative}#diff-ui #cell-diff-jump-visualiser>div.deletion{background:#b30000;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.insertion{background:#406619;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.note{background:#406619;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.mixed{background:linear-gradient(to right, #b30000 50%, #406619 50%);opacity:1}#diff-ui #cell-diff-jump-visualiser>div.current-position::after{content:"";position:absolute;bottom:-6px;left:50%;transform:translateX(-50%);width:0;height:0;border-left:4px solid rgba(0,0,0,0);border-right:4px solid rgba(0,0,0,0);border-bottom:4px solid var(--color-text)}#diff-ui #cell-diff-jump-visualiser>div:hover{opacity:.8;cursor:pointer}#text-diff-heading-area .snapshot-age{padding:4px;margin:.5rem 0;background-color:var(--color-background-snapshot-age);border-radius:3px;font-weight:bold;margin-bottom:4px}#text-diff-heading-area .snapshot-age.error{background-color:var(--color-error-background-snapshot-age);color:var(--color-error-text-snapshot-age)}#text-diff-heading-area .snapshot-age>*{padding-right:1rem}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
/**
|
||||
* SCSS variables (compile-time)
|
||||
* These can be used in media queries and other places where CSS custom properties don't work
|
||||
*/
|
||||
|
||||
// Breakpoints
|
||||
$desktop-wide-breakpoint: 980px;
|
||||
@@ -1,115 +0,0 @@
|
||||
// Action Sidebar - Minimal navigation icons with light grey aesthetic
|
||||
|
||||
.content-wrapper {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
position: relative;
|
||||
|
||||
@media only screen and (max-width: 900px) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.action-sidebar {
|
||||
position: sticky;
|
||||
top: 100px;
|
||||
flex-shrink: 0;
|
||||
width: 80px;
|
||||
height: fit-content;
|
||||
background: transparent;
|
||||
padding: 1.5rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
z-index: 0;
|
||||
|
||||
@media only screen and (max-width: 900px) {
|
||||
position: relative;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
padding: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.action-sidebar-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.75rem 0.5rem;
|
||||
min-width: 64px;
|
||||
text-decoration: none;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.active {
|
||||
opacity: 1;
|
||||
|
||||
.action-icon {
|
||||
stroke: #fff;
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
.action-label {
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
stroke: #fff;
|
||||
stroke-width: 2;
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
transition: stroke 0.2s ease;
|
||||
}
|
||||
|
||||
.action-label {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
line-height: 1.1;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
color: #fff;
|
||||
transition: color 0.2s ease;
|
||||
max-width: 60px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.content-main {
|
||||
flex: 0 1 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// Dark mode adjustments
|
||||
html[data-darkmode=true] {
|
||||
.action-icon {
|
||||
/* stroke: #666;*/
|
||||
}
|
||||
|
||||
.action-label {
|
||||
/* color: #666;*/
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
|
||||
.toggle-light-mode {
|
||||
#toggle-light-mode {
|
||||
/* width: 3rem;*/
|
||||
/* default */
|
||||
.icon-dark {
|
||||
display: none;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
html[data-darkmode="true"] {
|
||||
.toggle-light-mode {
|
||||
#toggle-light-mode {
|
||||
.icon-light {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
// Hamburger Menu for Mobile Navigation
|
||||
@use "../settings" as *;
|
||||
|
||||
.hamburger-menu {
|
||||
display: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
z-index: 10001;
|
||||
position: relative;
|
||||
|
||||
@media only screen and (max-width: $desktop-wide-breakpoint) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.hamburger-icon {
|
||||
width: 24px;
|
||||
height: 20px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
height: 3px;
|
||||
width: 100%;
|
||||
background: var(--color-text);
|
||||
border-radius: 2px;
|
||||
transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
transform-origin: center;
|
||||
}
|
||||
}
|
||||
|
||||
.hamburger-menu.active {
|
||||
.hamburger-icon span:nth-child(1) {
|
||||
transform: translateY(8.5px) rotate(45deg);
|
||||
}
|
||||
|
||||
.hamburger-icon span:nth-child(2) {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
|
||||
.hamburger-icon span:nth-child(3) {
|
||||
transform: translateY(-8.5px) rotate(-45deg);
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile menu overlay
|
||||
.mobile-menu-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 9999;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
|
||||
&.active {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile menu drawer
|
||||
.mobile-menu-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: -280px;
|
||||
width: 280px;
|
||||
height: 100%;
|
||||
background: var(--color-background);
|
||||
opacity: 1;
|
||||
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.15);
|
||||
z-index: 10000;
|
||||
transition: right 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
overflow-y: auto;
|
||||
padding-top: 60px;
|
||||
|
||||
&.active {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.mobile-menu-items {
|
||||
list-style: none;
|
||||
padding: 1rem 0;
|
||||
margin: 0;
|
||||
|
||||
li {
|
||||
border-bottom: 1px solid var(--color-border-table-cell);
|
||||
|
||||
>* {
|
||||
display: block;
|
||||
padding: 1rem 1.5rem;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-background-menu-link-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Logo styling
|
||||
.logo-cdio {
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
|
||||
.logo-cd {
|
||||
color: var(--color-grey-500);
|
||||
}
|
||||
|
||||
.logo-io {
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
// Always visible items container
|
||||
.menu-always-visible {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
// Hide regular menu items on mobile (but not in mobile drawer)
|
||||
@media only screen and (max-width: $desktop-wide-breakpoint) {
|
||||
#top-right-menu .menu-collapsible {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.pure-menu-horizontal {
|
||||
overflow-x: visible !important;
|
||||
}
|
||||
|
||||
#nav-menu {
|
||||
overflow-x: visible !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Desktop - hide mobile menu elements
|
||||
@media only screen and (min-width: 1025px) {
|
||||
.hamburger-menu,
|
||||
.mobile-menu-drawer,
|
||||
.mobile-menu-overlay {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
html[data-darkmode=true] {
|
||||
.mobile-menu-drawer {
|
||||
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
#language-selector-flag {
|
||||
display: inline-block;
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
vertical-align: middle;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
opacity: 0.6;
|
||||
&:hover {
|
||||
opacity: 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 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.25rem;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
#language-modal {
|
||||
.language-list {
|
||||
.lang-option {
|
||||
display: inline-block;
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
vertical-align: middle;
|
||||
margin-right: 0.5em;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
// Modern Login Form - Friendly and Welcoming Design
|
||||
|
||||
.login-form {
|
||||
min-height: 52vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem 1rem;
|
||||
|
||||
.inner {
|
||||
background: var(--color-background);
|
||||
border-radius: 16px;
|
||||
box-shadow:
|
||||
0 10px 40px rgba(0, 0, 0, 0.08),
|
||||
0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
padding: 3rem 2.5rem;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
|
||||
box-shadow:
|
||||
0 15px 50px rgba(0, 0, 0, 0.12),
|
||||
0 5px 15px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pure-control-group {
|
||||
margin-bottom: 1.75rem;
|
||||
|
||||
&:last-of-type {
|
||||
margin-bottom: 0;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 0.875rem 1rem;
|
||||
border: 2px solid var(--color-grey-800);
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
background: var(--color-background-input);
|
||||
color: var(--color-text-input);
|
||||
transition: all 0.2s ease;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-link);
|
||||
box-shadow: 0 0 0 3px rgba(27, 152, 248, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-text-input-placeholder);
|
||||
}
|
||||
}
|
||||
|
||||
button[type="submit"] {
|
||||
width: 100%;
|
||||
padding: 0.875rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: var(--color-background-button-primary);
|
||||
color: var(--color-text-button);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 8px rgba(27, 152, 248, 0.2);
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(27, 152, 248, 0.3);
|
||||
background: #0066cc;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 4px rgba(27, 152, 248, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Messages styling for login page
|
||||
.content-main > ul.messages {
|
||||
position: fixed;
|
||||
top: 120px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
z-index: 1000;
|
||||
min-width: 300px;
|
||||
max-width: 500px;
|
||||
|
||||
li {
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
animation: slideDown 0.3s ease-out;
|
||||
border: 2px solid transparent;
|
||||
|
||||
&.error {
|
||||
background: #fee;
|
||||
border: 2px solid #ef4444;
|
||||
color: #991b1b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&.success {
|
||||
background: #f0fdf4;
|
||||
border: 2px solid #10b981;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
&.info,
|
||||
&.message {
|
||||
background: #eff6ff;
|
||||
border: 2px solid #3b82f6;
|
||||
color: #1e40af;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Dark mode adjustments
|
||||
html[data-darkmode="true"] {
|
||||
.login-form {
|
||||
.inner {
|
||||
box-shadow:
|
||||
0 10px 40px rgba(0, 0, 0, 0.4),
|
||||
0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
|
||||
&:hover {
|
||||
box-shadow:
|
||||
0 15px 50px rgba(0, 0, 0, 0.5),
|
||||
0 5px 15px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
input[type="password"] {
|
||||
border-color: var(--color-grey-400);
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-link);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-main > ul.messages {
|
||||
li {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
|
||||
&.error {
|
||||
background: #4a1d1d;
|
||||
border-color: #ef4444;
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
&.success {
|
||||
background: #1a3a2a;
|
||||
border-color: #10b981;
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
&.info,
|
||||
&.message {
|
||||
background: #1e3a5f;
|
||||
border-color: #3b82f6;
|
||||
color: #93c5fd;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile adjustments
|
||||
@media only screen and (max-width: 768px) {
|
||||
.login-form {
|
||||
min-height: auto;
|
||||
padding: 1rem 0.5rem;
|
||||
padding-top: 5rem; // Space for error message
|
||||
|
||||
.inner {
|
||||
padding: 2rem 1.5rem;
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.content-main > ul.messages {
|
||||
top: 70px; // Higher up on mobile to avoid overlap
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
transform: none;
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
@@ -22,22 +22,4 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
// Active menu item styling
|
||||
&.active {
|
||||
.pure-menu-link {
|
||||
background-color: var(--color-background-menu-link-hover);
|
||||
color: var(--color-text-menu-link-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#cdio-logo {
|
||||
padding-left: 0.5em;
|
||||
}
|
||||
|
||||
#inline-menu-extras-group {
|
||||
>* {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
// Reusable notification bubble for action sidebar icons
|
||||
|
||||
.action-sidebar-item {
|
||||
position: relative;
|
||||
|
||||
.notification-bubble {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
background: #ff4444;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
border-radius: 9px;
|
||||
padding: 0 2px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
pointer-events: none;
|
||||
transition: all 0.2s ease;
|
||||
display: none;
|
||||
|
||||
// Red bubble for errors/urgent
|
||||
&.red-bubble {
|
||||
background: #ff4444;
|
||||
}
|
||||
|
||||
// Blue bubble for informational
|
||||
&.blue-bubble {
|
||||
background: #4a9eff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
// Pulse animation when value changes
|
||||
&.pulse {
|
||||
animation: bubblePulse 0.4s ease-out;
|
||||
}
|
||||
|
||||
// Large numbers get smaller font
|
||||
&.large-number {
|
||||
font-size: 8px;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bubblePulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Dark mode adjustments
|
||||
html[data-darkmode=true] {
|
||||
.notification-bubble {
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
.pagination-page-info {
|
||||
color: #fff;
|
||||
font-size: 0.85rem;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
// Search Modal Styles
|
||||
|
||||
#search-modal {
|
||||
.modal-body {
|
||||
padding: 2rem 1.5rem;
|
||||
|
||||
.pure-control-group {
|
||||
padding-bottom: 0;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
#search-modal-input {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.6rem 0.8rem;
|
||||
font-size: 1rem;
|
||||
border: 1px solid var(--color-border-input);
|
||||
border-radius: 4px;
|
||||
background-color: var(--color-background-input);
|
||||
color: var(--color-text-input);
|
||||
box-shadow: inset 0 1px 3px var(--color-shadow-input);
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-link);
|
||||
box-shadow: 0 0 0 3px rgba(27, 152, 248, 0.1);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-text-input-placeholder);
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dark mode adjustments
|
||||
html[data-darkmode=true] {
|
||||
#search-modal {
|
||||
#search-modal-input {
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 3px rgba(89, 189, 251, 0.15);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
body.wrapped-tabs {
|
||||
.tabs {
|
||||
ul {
|
||||
grid-template-columns: repeat(auto-fill, minmax(var(--tab-width, 180px), 1fr));
|
||||
grid-auto-flow: row;
|
||||
grid-auto-columns: unset;
|
||||
gap: 0;
|
||||
column-gap: 5px;
|
||||
}
|
||||
|
||||
ul li {
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
ul {
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: max-content;
|
||||
gap: 5px;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
white-space: nowrap;
|
||||
color: var(--color-text-tab);
|
||||
border-top-left-radius: 5px;
|
||||
border-top-right-radius: 5px;
|
||||
background-color: var(--color-background-tab);
|
||||
|
||||
&:not(.active) {
|
||||
&:hover {
|
||||
background-color: var(--color-background-tab-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&.active,
|
||||
:target {
|
||||
background-color: var(--color-background);
|
||||
|
||||
a {
|
||||
color: var(--color-text-tab-active);
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
display: block;
|
||||
padding: 0.7em;
|
||||
color: var(--color-text-tab);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
// Toast Notification System
|
||||
// Modern, animated toast notifications
|
||||
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
pointer-events: none;
|
||||
z-index: 10000;
|
||||
|
||||
// Positioning
|
||||
&.toast-top-right {
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
}
|
||||
|
||||
&.toast-top-center {
|
||||
top: 100px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
&.toast-top-left {
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
&.toast-bottom-right {
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
}
|
||||
|
||||
&.toast-bottom-center {
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
&.toast-bottom-left {
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 300px;
|
||||
max-width: 500px;
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--color-background);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.05);
|
||||
pointer-events: auto;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transform: translateY(-50px);
|
||||
transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
font-family: inherit;
|
||||
|
||||
&.toast-show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&.toast-hide {
|
||||
opacity: 0;
|
||||
transform: translateY(-50px) scale(0.95);
|
||||
}
|
||||
|
||||
// Toast types
|
||||
&.toast-success {
|
||||
border-left: 4px solid #10b981;
|
||||
|
||||
.toast-icon {
|
||||
color: #10b981;
|
||||
}
|
||||
}
|
||||
|
||||
&.toast-error {
|
||||
border-left: 4px solid #ef4444;
|
||||
|
||||
.toast-icon {
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
|
||||
&.toast-warning {
|
||||
border-left: 4px solid #f59e0b;
|
||||
|
||||
.toast-icon {
|
||||
color: #f59e0b;
|
||||
}
|
||||
}
|
||||
|
||||
&.toast-info {
|
||||
border-left: 4px solid #3b82f6;
|
||||
|
||||
.toast-icon {
|
||||
color: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
&.toast-default {
|
||||
border-left: 4px solid var(--color-grey-500);
|
||||
}
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text);
|
||||
word-break: break-word;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: var(--color-grey-500);
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
padding: 0;
|
||||
margin-left: 0.25rem;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-grey-800);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.toast-progress {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: currentColor;
|
||||
opacity: 0.3;
|
||||
transform-origin: left;
|
||||
transition: transform linear;
|
||||
}
|
||||
|
||||
// Dark mode adjustments
|
||||
html[data-darkmode=true] {
|
||||
.toast {
|
||||
background: var(--color-grey-300);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.toast-close:hover {
|
||||
background: var(--color-grey-400);
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile adjustments
|
||||
@media only screen and (max-width: 768px) {
|
||||
.toast-container {
|
||||
left: 10px !important;
|
||||
right: 10px !important;
|
||||
top: 10px !important;
|
||||
transform: none !important;
|
||||
align-items: stretch;
|
||||
|
||||
&.toast-bottom-right,
|
||||
&.toast-bottom-center,
|
||||
&.toast-bottom-left {
|
||||
top: auto !important;
|
||||
bottom: 10px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.toast {
|
||||
min-width: auto;
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
transform: translateY(-100px);
|
||||
|
||||
&.toast-show {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&.toast-hide {
|
||||
transform: translateY(-100px) scale(0.95);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Accessibility
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toast {
|
||||
transition: opacity 0.2s ease;
|
||||
transform: none !important;
|
||||
|
||||
&.toast-show {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.toast-hide {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,11 +125,6 @@ $grid-gap: 0.5rem;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
// Empty state message - span full width on mobile
|
||||
> td[colspan] {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
> td.title-col {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 1;
|
||||
|
||||
@@ -1,32 +1,4 @@
|
||||
/* table related */
|
||||
#stats_row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
color: #fff;
|
||||
font-size: 0.85rem;
|
||||
>* {
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
.left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.right {
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.6s ease;
|
||||
margin-left: auto; /* pushes it to the far right */
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
body.has-queue {
|
||||
#stats_row {
|
||||
.right {
|
||||
opacity: 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.watch-table {
|
||||
width: 100%;
|
||||
font-size: 80%;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
* -- BASE STYLES --
|
||||
*/
|
||||
|
||||
@use "settings" as *;
|
||||
@use "parts/variables";
|
||||
@use "parts/arrows";
|
||||
@use "parts/browser-steps";
|
||||
@@ -24,40 +23,7 @@
|
||||
@use "parts/widgets";
|
||||
@use "parts/diff_image";
|
||||
@use "parts/modal";
|
||||
@use "parts/language";
|
||||
@use "parts/action_sidebar";
|
||||
@use "parts/hamburger_menu";
|
||||
@use "parts/search_modal";
|
||||
@use "parts/notification_bubble";
|
||||
@use "parts/toast";
|
||||
@use "parts/login_form";
|
||||
@use "parts/tabs";
|
||||
|
||||
// Smooth transitions for theme switching
|
||||
body,
|
||||
.pure-table,
|
||||
.pure-table thead,
|
||||
.pure-table td,
|
||||
.pure-table th,
|
||||
.pure-form input,
|
||||
.pure-form textarea,
|
||||
.pure-form select,
|
||||
.edit-form .inner,
|
||||
.pure-menu-horizontal,
|
||||
footer,
|
||||
.sticky-tab,
|
||||
#diff-jump,
|
||||
.button-tag,
|
||||
#new-watch-form,
|
||||
#new-watch-form input:not(.pure-button),
|
||||
code,
|
||||
.messages li,
|
||||
#checkbox-operations,
|
||||
.inline-warning,
|
||||
a,
|
||||
.watch-controls img {
|
||||
transition: color 0.4s ease, background-color 0.4s ease, background 0.4s ease, border-color 0.4s ease, box-shadow 0.4s ease;
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--color-text);
|
||||
@@ -105,6 +71,20 @@ a.github-link {
|
||||
}
|
||||
}
|
||||
|
||||
#search-q {
|
||||
opacity: 0;
|
||||
-webkit-transition: all .9s ease;
|
||||
-moz-transition: all .9s ease;
|
||||
transition: all .9s ease;
|
||||
width: 0;
|
||||
display: none;
|
||||
&.expanded {
|
||||
width: auto;
|
||||
display: inline-block;
|
||||
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
#search-result-info {
|
||||
color: #fff;
|
||||
}
|
||||
@@ -185,13 +165,7 @@ body.spinner-active {
|
||||
}
|
||||
|
||||
section.content {
|
||||
@media only screen and (max-width: $desktop-wide-breakpoint) {
|
||||
padding-top: 80px;
|
||||
}
|
||||
@media only screen and (min-width: $desktop-wide-breakpoint) {
|
||||
padding-top: 100px;
|
||||
}
|
||||
|
||||
padding-top: 100px;
|
||||
padding-bottom: 1em;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
@@ -209,13 +183,13 @@ code {
|
||||
border-radius: 5px;
|
||||
padding: 2px 5px;
|
||||
margin-right: 4px;
|
||||
line-height: 1.2rem;
|
||||
}
|
||||
|
||||
/* Processor type badges - colors auto-generated from processor names */
|
||||
.processor-badge {
|
||||
@extend .inline-tag;
|
||||
font-weight: 900;
|
||||
font-size: 0.85em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.watch-tag-list {
|
||||
@@ -548,9 +522,6 @@ footer {
|
||||
}
|
||||
|
||||
.sticky-tab {
|
||||
@media only screen and (max-width: $desktop-wide-breakpoint) {
|
||||
display: none;
|
||||
}
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
font-size: 65%;
|
||||
@@ -695,7 +666,7 @@ footer {
|
||||
|
||||
|
||||
@media only screen and (max-width: 760px),
|
||||
(min-device-width: 768px) and (max-device-width: $desktop-wide-breakpoint) {
|
||||
(min-device-width: 768px) and (max-device-width: 1024px) {
|
||||
.edit-form {
|
||||
padding: 0.5em;
|
||||
margin: 0;
|
||||
@@ -707,10 +678,30 @@ footer {
|
||||
}
|
||||
|
||||
|
||||
@media only screen and (max-width: 760px), (min-device-width: 768px) and (max-device-width: $desktop-wide-breakpoint) {
|
||||
@media only screen and (max-width: 760px),
|
||||
(min-device-width: 768px) and (max-device-width: 800px) {
|
||||
|
||||
div.sticky-tab#hosted-sticky {
|
||||
top: 60px;
|
||||
left: 0px;
|
||||
right: auto;
|
||||
}
|
||||
|
||||
section.content {
|
||||
padding-top: 110px;
|
||||
}
|
||||
|
||||
// Make the tabs easier to hit, they will be all nice and horizontal
|
||||
div.tabs.collapsable ul li {
|
||||
display: block;
|
||||
border-radius: 0px;
|
||||
margin-right: 0px;
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.pure-table {
|
||||
@@ -786,6 +777,45 @@ textarea::placeholder {
|
||||
}
|
||||
|
||||
|
||||
.tabs {
|
||||
ul {
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
display: block;
|
||||
|
||||
li {
|
||||
margin-right: 1px;
|
||||
display: inline-block;
|
||||
color: var(--color-text-tab);
|
||||
border-top-left-radius: 5px;
|
||||
border-top-right-radius: 5px;
|
||||
background-color: var(--color-background-tab);
|
||||
|
||||
&:not(.active) {
|
||||
&:hover {
|
||||
background-color: var(--color-background-tab-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&.active,
|
||||
:target {
|
||||
background-color: var(--color-background);
|
||||
|
||||
a {
|
||||
color: var(--color-text-tab-active);
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
display: block;
|
||||
padding: 0.7em;
|
||||
color: var(--color-text-tab);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$form-edge-padding: 20px;
|
||||
|
||||
.pure-form-stacked {
|
||||
@@ -794,7 +824,14 @@ $form-edge-padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
// Login form styles moved to parts/_login_form.scss
|
||||
.login-form {
|
||||
.inner {
|
||||
background: var(--color-background);
|
||||
;
|
||||
padding: $form-edge-padding;
|
||||
border-radius: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-pane-inner {
|
||||
|
||||
@@ -1122,4 +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.25rem;
|
||||
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
@@ -186,7 +186,7 @@ class ChangeDetectionStore:
|
||||
# Finally start the thread that will manage periodic data saves to JSON
|
||||
# Only start if thread is not already running (reload_state might be called multiple times)
|
||||
if not self.save_data_thread or not self.save_data_thread.is_alive():
|
||||
self.save_data_thread = threading.Thread(target=self.save_datastore, daemon=True, name="DatastoreSaver")
|
||||
self.save_data_thread = threading.Thread(target=self.save_datastore)
|
||||
self.save_data_thread.start()
|
||||
|
||||
def rehydrate_entity(self, uuid, entity, processor_override=None):
|
||||
|
||||
@@ -17,8 +17,6 @@ _MAP = {
|
||||
|
||||
|
||||
def strtobool(value):
|
||||
if not value:
|
||||
return False
|
||||
try:
|
||||
return _MAP[str(value).lower()]
|
||||
except KeyError:
|
||||
|
||||
@@ -8,15 +8,16 @@
|
||||
<span class="pure-form-message-inline">
|
||||
Body for all notifications ‐ You can use <a target="newwindow" href="https://jinja.palletsprojects.com/en/3.0.x/templates/">Jinja2</a> templating in the notification title, body and URL, and tokens from below.
|
||||
</span><br>
|
||||
<div data-target="#notification-tokens-info{{ suffix }}" class="toggle-show pure-button button-tag button-xsmall">{{ _('Show token/placeholders') }}
|
||||
<div data-target="#notification-tokens-info{{ suffix }}" class="toggle-show pure-button button-tag button-xsmall">Show
|
||||
token/placeholders
|
||||
</div>
|
||||
</div>
|
||||
<div class="pure-controls" style="display: none;" id="notification-tokens-info{{ suffix }}">
|
||||
<table class="pure-table" id="token-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ _('Token') }}</th>
|
||||
<th>{{ _('Description') }}</th>
|
||||
<th>Token</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -125,7 +126,7 @@
|
||||
<p>
|
||||
<strong>Tip:</strong> Use <a target="newwindow" href="https://github.com/caronc/apprise">AppRise Notification URLs</a> for notification to just about any service! <i><a target="newwindow" href="https://github.com/dgtlmoon/changedetection.io/wiki/Notification-configuration-notes">Please read the notification services wiki here for important configuration notes</a></i>.<br>
|
||||
</p>
|
||||
<div data-target="#advanced-help-notifications" class="toggle-show pure-button button-tag button-xsmall">{{ _('Show advanced help and tips') }}</div>
|
||||
<div data-target="#advanced-help-notifications" class="toggle-show pure-button button-tag button-xsmall">Show advanced help and tips</div>
|
||||
<ul style="display: none" id="advanced-help-notifications">
|
||||
<li><code><a target="newwindow" href="https://github.com/caronc/apprise/wiki/Notify_discord">discord://</a></code> (or <code>https://discord.com/api/webhooks...</code>)) only supports a maximum <strong>2,000 characters</strong> of notification text, including the title.</li>
|
||||
<li><code><a target="newwindow" href="https://github.com/caronc/apprise/wiki/Notify_telegram">tgram://</a></code> bots can't send messages to other bots, so you should specify chat ID of non-bot user.</li>
|
||||
@@ -135,20 +136,19 @@
|
||||
</ul>
|
||||
</div>
|
||||
<div class="notifications-wrapper">
|
||||
<a id="send-test-notification" class="pure-button button-secondary button-xsmall" >{{ _('Send test notification') }}</a> <div class="spinner" style="display: none;"></div>
|
||||
<a id="send-test-notification" class="pure-button button-secondary button-xsmall" >Send test notification</a> <div class="spinner" style="display: none;"></div>
|
||||
{% if emailprefix %}
|
||||
<a id="add-email-helper" class="pure-button button-secondary button-xsmall" >{{ _('Add email') }} <img style="height: 1em; display: inline-block" src="{{url_for('static_content', group='images', filename='email.svg')}}" alt="{{ _('Add an email address') }}"> </a>
|
||||
<a id="add-email-helper" class="pure-button button-secondary button-xsmall" >Add email <img style="height: 1em; display: inline-block" src="{{url_for('static_content', group='images', filename='email.svg')}}" alt="Add an email address"> </a>
|
||||
{% endif %}
|
||||
<a href="{{url_for('settings.notification_logs')}}" class="pure-button button-secondary button-xsmall" >{{ _('Notification debug logs') }}</a>
|
||||
<a href="{{url_for('settings.notification_logs')}}" class="pure-button button-secondary button-xsmall" >Notification debug logs</a>
|
||||
<br>
|
||||
<div id="notification-test-log" style="display: none;"><span class="pure-form-message-inline">{{ _('Processing..') }}</span></div>
|
||||
<div id="notification-test-log" style="display: none;"><span class="pure-form-message-inline">Processing..</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group grey-form-border">
|
||||
<div class="pure-control-group">
|
||||
{{ render_field(form.notification_title, class="m-d notification-title", placeholder=settings_application['notification_title']) }}
|
||||
<span class="pure-form-message-inline">{{ _('Title for all notifications') }}</span>
|
||||
<span class="pure-form-message-inline">Title for all notifications</span>
|
||||
</div>
|
||||
<div class="pure-control-group">
|
||||
{{ render_field(form.notification_body , rows=5, class="notification-body", placeholder=settings_application['notification_body']) }}
|
||||
@@ -169,11 +169,10 @@
|
||||
</span></li>
|
||||
</ul>
|
||||
<br>
|
||||
</div>
|
||||
</div>
|
||||
<div class="">
|
||||
{{ render_field(form.notification_format , class="notification-format") }}
|
||||
<span class="pure-form-message-inline">{{ _('Format for all notifications') }}</span>
|
||||
<span class="pure-form-message-inline">Format for all notifications</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
{% for idx, entry_errors in field.errors|enumerate %}
|
||||
{% if entry_errors is mapping and entry_errors %}
|
||||
{# Only show entries that have actual errors #}
|
||||
<li><strong>{{ _('Entry') }} {{ idx + 1 }}:</strong>
|
||||
<li><strong>Entry {{ idx + 1 }}:</strong>
|
||||
<ul>
|
||||
{% for field_name, messages in entry_errors.items() %}
|
||||
{% for message in messages %}
|
||||
@@ -150,7 +150,7 @@
|
||||
{% for subfield in fieldlist[0] %}
|
||||
<div class="fieldlist-header-cell">{{ subfield.label }}</div>
|
||||
{% endfor %}
|
||||
<div class="fieldlist-header-cell">{{ _('Actions') }}</div>
|
||||
<div class="fieldlist-header-cell">Actions</div>
|
||||
</div>
|
||||
<div class="fieldlist-body">
|
||||
{% for form_row in fieldlist %}
|
||||
@@ -169,9 +169,9 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="fieldlist-cell fieldlist-actions">
|
||||
<button type="button" class="addRuleRow" title="{{ _('Add a row/rule after') }}">+</button>
|
||||
<button type="button" class="removeRuleRow" title="{{ _('Remove this row/rule') }}">-</button>
|
||||
<button type="button" class="verifyRuleRow" title="{{ _('Verify this rule against current snapshot') }}">✓</button>
|
||||
<button type="button" class="addRuleRow" title="Add a row/rule after">+</button>
|
||||
<button type="button" class="removeRuleRow" title="Remove this row/rule">-</button>
|
||||
<button type="button" class="verifyRuleRow" title="Verify this rule against current snapshot">✓</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -181,8 +181,8 @@
|
||||
|
||||
|
||||
{% macro playwright_warning() %}
|
||||
<p><strong>{{ _('Error - This watch needs Chrome (with playwright/sockpuppetbrowser), but Chrome based fetching is not enabled.') }}</strong> {{ _('Alternatively try our') }} <a href="https://changedetection.io">{{ _('very affordable subscription based service which has all this setup for you') }}</a>.</p>
|
||||
<p>{{ _('You may need to') }} <a href="https://github.com/dgtlmoon/changedetection.io/blob/09ebc6ec6338545bdd694dc6eee57f2e9d2b8075/docker-compose.yml#L31">{{ _('Enable playwright environment variable') }}</a> {{ _('and uncomment the') }} <strong>sockpuppetbrowser</strong> {{ _('in the') }} <a href="https://github.com/dgtlmoon/changedetection.io/blob/master/docker-compose.yml">docker-compose.yml</a> {{ _('file') }}.</p>
|
||||
<p><strong>Error - This watch needs Chrome (with playwright/sockpuppetbrowser), but Chrome based fetching is not enabled.</strong> Alternatively try our <a href="https://changedetection.io">very affordable subscription based service which has all this setup for you</a>.</p>
|
||||
<p>You may need to <a href="https://github.com/dgtlmoon/changedetection.io/blob/09ebc6ec6338545bdd694dc6eee57f2e9d2b8075/docker-compose.yml#L31">Enable playwright environment variable</a> and uncomment the <strong>sockpuppetbrowser</strong> in the <a href="https://github.com/dgtlmoon/changedetection.io/blob/master/docker-compose.yml">docker-compose.yml</a> file.</p>
|
||||
<br>
|
||||
{% endmacro %}
|
||||
|
||||
@@ -237,17 +237,18 @@
|
||||
<span id="scheduler-icon-label" style="">
|
||||
{{ render_checkbox_field(form.time_schedule_limit.enabled) }}
|
||||
<div class="pure-form-message-inline">
|
||||
{{ _('Set a hourly/week day schedule') }}
|
||||
Set a hourly/week day schedule
|
||||
</div>
|
||||
</span>
|
||||
|
||||
</div>
|
||||
<br>
|
||||
<div id="schedule-day-limits-wrapper">
|
||||
<label>{{ _('Schedule time limits') }}</label><a data-template="business-hours"
|
||||
class="set-schedule pure-button button-secondary button-xsmall">{{ _('Business hours') }}</a>
|
||||
<a data-template="weekend" class="set-schedule pure-button button-secondary button-xsmall">{{ _('Weekends') }}</a>
|
||||
<a data-template="reset" class="set-schedule pure-button button-xsmall">{{ _('Reset') }}</a><br>
|
||||
<label>Schedule time limits</label><a data-template="business-hours"
|
||||
class="set-schedule pure-button button-secondary button-xsmall">Business
|
||||
hours</a>
|
||||
<a data-template="weekend" class="set-schedule pure-button button-secondary button-xsmall">Weekends</a>
|
||||
<a data-template="reset" class="set-schedule pure-button button-xsmall">Reset</a><br>
|
||||
<br>
|
||||
|
||||
<ul id="day-wrapper">
|
||||
@@ -256,8 +257,8 @@
|
||||
{{ render_nolabel_field(form.time_schedule_limit[day]) }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li id="timespan-warning">{{ _("Warning, one or more of your 'days' has a duration that would extend into the next day.") }}<br>
|
||||
{{ _('This could have unintended consequences.') }}</li>
|
||||
<li id="timespan-warning">Warning, one or more of your 'days' has a duration that would extend into the next day.<br>
|
||||
This could have unintended consequences.</li>
|
||||
<li id="timezone-info">
|
||||
{{ render_field(form.time_schedule_limit.timezone, placeholder=timezone_default_config) }} <span id="local-time-in-tz"></span>
|
||||
<datalist id="timezones" style="display: none;">
|
||||
@@ -267,12 +268,12 @@
|
||||
</ul>
|
||||
<br>
|
||||
<span class="pure-form-message-inline">
|
||||
<a href="https://changedetection.io/tutorial/checking-web-pages-changes-according-schedule">{{ _('More help and examples about using the scheduler') }}</a>
|
||||
<a href="https://changedetection.io/tutorials">More help and examples about using the scheduler</a>
|
||||
</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="pure-form-message-inline">
|
||||
{{ _('Want to use a time schedule?') }} <a href="{{url_for('settings.settings_page')}}#timedate">{{ _('First confirm/save your Time Zone Settings') }}</a>
|
||||
Want to use a time schedule? <a href="{{url_for('settings.settings_page')}}#timedate">First confirm/save your Time Zone Settings</a>
|
||||
</span>
|
||||
<br>
|
||||
{% endif %}
|
||||
@@ -281,8 +282,8 @@
|
||||
|
||||
{% macro highlight_trigger_ignored_explainer() %}
|
||||
<p>
|
||||
<span title="{{ _('Triggers a change if this text appears, AND something changed in the document.') }}" style="background-color: var(--highlight-trigger-text-bg-color); color: #fff; padding: 4px; border-radius: 2px; margin-right: 4px;">{{ _('Triggered text') }}</span>
|
||||
<span title="{{ _('Ignored for calculating changes, but still shown.') }}" style="background-color: var(--highlight-ignored-text-bg-color); color: #fff; padding: 4px; border-radius: 2px; margin-right: 4px;">{{ _('Ignored text') }}</span>
|
||||
<span title="{{ _('No change-detection will occur because this text exists.') }}" style="background-color: var(--highlight-blocked-text-bg-color); color: #fff; padding: 4px; border-radius: 2px; margin-right: 4px;">{{ _('Blocked text') }}</span>
|
||||
<span title="Triggers a change if this text appears, AND something changed in the document." style="background-color: var(--highlight-trigger-text-bg-color); color: #fff; padding: 4px; border-radius: 2px; margin-right: 4px;">Triggered text</span>
|
||||
<span title="Ignored for calculating changes, but still shown." style="background-color: var(--highlight-ignored-text-bg-color); color: #fff; padding: 4px; border-radius: 2px; margin-right: 4px;">Ignored text</span>
|
||||
<span title="No change-detection will occur because this text exists." style="background-color: var(--highlight-blocked-text-bg-color); color: #fff; padding: 4px; border-radius: 2px; margin-right: 4px;">Blocked text</span>
|
||||
</p>
|
||||
{% endmacro %}
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ get_locale()|replace('_', '-') }}" data-darkmode="{{ get_darkmode_state() }}">
|
||||
<html lang="{{ get_locale() }}" data-darkmode="{{ get_darkmode_state() }}">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" >
|
||||
@@ -53,10 +53,10 @@
|
||||
<div class="home-menu pure-menu pure-menu-horizontal" id="nav-menu">
|
||||
|
||||
{% if has_password and not current_user.is_authenticated %}
|
||||
<a id="cdio-logo" class="pure-menu-heading" href="https://changedetection.io" rel="noopener">
|
||||
<a class="pure-menu-heading" href="https://changedetection.io" rel="noopener">
|
||||
<strong>Change</strong>Detection.io</a>
|
||||
{% else %}
|
||||
<a id="cdio-logo" class="pure-menu-heading" href="{{url_for('watchlist.index')}}">
|
||||
<a class="pure-menu-heading" href="{{url_for('watchlist.index')}}">
|
||||
<strong>Change</strong>Detection.io</a>
|
||||
{% endif %}
|
||||
{% if current_diff_url and is_safe_valid_url(current_diff_url) %}
|
||||
@@ -71,20 +71,64 @@
|
||||
{% endif %}
|
||||
|
||||
<ul class="pure-menu-list" id="top-right-menu">
|
||||
<!-- Collapsible menu items (hidden on mobile, shown in drawer) -->
|
||||
{% include "menu.html" %}
|
||||
|
||||
{% if current_user.is_authenticated or not has_password %}
|
||||
{% if not current_diff_url %}
|
||||
<li class="pure-menu-item menu-collapsible">
|
||||
<button class="toggle-button" id="open-search-modal" type="button" title="{{ _('Search, or Use Alt+S Key') }}">
|
||||
{% include "svgs/search-icon.svg" %}
|
||||
</button>
|
||||
<li class="pure-menu-item">
|
||||
<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>
|
||||
</li>
|
||||
<li class="pure-menu-item">
|
||||
<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>
|
||||
</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>
|
||||
</li>
|
||||
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<li class="pure-menu-item">
|
||||
<a class="pure-menu-link" href="https://changedetection.io">Website Change Detection and Notification.</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.is_authenticated %}
|
||||
<li class="pure-menu-item">
|
||||
<a href="{{url_for('logout', redirect=request.path)}}" class="pure-menu-link">{{ _('LOG OUT') }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.is_authenticated or not has_password %}
|
||||
<li class="pure-menu-item pure-form" id="search-menu-item">
|
||||
<!-- We use GET here so it offers people a chance to set bookmarks etc -->
|
||||
<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') }}" >
|
||||
{% 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>
|
||||
<span class="icon-light">
|
||||
{% include "svgs/light-mode-toggle-icon.svg" %}
|
||||
</span>
|
||||
<span class="icon-dark">
|
||||
{% include "svgs/dark-mode-toggle-icon.svg" %}
|
||||
</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"
|
||||
@@ -94,37 +138,24 @@
|
||||
id="svg-heart"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path id="heartpath" d="M 5.338316,0.50302766 C 0.71136983,0.50647126 -3.9576371,7.2707777 8.5004254,15.503028 23.833425,5.3700277 13.220206,-2.5384409 8.6762066,1.6475589 c -0.060791,0.054322 -0.11943,0.1110064 -0.1757812,0.1699219 -0.057,-0.059 -0.1157813,-0.116875 -0.1757812,-0.171875 C 7.4724566,0.86129334 6.4060729,0.50223298 5.338316,0.50302766 Z" style="fill:var(--color-background);fill-opacity:1;stroke:#ff0000;stroke-opacity:1" />
|
||||
<path id="heartpath" d="M 5.338316,0.50302766 C 0.71136983,0.50647126 -3.9576371,7.2707777 8.5004254,15.503028 23.833425,5.3700277 13.220206,-2.5384409 8.6762066,1.6475589 c -0.060791,0.054322 -0.11943,0.1110064 -0.1757812,0.1699219 -0.057,-0.059 -0.1157813,-0.116875 -0.1757812,-0.171875 C 7.4724566,0.86129334 6.4060729,0.50223298 5.338316,0.50302766 Z"
|
||||
style="fill:var(--color-background);fill-opacity:1;stroke:#ff0000;stroke-opacity:1" />
|
||||
</svg>
|
||||
</li>
|
||||
<!-- Hamburger menu button (mobile only) -->
|
||||
<li class="pure-menu-item">
|
||||
<button class="hamburger-menu" id="hamburger-toggle" aria-label="Toggle menu">
|
||||
<div class="hamburger-icon">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu drawer -->
|
||||
<div class="mobile-menu-overlay" id="mobile-menu-overlay"></div>
|
||||
<div class="mobile-menu-drawer" id="mobile-menu-drawer">
|
||||
<ul class="mobile-menu-items">
|
||||
{% include "menu.html" %}
|
||||
<li class="pure-menu-item menu-collapsible">
|
||||
{%- if right_sticky -%}<div>{{ right_sticky }}</div>{%- endif -%}
|
||||
<a href="https://changedetection.io/?ref={{ guid }}">Let us host your instance!</a><br>
|
||||
</li>
|
||||
</li>
|
||||
<li class="pure-menu-item">
|
||||
<a class="github-link" href="https://github.com/dgtlmoon/changedetection.io">
|
||||
{% include "svgs/github.svg" %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="pure-menu-horizontal-spinner"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
{% if hosted_sticky %}
|
||||
<div class="sticky-tab" id="hosted-sticky">
|
||||
<a href="https://changedetection.io/?ref={{guid}}">Let us host your instance!</a>
|
||||
@@ -187,62 +218,32 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<header>
|
||||
{% block header %}{% endblock %}
|
||||
</header>
|
||||
|
||||
<div class="content-wrapper">
|
||||
{#
|
||||
{% if current_user.is_authenticated or not has_password %}
|
||||
<aside class="action-sidebar">
|
||||
<a href="{{ url_for('watchlist.index') }}" class="action-sidebar-item {% if request.endpoint.startswith('watchlist.') or request.endpoint.startswith('ui.') %}active{% endif %}" title="{{ _('Watch List') }}">
|
||||
<svg class="action-icon" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M12 6v6l4 2"/>
|
||||
</svg>
|
||||
<span class="action-label">{{ _('Watches') }}</span>
|
||||
</a>
|
||||
<a href="{{ url_for('queue_status') }}" class="action-sidebar-item {% if request.endpoint == 'queue_status' %}active{% endif %}" id="queue-action-item" title="{{ _('Queue Status') }}">
|
||||
<svg class="action-icon" viewBox="0 0 24 24">
|
||||
<line x1="8" y1="6" x2="21" y2="6"/>
|
||||
<line x1="8" y1="12" x2="21" y2="12"/>
|
||||
<line x1="8" y1="18" x2="21" y2="18"/>
|
||||
<line x1="3" y1="6" x2="3.01" y2="6"/>
|
||||
<line x1="3" y1="12" x2="3.01" y2="12"/>
|
||||
<line x1="3" y1="18" x2="3.01" y2="18"/>
|
||||
</svg>
|
||||
<span class="action-label">{{ _('Queue') }}</span>
|
||||
<span class="notification-bubble blue-bubble" id="queue-bubble" data-count="0"></span>
|
||||
</a>
|
||||
</aside>
|
||||
{% endif %}
|
||||
#}
|
||||
<div class="content-main">
|
||||
<header>
|
||||
{% block header %}{% endblock %}
|
||||
</header>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories = true) %}
|
||||
{% if messages %}
|
||||
<ul class="messages">
|
||||
{% for category, message in messages %}
|
||||
<li class="{{ category }}">{{ message }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% if session['share-link'] %}
|
||||
<ul class="messages with-share-link">
|
||||
<li class="message">
|
||||
Share this link:
|
||||
<span id="share-link">{{ session['share-link'] }}</span>
|
||||
<img style="height: 1em; display: inline-block" src="{{url_for('static_content', group='images', filename='copy.svg')}}" >
|
||||
</li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
{% with messages = get_flashed_messages(with_categories = true) %}
|
||||
{% if
|
||||
messages %}
|
||||
<ul class="messages">
|
||||
{% for category, message in messages %}
|
||||
<li class="{{ category }}">{{ message }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% if session['share-link'] %}
|
||||
<ul class="messages with-share-link">
|
||||
<li class="message">
|
||||
Share this link:
|
||||
<span id="share-link">{{ session['share-link'] }}</span>
|
||||
<img style="height: 1em; display: inline-block" src="{{url_for('static_content', group='images', filename='copy.svg')}}" >
|
||||
</li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% block content %}{% endblock %}
|
||||
</section>
|
||||
<script src="{{url_for('static_content', group='js', filename='toggle-theme.js')}}" defer></script>
|
||||
<script src="{{url_for('static_content', group='js', filename='hamburger-menu.js')}}" defer></script>
|
||||
|
||||
<div id="checking-now-fixed-tab" style="display: none;"><span class="spinner"></span><span class="status-text"> {{ _('Checking now') }}</span></div>
|
||||
<div id="realtime-conn-error" style="display:none">{{ _('Real-time updates offline') }}</div>
|
||||
@@ -261,7 +262,7 @@
|
||||
<div class="language-list">
|
||||
{% for locale, lang_data in available_languages.items()|sort %}
|
||||
<a href="{{ url_for('set_language', locale=locale, redirect=request.path) }}" class="language-option" data-locale="{{ locale }}">
|
||||
<span class="lang-option {{ lang_data.flag }}"></span> <span class="language-name">{{ lang_data.name }}</span>
|
||||
<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>
|
||||
@@ -274,103 +275,7 @@
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Search Modal -->
|
||||
{% if current_user.is_authenticated or not has_password %}
|
||||
<dialog id="search-modal" class="modal-dialog" aria-labelledby="search-modal-title">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title" id="search-modal-title">{{ _('Search') }}</h2>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="search-form" method="GET">
|
||||
<div class="pure-control-group">
|
||||
<label for="search-modal-input">{{ _('URL or Title') }}{% if active_tag_uuid %} {{ _('in') }} '{{ active_tag.title }}'{% endif %}</label>
|
||||
<input id="search-modal-input" class="m-d" name="q" placeholder="{{ _('Enter search term...') }}" required type="text" value="" autofocus>
|
||||
<input name="tags" type="hidden" value="{% if active_tag_uuid %}{{active_tag_uuid}}{% endif %}">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="pure-button button-cancel" id="close-search-modal">{{ _('Cancel') }}</button>
|
||||
<button type="submit" form="search-form" class="pure-button pure-button-primary">{{ _('Search') }}</button>
|
||||
</div>
|
||||
</dialog>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
/* AUTOMATIC TAB COLUMN-IZER FOR WHEN TABS WRAP */
|
||||
// Exit early if no tabs on page
|
||||
if (!document.querySelector('.tab')) return;
|
||||
|
||||
const cache = new Map();
|
||||
|
||||
function checkWrapping(ul) {
|
||||
const tabs = ul.querySelectorAll('.tab');
|
||||
if (tabs.length < 2) return false;
|
||||
|
||||
// Init cache on first run
|
||||
if (!cache.has(ul)) {
|
||||
ul.style.setProperty('--tab-width', '');
|
||||
void ul.offsetHeight;
|
||||
let max = 0;
|
||||
tabs.forEach(t => max = Math.max(max, t.offsetWidth));
|
||||
cache.set(ul, max);
|
||||
}
|
||||
|
||||
// Temporarily use flex wrap to check if wrapping occurs
|
||||
ul.style.display = 'flex';
|
||||
ul.style.flexWrap = 'wrap';
|
||||
void ul.offsetHeight;
|
||||
|
||||
const top = tabs[0].offsetTop;
|
||||
const wrapped = Array.from(tabs).some((t, i) => i > 0 && t.offsetTop !== top);
|
||||
|
||||
// Reset display to use CSS grid
|
||||
ul.style.display = '';
|
||||
ul.style.flexWrap = '';
|
||||
|
||||
// Set CSS variable for wrapped mode
|
||||
if (wrapped) {
|
||||
ul.style.setProperty('--tab-width', `${cache.get(ul) + 10}px`);
|
||||
} else {
|
||||
ul.style.setProperty('--tab-width', '');
|
||||
}
|
||||
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
function check() {
|
||||
let any = false;
|
||||
document.querySelectorAll('ul').forEach(ul => {
|
||||
if (ul.querySelector('.tab') && checkWrapping(ul)) any = true;
|
||||
});
|
||||
document.body.classList.toggle('wrapped-tabs', any);
|
||||
}
|
||||
|
||||
check();
|
||||
let timer;
|
||||
window.addEventListener('resize', () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(check, 100);
|
||||
});
|
||||
|
||||
// Re-check wrapping when tabs are switched via anchors
|
||||
window.addEventListener('hashchange', () => {
|
||||
clearTimeout(timer);
|
||||
// Use requestAnimationFrame + setTimeout to ensure DOM has settled
|
||||
requestAnimationFrame(() => {
|
||||
timer = setTimeout(check, 0);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
<script src="{{url_for('static_content', group='js', filename='language-selector.js')}}" defer></script>
|
||||
<script src="{{url_for('static_content', group='js', filename='search-modal.js')}}" defer></script>
|
||||
<script src="{{url_for('static_content', group='js', filename='toast.js')}}"></script>
|
||||
<script src="{{url_for('static_content', group='js', filename='flask-toast-bridge.js')}}" defer></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
") }}
|
||||
<span class="pure-form-message-inline">
|
||||
<ul>
|
||||
<li>{{ _('Text to wait for before triggering a change/notification, all text and regex are tested case-insensitive.') }}</li>
|
||||
<li>{{ _('Trigger text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor') }}</li>
|
||||
<li>{{ _('Each line is processed separately (think of each line as "OR")') }}</li>
|
||||
<li>{{ _('Note: Wrap in forward slash / to use regex example:') }} <code>/foo\d/</code></li>
|
||||
<li>Text to wait for before triggering a change/notification, all text and regex are tested <i>case-insensitive</i>.</li>
|
||||
<li>Trigger text is processed from the result-text that comes out of any CSS/JSON Filters for this watch</li>
|
||||
<li>Each line is processed separately (think of each line as "OR")</li>
|
||||
<li>Note: Wrap in forward slash / to use regex example: <code>/foo\d/</code></li>
|
||||
</ul>
|
||||
</span>
|
||||
</div>
|
||||
@@ -20,10 +20,10 @@
|
||||
") }}
|
||||
<span class="pure-form-message-inline">
|
||||
<ul>
|
||||
<li>{{ _('Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)') }}</li>
|
||||
<li>{{ _('Each line processed separately, any line matching will be ignored (removed before creating the checksum)') }}</li>
|
||||
<li>{{ _('Regular Expression support, wrap the entire line in forward slash') }} <code>/regex/</code></li>
|
||||
<li>{{ _('Changing this will affect the comparison checksum which may trigger an alert') }}</li>
|
||||
<li>Matching text will be <strong>ignored</strong> in the text snapshot (you can still see it but it wont trigger a change)</li>
|
||||
<li>Each line processed separately, any line matching will be ignored (removed before creating the checksum)</li>
|
||||
<li>Regular Expression support, wrap the entire line in forward slash <code>/regex/</code></li>
|
||||
<li>Changing this will affect the comparison checksum which may trigger an alert</li>
|
||||
</ul>
|
||||
</span>
|
||||
<br><br>
|
||||
@@ -40,10 +40,10 @@ Not in stock
|
||||
Unavailable") }}
|
||||
<span class="pure-form-message-inline">
|
||||
<ul>
|
||||
<li>{{ _('Block change-detection while this text is on the page, all text and regex are tested case-insensitive, good for waiting for when a product is available again') }}</li>
|
||||
<li>{{ _('Block text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor') }}</li>
|
||||
<li>{{ _('All lines here must not exist (think of each line as "OR")') }}</li>
|
||||
<li>{{ _('Note: Wrap in forward slash / to use regex example:') }} <code>/foo\d/</code></li>
|
||||
<li>Block change-detection while this text is on the page, all text and regex are tested <i>case-insensitive</i>, good for waiting for when a product is available again</li>
|
||||
<li>Block text is processed from the result-text that comes out of any CSS/JSON Filters for this watch</li>
|
||||
<li>All lines here must not exist (think of each line as "OR")</li>
|
||||
<li>Note: Wrap in forward slash / to use regex example: <code>/foo\d/</code></li>
|
||||
</ul>
|
||||
</span>
|
||||
</div>
|
||||
@@ -55,17 +55,17 @@ Unavailable") }}
|
||||
keyword") }}
|
||||
<span class="pure-form-message-inline">
|
||||
<ul>
|
||||
<li>{{ _('Extracts text in the final output (line by line) after other filters using regular expressions or string match:') }}
|
||||
<li>Extracts text in the final output (line by line) after other filters using regular expressions or string match;
|
||||
<ul>
|
||||
<li>{{ _('Regular expression - example') }} <code>/reports.+?2022/i</code></li>
|
||||
<li>{{ _('Don\'t forget to consider the white-space at the start of a line') }} <code>/.+?reports.+?2022/i</code></li>
|
||||
<li>{{ _('Use') }} <code>//(?aiLmsux))</code> {{ _('type flags (more') }} <a href="https://docs.python.org/3/library/re.html#index-15">{{ _('information here') }}</a>)<br></li>
|
||||
<li>{{ _('Keyword example - example') }} <code>Out of stock</code></li>
|
||||
<li>{{ _('Use groups to extract just that text - example') }} <code>/reports.+?(\d+)/i</code> {{ _('returns a list of years only') }}</li>
|
||||
<li>{{ _('Example - match lines containing a keyword') }} <code>/.*icecream.*/</code></li>
|
||||
<li>Regular expression ‐ example <code>/reports.+?2022/i</code></li>
|
||||
<li>Don't forget to consider the white-space at the start of a line <code>/.+?reports.+?2022/i</code></li>
|
||||
<li>Use <code>//(?aiLmsux))</code> type flags (more <a href="https://docs.python.org/3/library/re.html#index-15">information here</a>)<br></li>
|
||||
<li>Keyword example ‐ example <code>Out of stock</code></li>
|
||||
<li>Use groups to extract just that text ‐ example <code>/reports.+?(\d+)/i</code> returns a list of years only</li>
|
||||
<li>Example - match lines containing a keyword <code>/.*icecream.*/</code></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>{{ _('One line per regular-expression/string match') }}</li>
|
||||
<li>One line per regular-expression/string match</li>
|
||||
</ul>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
{# Menu items template - used for both desktop and mobile menus #}
|
||||
{# CSS media queries handle which version displays - no need for conditional classes #}
|
||||
|
||||
|
||||
{% if current_user.is_authenticated or not has_password %}
|
||||
{% if not current_diff_url %}
|
||||
<li class="pure-menu-item menu-collapsible {% if request.endpoint.startswith('tags.') %}active{% endif %}">
|
||||
<a href="{{ url_for('tags.tags_overview_page') }}" class="pure-menu-link">{{ _('GROUPS') }}</a>
|
||||
</li>
|
||||
<li class="pure-menu-item menu-collapsible {% if request.endpoint.startswith('settings.') %}active{% endif %}">
|
||||
<a href="{{ url_for('settings.settings_page') }}" class="pure-menu-link">{{ _('SETTINGS') }}</a>
|
||||
</li>
|
||||
<li class="pure-menu-item menu-collapsible {% if request.endpoint.startswith('imports.') %}active{% endif %}">
|
||||
<a href="{{ url_for('imports.import_page') }}" class="pure-menu-link">{{ _('IMPORT') }}</a>
|
||||
</li>
|
||||
<li class="pure-menu-item menu-collapsible {% if request.endpoint.startswith('backups.') %}active{% endif %}">
|
||||
<a href="{{ url_for('backups.index') }}" class="pure-menu-link">{{ _('BACKUPS') }}</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="pure-menu-item menu-collapsible">
|
||||
<a href="{{ url_for('ui.ui_edit.edit_page', uuid=uuid, next='diff') }}"
|
||||
class="pure-menu-link">{{ _('EDIT') }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{%- if current_user.is_authenticated -%}
|
||||
<li class="pure-menu-item menu-collapsible">
|
||||
<a href="{{ url_for('logout', redirect=request.path) }}" class="pure-menu-link">{{ _('LOG OUT') }}</a>
|
||||
</li>
|
||||
{%- endif -%}
|
||||
|
||||
{% else %}
|
||||
<li class="pure-menu-item menu-collapsible">
|
||||
<a class="pure-menu-link" href="https://changedetection.io">Website Change Detection and Notification.</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="pure-menu-item menu-collapsible" id="inline-menu-extras-group">
|
||||
<button class="toggle-button 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>
|
||||
<span class="icon-dark">
|
||||
{% include "svgs/dark-mode-toggle-icon.svg" %}
|
||||
</span>
|
||||
</button>
|
||||
<button class="toggle-button language-selector" type="button" title="{{ _('Change Language') }}">
|
||||
<span class="visually-hidden">{{ _('Change language') }}</span>
|
||||
<span class="{{ get_flag_for_locale(get_locale()) }}" id="language-selector-flag"></span>
|
||||
</button>
|
||||
<a class="github-link" href="https://github.com/dgtlmoon/changedetection.io">
|
||||
{% include "svgs/github.svg" %}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -270,6 +270,3 @@ def app(request, datastore_path):
|
||||
|
||||
request.addfinalizer(teardown)
|
||||
yield app
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -206,10 +206,11 @@ def test_regex_error_handling(client, live_server, measure_memory_usage, datasto
|
||||
# Add our URL to the import page
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)
|
||||
time.sleep(0.2)
|
||||
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||
|
||||
### test regex error handling
|
||||
res = client.post(
|
||||
url_for("ui.ui_edit.edit_page", uuid=uuid),
|
||||
url_for("ui.ui_edit.edit_page", uuid="first"),
|
||||
data={"extract_text": '/something bad\d{3/XYZ',
|
||||
"url": test_url,
|
||||
"fetch_backend": "html_requests",
|
||||
|
||||
@@ -45,7 +45,7 @@ def run_filter_test(client, live_server, content_filter, app_notification_format
|
||||
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)
|
||||
res = client.get(url_for("watchlist.index"))
|
||||
|
||||
assert b'No web page change detection watches configured' not in res.data
|
||||
assert b'No website watches configured' not in res.data
|
||||
|
||||
|
||||
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||
|
||||
@@ -4,47 +4,25 @@ import time
|
||||
import os
|
||||
import json
|
||||
from flask import url_for
|
||||
from loguru import logger
|
||||
from .. import strtobool
|
||||
from .util import wait_for_all_checks, delete_all_watches
|
||||
import brotli
|
||||
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
def test_consistent_history(client, live_server, measure_memory_usage, datastore_path):
|
||||
# live_server_setup(live_server) # Setup on conftest per function
|
||||
workers = int(os.getenv("FETCH_WORKERS", 10))
|
||||
r = range(1, 10+workers)
|
||||
|
||||
uuids = set()
|
||||
sys_fetch_workers = int(os.getenv("FETCH_WORKERS", 10))
|
||||
workers = range(1, sys_fetch_workers)
|
||||
now = time.time()
|
||||
for one in r:
|
||||
test_url = url_for('test_endpoint', content_type="text/html", content=str(one), _external=True)
|
||||
res = client.post(
|
||||
url_for("imports.import_page"),
|
||||
data={"urls": test_url},
|
||||
follow_redirects=True
|
||||
)
|
||||
|
||||
for one in workers:
|
||||
if strtobool(os.getenv("TEST_WITH_BROTLI")):
|
||||
# A very long string that WILL trigger Brotli compression of the snapshot
|
||||
# BROTLI_COMPRESS_SIZE_THRESHOLD should be set to say 200
|
||||
from ..model.Watch import BROTLI_COMPRESS_SIZE_THRESHOLD
|
||||
content = str(one) + "x" + str(one) * (BROTLI_COMPRESS_SIZE_THRESHOLD + 10)
|
||||
else:
|
||||
# Just enough to test datastore
|
||||
content = str(one)+'x'
|
||||
|
||||
test_url = url_for('test_endpoint', content_type="text/html", content=content, _external=True)
|
||||
uuids.add(client.application.config.get('DATASTORE').add_watch(url=test_url, extras={'title': str(one)}))
|
||||
|
||||
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||
assert b"1 Imported" in res.data
|
||||
|
||||
wait_for_all_checks(client)
|
||||
duration = time.time() - now
|
||||
per_worker = duration/sys_fetch_workers
|
||||
if sys_fetch_workers < 20:
|
||||
per_worker_threshold=0.6
|
||||
elif sys_fetch_workers < 50:
|
||||
per_worker_threshold = 0.8
|
||||
else:
|
||||
per_worker_threshold = 1.5
|
||||
|
||||
logger.debug(f"All fetched in {duration:.2f}s, {per_worker}s per worker")
|
||||
# Problematic on github
|
||||
#assert per_worker < per_worker_threshold, f"If concurrency is working good, no blocking async problems, each worker ({sys_fetch_workers} workers) should have done his job in under {per_worker_threshold}s, got {per_worker:.2f}s per worker, total duration was {duration:.2f}s"
|
||||
|
||||
# Essentially just triggers the DB write/update
|
||||
res = client.post(
|
||||
@@ -56,7 +34,7 @@ def test_consistent_history(client, live_server, measure_memory_usage, datastore
|
||||
)
|
||||
assert b"Settings updated." in res.data
|
||||
|
||||
# Wait for the sync DB save to happen
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
json_db_file = os.path.join(live_server.app.config['DATASTORE'].datastore_path, 'url-watches.json')
|
||||
@@ -66,18 +44,14 @@ def test_consistent_history(client, live_server, measure_memory_usage, datastore
|
||||
json_obj = json.load(f)
|
||||
|
||||
# assert the right amount of watches was found in the JSON
|
||||
assert len(json_obj['watching']) == len(workers), "Correct number of watches was found in the JSON"
|
||||
|
||||
i = 0
|
||||
assert len(json_obj['watching']) == len(r), "Correct number of watches was found in the JSON"
|
||||
i=0
|
||||
# each one should have a history.txt containing just one line
|
||||
for w in json_obj['watching'].keys():
|
||||
i += 1
|
||||
i+=1
|
||||
history_txt_index_file = os.path.join(live_server.app.config['DATASTORE'].datastore_path, w, 'history.txt')
|
||||
assert os.path.isfile(history_txt_index_file), f"History.txt should exist where I expect it at {history_txt_index_file}"
|
||||
|
||||
# Should be no errors (could be from brotli etc)
|
||||
assert not live_server.app.config['DATASTORE'].data['watching'][w].get('last_error')
|
||||
|
||||
# Same like in model.Watch
|
||||
with open(history_txt_index_file, "r") as f:
|
||||
tmp_history = dict(i.strip().split(',', 2) for i in f.readlines())
|
||||
@@ -89,21 +63,15 @@ def test_consistent_history(client, live_server, measure_memory_usage, datastore
|
||||
# Find the snapshot one
|
||||
for fname in files_in_watch_dir:
|
||||
if fname != 'history.txt' and 'html' not in fname:
|
||||
if strtobool(os.getenv("TEST_WITH_BROTLI")):
|
||||
assert fname.endswith('.br'), "Forced TEST_WITH_BROTLI then it should be a .br filename"
|
||||
|
||||
full_snapshot_history_path = os.path.join(live_server.app.config['DATASTORE'].datastore_path, w, fname)
|
||||
# contents should match what we requested as content returned from the test url
|
||||
if fname.endswith('.br'):
|
||||
with open(full_snapshot_history_path, 'rb') as f:
|
||||
contents = brotli.decompress(f.read()).decode('utf-8')
|
||||
else:
|
||||
with open(full_snapshot_history_path, 'r') as snapshot_f:
|
||||
contents = snapshot_f.read()
|
||||
with open(os.path.join(live_server.app.config['DATASTORE'].datastore_path, w, fname), 'r') as snapshot_f:
|
||||
contents = snapshot_f.read()
|
||||
watch_url = json_obj['watching'][w]['url']
|
||||
u = urlparse(watch_url)
|
||||
q = parse_qs(u[4])
|
||||
assert q['content'][0] == contents.strip(), f"Snapshot file {fname} should contain {q['content'][0]}"
|
||||
|
||||
|
||||
watch_title = json_obj['watching'][w]['title']
|
||||
assert json_obj['watching'][w]['title'], "Watch should have a title set"
|
||||
assert contents.startswith(watch_title + "x"), f"Snapshot contents in file {fname} should start with '{watch_title}x', got '{contents}'"
|
||||
|
||||
assert len(files_in_watch_dir) == 3, "Should be just three files in the dir, html.br snapshot, history.txt and the extracted text snapshot"
|
||||
|
||||
|
||||
@@ -1,71 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from flask import url_for
|
||||
from .util import live_server_setup, wait_for_all_checks
|
||||
|
||||
|
||||
def test_zh_TW(client, live_server, measure_memory_usage, datastore_path):
|
||||
import time
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
|
||||
# Be sure we got a session cookie
|
||||
res = client.get(url_for("watchlist.index"), follow_redirects=True)
|
||||
|
||||
res = client.get(
|
||||
url_for("set_language", locale="zh_Hant_TW"), # Traditional
|
||||
follow_redirects=True
|
||||
)
|
||||
# HTML follows BCP 47 language tag rules, not underscore-based locale formats.
|
||||
assert b'<html lang="zh-Hant-TW"' in res.data
|
||||
assert b'Cannot set language without session cookie' not in res.data
|
||||
assert '選擇語言'.encode() in res.data
|
||||
|
||||
# Check second set works
|
||||
res = client.get(
|
||||
url_for("set_language", locale="en_GB"),
|
||||
follow_redirects=True
|
||||
)
|
||||
assert b'Cannot set language without session cookie' not in res.data
|
||||
res = client.get(url_for("watchlist.index"), follow_redirects=True)
|
||||
assert b"Select Language" in res.data, "Second set of language worked"
|
||||
|
||||
# Check arbitration between zh_Hant_TW<->zh
|
||||
res = client.get(
|
||||
url_for("set_language", locale="zh"), # Simplified chinese
|
||||
follow_redirects=True
|
||||
)
|
||||
res = client.get(url_for("watchlist.index"), follow_redirects=True)
|
||||
assert "选择语言".encode() in res.data, "Simplified chinese worked and it means the flask-babel cache worked"
|
||||
|
||||
|
||||
# timeago library just hasn't been updated to use the more modern locale naming convention, before BCP 47 / RFC 5646.
|
||||
# The Python timeago library (https://github.com/hustcc/timeago) supports 48 locales but uses different naming conventions than Flask-Babel.
|
||||
def test_zh_Hant_TW_timeago_integration():
|
||||
"""Test that zh_Hant_TW mapping works and timeago renders Traditional Chinese correctly"""
|
||||
import timeago
|
||||
from datetime import datetime, timedelta
|
||||
from changedetectionio.languages import get_timeago_locale
|
||||
|
||||
# 1. Test the mapping
|
||||
mapped_locale = get_timeago_locale('zh_Hant_TW')
|
||||
assert mapped_locale == 'zh_TW', "zh_Hant_TW should map to timeago's zh_TW"
|
||||
assert get_timeago_locale('zh_TW') == 'zh_TW', "zh_TW should also map to zh_TW"
|
||||
|
||||
# 2. Test timeago library renders Traditional Chinese with the mapped locale
|
||||
now = datetime.now()
|
||||
|
||||
# Test various time periods with Traditional Chinese strings
|
||||
result_15s = timeago.format(now - timedelta(seconds=15), now, mapped_locale)
|
||||
assert '秒前' in result_15s, f"Expected '秒前' in '{result_15s}'"
|
||||
|
||||
result_5m = timeago.format(now - timedelta(minutes=5), now, mapped_locale)
|
||||
assert '分鐘前' in result_5m, f"Expected '分鐘前' in '{result_5m}'"
|
||||
|
||||
result_2h = timeago.format(now - timedelta(hours=2), now, mapped_locale)
|
||||
assert '小時前' in result_2h, f"Expected '小時前' in '{result_2h}'"
|
||||
|
||||
result_3d = timeago.format(now - timedelta(days=3), now, mapped_locale)
|
||||
assert '天前' in result_3d, f"Expected '天前' in '{result_3d}'"
|
||||
from .util import live_server_setup
|
||||
|
||||
|
||||
def test_language_switching(client, live_server, measure_memory_usage, datastore_path):
|
||||
@@ -77,9 +13,6 @@ def test_language_switching(client, live_server, measure_memory_usage, datastore
|
||||
3. Switch back to English and verify English text appears
|
||||
"""
|
||||
|
||||
# Establish session cookie
|
||||
client.get(url_for("watchlist.index"), follow_redirects=True)
|
||||
|
||||
# Step 1: Set the language to Italian using the /set-language endpoint
|
||||
res = client.get(
|
||||
url_for("set_language", locale="it"),
|
||||
@@ -128,9 +61,6 @@ def test_invalid_locale(client, live_server, measure_memory_usage, datastore_pat
|
||||
The app should ignore invalid locales and continue working.
|
||||
"""
|
||||
|
||||
# Establish session cookie
|
||||
client.get(url_for("watchlist.index"), follow_redirects=True)
|
||||
|
||||
# First set to English
|
||||
res = client.get(
|
||||
url_for("set_language", locale="en"),
|
||||
@@ -163,9 +93,6 @@ def test_language_persistence_in_session(client, live_server, measure_memory_usa
|
||||
within the same session.
|
||||
"""
|
||||
|
||||
# Establish session cookie
|
||||
client.get(url_for("watchlist.index"), follow_redirects=True)
|
||||
|
||||
# Set language to Italian
|
||||
res = client.get(
|
||||
url_for("set_language", locale="it"),
|
||||
@@ -192,9 +119,6 @@ def test_set_language_with_redirect(client, live_server, measure_memory_usage, d
|
||||
"""
|
||||
from flask import url_for
|
||||
|
||||
# Establish session cookie
|
||||
client.get(url_for("watchlist.index"), follow_redirects=True)
|
||||
|
||||
# Set language with a redirect parameter (simulating language change from /settings)
|
||||
res = client.get(
|
||||
url_for("set_language", locale="de", redirect="/settings"),
|
||||
|
||||
@@ -25,13 +25,12 @@ def test_content_filter_live_preview(client, live_server, measure_memory_usage,
|
||||
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
|
||||
|
||||
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)
|
||||
res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||
assert b'Queued 1 watch for rechecking.' in res.data
|
||||
|
||||
wait_for_all_checks(client)
|
||||
|
||||
res = client.post(
|
||||
url_for("ui.ui_views.form_quick_watch_add"),
|
||||
data={"url": test_url, "tags": ''},
|
||||
follow_redirects=True
|
||||
)
|
||||
uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
|
||||
res = client.post(
|
||||
url_for("ui.ui_edit.edit_page", uuid=uuid),
|
||||
data={
|
||||
|
||||
@@ -5,7 +5,7 @@ import re
|
||||
from flask import url_for
|
||||
from loguru import logger
|
||||
|
||||
from .util import set_original_response, set_modified_response, set_more_modified_response, live_server_setup, wait_for_all_checks, wait_for_notification_endpoint_output
|
||||
from .util import set_original_response, set_modified_response, set_more_modified_response, live_server_setup, wait_for_all_checks
|
||||
from . util import extract_UUID_from_client
|
||||
import logging
|
||||
import base64
|
||||
@@ -83,9 +83,7 @@ def test_check_notification(client, live_server, measure_memory_usage, datastore
|
||||
|
||||
|
||||
uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
|
||||
screenshot_dir = os.path.join(datastore_path, str(uuid))
|
||||
os.makedirs(screenshot_dir, exist_ok=True)
|
||||
with open(os.path.join(screenshot_dir, 'last-screenshot.png'), 'wb') as f:
|
||||
with open(os.path.join(datastore_path, str(uuid), 'last-screenshot.png'), 'wb') as f:
|
||||
f.write(base64.b64decode(testimage_png))
|
||||
|
||||
# Goto the edit page, add our ignore text
|
||||
@@ -144,7 +142,7 @@ def test_check_notification(client, live_server, measure_memory_usage, datastore
|
||||
# Trigger a check
|
||||
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||
wait_for_all_checks(client)
|
||||
wait_for_notification_endpoint_output(datastore_path=datastore_path)
|
||||
time.sleep(6)
|
||||
|
||||
# Check no errors were recorded
|
||||
res = client.get(url_for("watchlist.index"))
|
||||
@@ -201,7 +199,7 @@ def test_check_notification(client, live_server, measure_memory_usage, datastore
|
||||
set_more_modified_response(datastore_path=datastore_path)
|
||||
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||
wait_for_all_checks(client)
|
||||
wait_for_notification_endpoint_output(datastore_path=datastore_path)
|
||||
time.sleep(6)
|
||||
# Verify what was sent as a notification, this file should exist
|
||||
with open(os.path.join(datastore_path, "notification.txt"), "r") as f:
|
||||
notification_submission = f.read()
|
||||
@@ -242,8 +240,7 @@ def test_check_notification(client, live_server, measure_memory_usage, datastore
|
||||
)
|
||||
assert b"Updated watch." in res.data
|
||||
|
||||
wait_for_all_checks(client)
|
||||
wait_for_notification_endpoint_output(datastore_path=datastore_path)
|
||||
time.sleep(2)
|
||||
|
||||
# Verify what was sent as a notification, this file should exist
|
||||
with open(os.path.join(datastore_path, "notification.txt"), "r") as f:
|
||||
@@ -328,7 +325,7 @@ def test_notification_custom_endpoint_and_jinja2(client, live_server, measure_me
|
||||
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||
wait_for_all_checks(client)
|
||||
|
||||
wait_for_notification_endpoint_output(datastore_path=datastore_path)
|
||||
time.sleep(2) # plus extra delay for notifications to fire
|
||||
|
||||
|
||||
# Check no errors were recorded, because we asked for 204 which is slightly uncommon but is still OK
|
||||
@@ -446,7 +443,7 @@ def test_global_send_test_notification(client, live_server, measure_memory_usage
|
||||
assert res.status_code != 500
|
||||
|
||||
# Give apprise time to fire
|
||||
wait_for_notification_endpoint_output(datastore_path=datastore_path)
|
||||
time.sleep(4)
|
||||
|
||||
with open(os.path.join(datastore_path, "notification.txt"), 'r') as f:
|
||||
x = f.read()
|
||||
@@ -503,7 +500,7 @@ def test_single_send_test_notification_on_watch(client, live_server, measure_mem
|
||||
|
||||
test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://')+"?xxx={{ watch_url }}&+custom-header=123"
|
||||
# 1995 UTF-8 content should be encoded
|
||||
test_body = 'change detection is cool 网站监测 内容更新了 - {{diff_full}}\n\nCurrent snapshot: {{current_snapshot}}'
|
||||
test_body = 'change detection is cool 网站监测 内容更新了 - {{diff_full}}'
|
||||
######### Test global/system settings
|
||||
res = client.post(
|
||||
url_for("ui.ui_notification.ajax_callback_send_notification_test")+f"/{uuid}",
|
||||
@@ -528,8 +525,7 @@ def test_single_send_test_notification_on_watch(client, live_server, measure_mem
|
||||
assert 'title="Changed into">Example text:' not in x
|
||||
assert 'span' not in x
|
||||
assert 'Example text:' in x
|
||||
#3720 current_snapshot check, was working but lets test it exactly.
|
||||
assert 'Current snapshot: Example text: example test' in x
|
||||
|
||||
os.unlink(os.path.join(datastore_path, "notification.txt"))
|
||||
|
||||
def _test_color_notifications(client, notification_body_token, datastore_path):
|
||||
@@ -576,7 +572,7 @@ def _test_color_notifications(client, notification_body_token, datastore_path):
|
||||
assert b'Queued 1 watch for rechecking.' in res.data
|
||||
|
||||
wait_for_all_checks(client)
|
||||
wait_for_notification_endpoint_output(datastore_path=datastore_path)
|
||||
time.sleep(2)
|
||||
|
||||
with open(os.path.join(datastore_path, "notification.txt"), 'r') as f:
|
||||
x = f.read()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import time
|
||||
from flask import url_for
|
||||
from .util import live_server_setup, wait_for_all_checks, wait_for_watch_history, extract_rss_token_from_UI, get_UUID_for_tag_name, delete_all_watches
|
||||
from .util import live_server_setup, wait_for_all_checks, extract_rss_token_from_UI, get_UUID_for_tag_name, delete_all_watches
|
||||
import os
|
||||
|
||||
|
||||
@@ -87,9 +87,6 @@ def test_rss_group(client, live_server, measure_memory_usage, datastore_path):
|
||||
# Wait for initial checks to complete
|
||||
wait_for_all_checks(client)
|
||||
|
||||
# Ensure initial snapshots are saved
|
||||
assert wait_for_watch_history(client, min_history_count=1, timeout=10), "Watches did not save initial snapshots"
|
||||
|
||||
# Trigger a change
|
||||
set_modified_response(datastore_path=datastore_path)
|
||||
|
||||
@@ -97,9 +94,6 @@ def test_rss_group(client, live_server, measure_memory_usage, datastore_path):
|
||||
res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||
wait_for_all_checks(client)
|
||||
|
||||
# Ensure all watches have sufficient history for RSS generation
|
||||
assert wait_for_watch_history(client, min_history_count=2, timeout=10), "Watches did not accumulate sufficient history"
|
||||
|
||||
# Get RSS token
|
||||
rss_token = extract_rss_token_from_UI(client)
|
||||
assert rss_token is not None
|
||||
@@ -222,13 +216,11 @@ def test_rss_group_only_unviewed(client, live_server, measure_memory_usage, data
|
||||
assert b"Watch added" in res.data
|
||||
|
||||
wait_for_all_checks(client)
|
||||
assert wait_for_watch_history(client, min_history_count=1, timeout=10), "Initial snapshots not saved"
|
||||
|
||||
# Trigger changes
|
||||
set_modified_response(datastore_path=datastore_path)
|
||||
res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||
wait_for_all_checks(client)
|
||||
assert wait_for_watch_history(client, min_history_count=2, timeout=10), "History not accumulated"
|
||||
|
||||
# Get RSS token
|
||||
rss_token = extract_rss_token_from_UI(client)
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import sys
|
||||
import os
|
||||
import pytest
|
||||
|
||||
from changedetectionio import html_tools
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
import html_tools
|
||||
|
||||
# test generation guide.
|
||||
# 1. Do not include encoding in the xml declaration if the test object is a str type.
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# coding=utf-8
|
||||
|
||||
"""Unit tests for html_tools.html_to_text function."""
|
||||
|
||||
import hashlib
|
||||
import threading
|
||||
from queue import Queue
|
||||
import pytest
|
||||
|
||||
from changedetectionio.html_tools import html_to_text
|
||||
|
||||
|
||||
class TestHtmlToText:
|
||||
"""Test html_to_text function for correctness and thread-safety."""
|
||||
|
||||
def test_basic_text_extraction(self):
|
||||
"""Test basic HTML to text conversion."""
|
||||
html = '<html><body><h1>Title</h1><p>Paragraph text.</p></body></html>'
|
||||
text = html_to_text(html)
|
||||
|
||||
assert 'Title' in text
|
||||
assert 'Paragraph text.' in text
|
||||
assert '<' not in text # HTML tags should be stripped
|
||||
assert '>' not in text
|
||||
|
||||
def test_empty_html(self):
|
||||
"""Test handling of empty HTML."""
|
||||
html = '<html><body></body></html>'
|
||||
text = html_to_text(html)
|
||||
|
||||
# Should return empty or whitespace only
|
||||
assert text.strip() == ''
|
||||
|
||||
def test_nested_elements(self):
|
||||
"""Test extraction from nested HTML elements."""
|
||||
html = '''
|
||||
<html>
|
||||
<body>
|
||||
<div>
|
||||
<h1>Header</h1>
|
||||
<div>
|
||||
<p>First paragraph</p>
|
||||
<p>Second paragraph</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
text = html_to_text(html)
|
||||
|
||||
assert 'Header' in text
|
||||
assert 'First paragraph' in text
|
||||
assert 'Second paragraph' in text
|
||||
|
||||
def test_anchor_tag_rendering(self):
|
||||
"""Test anchor tag rendering option."""
|
||||
html = '<html><body><a href="https://example.com">Link text</a></body></html>'
|
||||
|
||||
# Without rendering anchors
|
||||
text_without = html_to_text(html, render_anchor_tag_content=False)
|
||||
assert 'Link text' in text_without
|
||||
assert 'https://example.com' not in text_without
|
||||
|
||||
# With rendering anchors
|
||||
text_with = html_to_text(html, render_anchor_tag_content=True)
|
||||
assert 'Link text' in text_with
|
||||
assert 'https://example.com' in text_with or '[Link text]' in text_with
|
||||
|
||||
def test_rss_mode(self):
|
||||
"""Test RSS mode converts title tags to h1."""
|
||||
html = '<item><title>RSS Title</title><description>Content</description></item>'
|
||||
|
||||
# is_rss=True should convert <title> to <h1>
|
||||
text = html_to_text(html, is_rss=True)
|
||||
|
||||
assert 'RSS Title' in text
|
||||
assert 'Content' in text
|
||||
|
||||
def test_special_characters(self):
|
||||
"""Test handling of special characters and entities."""
|
||||
html = '<html><body><p>Test & <special> characters</p></body></html>'
|
||||
text = html_to_text(html)
|
||||
|
||||
# Entities should be decoded
|
||||
assert 'Test &' in text or 'Test &' in text
|
||||
assert 'special' in text
|
||||
|
||||
def test_whitespace_handling(self):
|
||||
"""Test that whitespace is properly handled."""
|
||||
html = '<html><body><p>Line 1</p><p>Line 2</p></body></html>'
|
||||
text = html_to_text(html)
|
||||
|
||||
# Should have some separation between lines
|
||||
assert 'Line 1' in text
|
||||
assert 'Line 2' in text
|
||||
assert text.count('\n') >= 1 # At least one newline
|
||||
|
||||
def test_deterministic_output(self):
|
||||
"""Test that the same HTML always produces the same text."""
|
||||
html = '<html><body><h1>Test</h1><p>Content here</p></body></html>'
|
||||
|
||||
# Extract text multiple times
|
||||
results = [html_to_text(html) for _ in range(10)]
|
||||
|
||||
# All results should be identical
|
||||
assert len(set(results)) == 1, "html_to_text should be deterministic"
|
||||
|
||||
def test_thread_safety_determinism(self):
|
||||
"""
|
||||
Test that html_to_text produces deterministic output under high concurrency.
|
||||
|
||||
This is the critical test for the lxml threading bug fix.
|
||||
Without the thread-local parser fix, this test would occasionally fail
|
||||
under high concurrency when multiple threads share the global parser.
|
||||
"""
|
||||
html = '''
|
||||
<html>
|
||||
<head><title>Test Page</title></head>
|
||||
<body>
|
||||
<h1>Main Heading</h1>
|
||||
<div class="content">
|
||||
<p>First paragraph with <b>bold text</b>.</p>
|
||||
<p>Second paragraph with <i>italic text</i>.</p>
|
||||
<ul>
|
||||
<li>Item 1</li>
|
||||
<li>Item 2</li>
|
||||
<li>Item 3</li>
|
||||
</ul>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
|
||||
results_queue = Queue()
|
||||
|
||||
def worker(worker_id, iterations=10):
|
||||
"""Worker that converts HTML to text multiple times."""
|
||||
for i in range(iterations):
|
||||
text = html_to_text(html)
|
||||
md5 = hashlib.md5(text.encode('utf-8')).hexdigest()
|
||||
results_queue.put((worker_id, i, md5))
|
||||
|
||||
# Launch many threads simultaneously
|
||||
num_threads = 50
|
||||
threads = []
|
||||
|
||||
for i in range(num_threads):
|
||||
t = threading.Thread(target=worker, args=(i,))
|
||||
threads.append(t)
|
||||
t.start()
|
||||
|
||||
# Wait for all threads to complete
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# Collect all MD5 results
|
||||
md5_values = []
|
||||
while not results_queue.empty():
|
||||
_, _, md5 = results_queue.get()
|
||||
md5_values.append(md5)
|
||||
|
||||
# All MD5s should be identical
|
||||
unique_md5s = set(md5_values)
|
||||
|
||||
assert len(unique_md5s) == 1, (
|
||||
f"Thread-safety issue detected! Found {len(unique_md5s)} different MD5 values: {unique_md5s}. "
|
||||
"The thread-local parser fix may not be working correctly."
|
||||
)
|
||||
|
||||
print(f"✓ Thread-safety test passed: {len(md5_values)} conversions, all identical")
|
||||
|
||||
def test_thread_local_parser_exists(self):
|
||||
"""Verify that thread-local storage is properly initialized."""
|
||||
# Call html_to_text at least once to initialize thread-local storage
|
||||
html_to_text('<html><body>Test</body></html>')
|
||||
|
||||
# Check that thread-local storage attribute exists
|
||||
assert hasattr(html_to_text, '_thread_local'), (
|
||||
"html_to_text should have _thread_local attribute for thread-safe parsers"
|
||||
)
|
||||
|
||||
def test_different_threads_get_different_parsers(self):
|
||||
"""Verify that different threads CAN get different parser instances."""
|
||||
parser_ids = Queue()
|
||||
|
||||
def get_parser_id():
|
||||
"""Get the parser ID in this thread."""
|
||||
# Trigger parser creation
|
||||
html_to_text('<html><body>Test</body></html>')
|
||||
|
||||
# Get the parser instance for this thread
|
||||
if hasattr(html_to_text._thread_local, 'parser'):
|
||||
parser = html_to_text._thread_local.parser
|
||||
parser_ids.put(id(parser))
|
||||
|
||||
# Launch multiple threads
|
||||
threads = []
|
||||
for _ in range(5):
|
||||
t = threading.Thread(target=get_parser_id)
|
||||
threads.append(t)
|
||||
t.start()
|
||||
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# Collect all parser IDs
|
||||
ids = []
|
||||
while not parser_ids.empty():
|
||||
ids.append(parser_ids.get())
|
||||
|
||||
# We should have at least 2 different parser instances
|
||||
# (threads can reuse IDs after completion, so not necessarily all unique)
|
||||
unique_ids = set(ids)
|
||||
assert len(unique_ids) >= 2, (
|
||||
f"Expected at least 2 unique parsers, but got {len(unique_ids)}. "
|
||||
"Thread-local storage may not be working correctly."
|
||||
)
|
||||
|
||||
print(f"✓ Parser isolation test passed: {len(ids)} threads, {len(unique_ids)} unique parsers")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Can run this file directly for quick testing
|
||||
pytest.main([__file__, '-v'])
|
||||
@@ -164,45 +164,14 @@ def wait_for_all_checks(client=None):
|
||||
if q_length == 0 and not any_workers_busy:
|
||||
if empty_since is None:
|
||||
empty_since = time.time()
|
||||
# Brief stabilization period for async workers
|
||||
elif time.time() - empty_since >= 0.3:
|
||||
elif time.time() - empty_since >= 0.15: # Shorter wait
|
||||
break
|
||||
else:
|
||||
empty_since = None
|
||||
|
||||
|
||||
attempt += 1
|
||||
time.sleep(0.3)
|
||||
|
||||
def wait_for_watch_history(client, min_history_count=2, timeout=10):
|
||||
"""
|
||||
Wait for watches to have sufficient history entries.
|
||||
Useful after wait_for_all_checks() when you need to ensure history is populated.
|
||||
|
||||
Args:
|
||||
client: Test client with access to datastore
|
||||
min_history_count: Minimum number of history entries required
|
||||
timeout: Maximum time to wait in seconds
|
||||
"""
|
||||
datastore = client.application.config.get('DATASTORE')
|
||||
start_time = time.time()
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
all_have_history = True
|
||||
for uuid, watch in datastore.data['watching'].items():
|
||||
history_count = len(watch.history.keys())
|
||||
if history_count < min_history_count:
|
||||
all_have_history = False
|
||||
break
|
||||
|
||||
if all_have_history:
|
||||
return True
|
||||
|
||||
time.sleep(0.2)
|
||||
|
||||
# Timeout - return False
|
||||
return False
|
||||
|
||||
|
||||
# Replaced by new_live_server_setup and calling per function scope in conftest.py
|
||||
def live_server_setup(live_server):
|
||||
return True
|
||||
@@ -220,8 +189,6 @@ def new_live_server_setup(live_server):
|
||||
|
||||
@live_server.app.route('/test-endpoint')
|
||||
def test_endpoint():
|
||||
from loguru import logger
|
||||
logger.debug(f"/test-endpoint hit {request}")
|
||||
ctype = request.args.get('content_type')
|
||||
status_code = request.args.get('status_code')
|
||||
content = request.args.get('content') or None
|
||||
|
||||
@@ -144,6 +144,7 @@ def test_basic_browserstep(client, live_server, measure_memory_usage, datastore_
|
||||
|
||||
def test_non_200_errors_report_browsersteps(client, live_server, measure_memory_usage, datastore_path):
|
||||
|
||||
|
||||
four_o_four_url = url_for('test_endpoint', status_code=404, _external=True)
|
||||
four_o_four_url = four_o_four_url.replace('localhost.localdomain', 'cdio')
|
||||
four_o_four_url = four_o_four_url.replace('localhost', 'cdio')
|
||||
@@ -185,65 +186,3 @@ def test_non_200_errors_report_browsersteps(client, live_server, measure_memory_
|
||||
url_for("ui.form_delete", uuid="all"),
|
||||
follow_redirects=True
|
||||
)
|
||||
|
||||
def test_browsersteps_edit_UI_startsession(client, live_server, measure_memory_usage, datastore_path):
|
||||
|
||||
assert os.getenv('PLAYWRIGHT_DRIVER_URL'), "Needs PLAYWRIGHT_DRIVER_URL set for this test"
|
||||
|
||||
# Add a watch first
|
||||
test_url = url_for('test_interactive_html_endpoint', _external=True)
|
||||
test_url = test_url.replace('localhost.localdomain', 'cdio')
|
||||
test_url = test_url.replace('localhost', 'cdio')
|
||||
|
||||
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url, extras={'fetch_backend': 'html_webdriver', 'paused': True})
|
||||
|
||||
# Test starting a browsersteps session
|
||||
res = client.get(
|
||||
url_for("browser_steps.browsersteps_start_session", uuid=uuid),
|
||||
follow_redirects=True
|
||||
)
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.is_json
|
||||
json_data = res.get_json()
|
||||
assert 'browsersteps_session_id' in json_data
|
||||
assert json_data['browsersteps_session_id'] # Not empty
|
||||
|
||||
browsersteps_session_id = json_data['browsersteps_session_id']
|
||||
|
||||
# Verify the session exists in browsersteps_sessions
|
||||
from changedetectionio.blueprint.browser_steps import browsersteps_sessions, browsersteps_watch_to_session
|
||||
assert browsersteps_session_id in browsersteps_sessions
|
||||
assert uuid in browsersteps_watch_to_session
|
||||
assert browsersteps_watch_to_session[uuid] == browsersteps_session_id
|
||||
|
||||
# Verify browsersteps UI shows up on edit page
|
||||
res = client.get(url_for("ui.ui_edit.edit_page", uuid=uuid))
|
||||
assert b'browsersteps-click-start' in res.data, "Browsersteps manual UI shows up"
|
||||
|
||||
# Session should still exist after GET (not cleaned up yet)
|
||||
assert browsersteps_session_id in browsersteps_sessions
|
||||
assert uuid in browsersteps_watch_to_session
|
||||
|
||||
# Test cleanup happens on save (POST)
|
||||
res = client.post(
|
||||
url_for("ui.ui_edit.edit_page", uuid=uuid),
|
||||
data={
|
||||
"url": test_url,
|
||||
"tags": "",
|
||||
'fetch_backend': "html_webdriver",
|
||||
"time_between_check_use_default": "y",
|
||||
},
|
||||
follow_redirects=True
|
||||
)
|
||||
assert b"Updated watch" in res.data
|
||||
|
||||
# NOW verify the session was cleaned up after save
|
||||
assert browsersteps_session_id not in browsersteps_sessions
|
||||
assert uuid not in browsersteps_watch_to_session
|
||||
|
||||
# Cleanup
|
||||
client.get(
|
||||
url_for("ui.form_delete", uuid="all"),
|
||||
follow_redirects=True
|
||||
)
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -38,11 +38,15 @@ msgid "Incorrect password"
|
||||
msgstr "Password errata"
|
||||
|
||||
#: changedetectionio/forms.py:63 changedetectionio/forms.py:243
|
||||
msgid "At least one time interval (weeks, days, hours, minutes, or seconds) must be specified."
|
||||
msgid ""
|
||||
"At least one time interval (weeks, days, hours, minutes, or seconds) must"
|
||||
" be specified."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py:64
|
||||
msgid "At least one time interval (weeks, days, hours, minutes, or seconds) must be specified when not using global settings."
|
||||
msgid ""
|
||||
"At least one time interval (weeks, days, hours, minutes, or seconds) must"
|
||||
" be specified when not using global settings."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py:164
|
||||
@@ -171,15 +175,16 @@ msgstr "Valore non valido."
|
||||
|
||||
#: changedetectionio/forms.py:732
|
||||
msgid "Watch"
|
||||
msgstr "Monitora"
|
||||
msgstr "Osserva"
|
||||
|
||||
#: changedetectionio/forms.py:733 changedetectionio/forms.py:766
|
||||
msgid "Processor"
|
||||
msgstr "Processore"
|
||||
|
||||
#: changedetectionio/forms.py:734
|
||||
#, fuzzy
|
||||
msgid "Edit > Watch"
|
||||
msgstr "Modifica > Monitora"
|
||||
msgstr "Modifica prima poi Monitora"
|
||||
|
||||
#: changedetectionio/forms.py:747 changedetectionio/forms.py:994
|
||||
msgid "Fetch Method"
|
||||
@@ -365,8 +370,9 @@ msgid "Muted"
|
||||
msgstr "Silenzia"
|
||||
|
||||
#: changedetectionio/forms.py:832
|
||||
#, fuzzy
|
||||
msgid "On"
|
||||
msgstr "Attivo"
|
||||
msgstr "nessuno"
|
||||
|
||||
#: changedetectionio/forms.py:833
|
||||
msgid "Attach screenshot to notification (where possible)"
|
||||
@@ -409,8 +415,9 @@ msgid "Invalid template syntax in \"%(header)s\" header: %(error)s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py:920 changedetectionio/forms.py:932
|
||||
#, fuzzy
|
||||
msgid "Name"
|
||||
msgstr "Nome"
|
||||
msgstr "Riattiva audio"
|
||||
|
||||
#: changedetectionio/forms.py:921
|
||||
msgid "Proxy URL"
|
||||
@@ -433,8 +440,9 @@ msgid "Plaintext requests"
|
||||
msgstr "Richieste in chiaro"
|
||||
|
||||
#: changedetectionio/forms.py:946
|
||||
#, fuzzy
|
||||
msgid "Chrome requests"
|
||||
msgstr "Richieste Chrome"
|
||||
msgstr "Richiesta"
|
||||
|
||||
#: changedetectionio/forms.py:952
|
||||
msgid "Default proxy"
|
||||
@@ -489,8 +497,9 @@ msgid "API access token security check enabled"
|
||||
msgstr "Controllo sicurezza token API attivo"
|
||||
|
||||
#: changedetectionio/forms.py:989
|
||||
#, fuzzy
|
||||
msgid "Notification base URL override"
|
||||
msgstr "URL base notifiche"
|
||||
msgstr "Notifiche"
|
||||
|
||||
#: changedetectionio/forms.py:993
|
||||
msgid "Treat empty pages as a change?"
|
||||
@@ -604,7 +613,9 @@ msgid "A backup is running!"
|
||||
msgstr "Un backup è in esecuzione!"
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html:13
|
||||
msgid "Here you can download and request a new backup, when a backup is completed you will see it listed below."
|
||||
msgid ""
|
||||
"Here you can download and request a new backup, when a backup is "
|
||||
"completed you will see it listed below."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html:19
|
||||
@@ -624,8 +635,12 @@ msgid "Remove backups"
|
||||
msgstr "Rimuovi backup"
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py:45
|
||||
msgid "Importing 5,000 of the first URLs from your list, the rest can be imported again."
|
||||
msgstr "Importazione delle prime 5.000 URL dalla tua lista, il resto può essere importato di nuovo."
|
||||
msgid ""
|
||||
"Importing 5,000 of the first URLs from your list, the rest can be "
|
||||
"imported again."
|
||||
msgstr ""
|
||||
"Importazione delle prime 5.000 URL dalla tua lista, il resto può essere "
|
||||
"importato di nuovo."
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py:78
|
||||
#, python-brace-format
|
||||
@@ -648,19 +663,27 @@ msgstr "{} Importate da Distill.io in {:.2f}s, {} Ignorate."
|
||||
#: changedetectionio/blueprint/imports/importer.py:160
|
||||
#: changedetectionio/blueprint/imports/importer.py:239
|
||||
msgid "Unable to read export XLSX file, something wrong with the file?"
|
||||
msgstr "Impossibile leggere il file XLSX di esportazione, c'è qualcosa che non va con il file?"
|
||||
msgstr ""
|
||||
"Impossibile leggere il file XLSX di esportazione, c'è qualcosa che non va"
|
||||
" con il file?"
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py:200
|
||||
#: changedetectionio/blueprint/imports/importer.py:268
|
||||
#, python-brace-format
|
||||
msgid "Error processing row number {}, URL value was incorrect, row was skipped."
|
||||
msgstr "Errore nell'elaborazione della riga numero {}, il valore dell'URL non era corretto, riga ignorata."
|
||||
msgstr ""
|
||||
"Errore nell'elaborazione della riga numero {}, il valore dell'URL non era"
|
||||
" corretto, riga ignorata."
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py:214
|
||||
#: changedetectionio/blueprint/imports/importer.py:297
|
||||
#, python-brace-format
|
||||
msgid "Error processing row number {}, check all cell data types are correct, row was skipped."
|
||||
msgstr "Errore nell'elaborazione della riga numero {}, verifica che tutti i tipi di dati delle celle siano corretti, riga ignorata."
|
||||
msgid ""
|
||||
"Error processing row number {}, check all cell data types are correct, "
|
||||
"row was skipped."
|
||||
msgstr ""
|
||||
"Errore nell'elaborazione della riga numero {}, verifica che tutti i tipi "
|
||||
"di dati delle celle siano corretti, riga ignorata."
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py:218
|
||||
#, python-brace-format
|
||||
@@ -685,7 +708,9 @@ msgid ".XLSX & Wachete"
|
||||
msgstr ".XLSX & Wachete"
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html:20
|
||||
msgid "Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):"
|
||||
msgid ""
|
||||
"Enter one URL per line, and optionally add tags for each URL after a "
|
||||
"space, delineated by comma (,):"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html:22
|
||||
@@ -697,7 +722,9 @@ msgid "URLs which do not pass validation will stay in the textarea."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html:44
|
||||
msgid "Copy and Paste your Distill.io watch 'export' file, this should be a JSON file."
|
||||
msgid ""
|
||||
"Copy and Paste your Distill.io watch 'export' file, this should be a JSON"
|
||||
" file."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html:45
|
||||
@@ -990,12 +1017,14 @@ msgid "Watch group / tag"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html:21
|
||||
msgid "Groups allows you to manage filters and notifications for multiple watches under a single organisational tag."
|
||||
msgid ""
|
||||
"Groups allows you to manage filters and notifications for multiple "
|
||||
"watches under a single organisational tag."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html:31
|
||||
msgid "# Watches"
|
||||
msgstr "# Monitoraggi"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html:32
|
||||
msgid "Tag / Label name"
|
||||
@@ -1016,7 +1045,9 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html:59
|
||||
#, python-format
|
||||
msgid "<p>Are you sure you want to delete group <strong>%(title)s</strong>?</p><p>This action cannot be undone.</p>"
|
||||
msgid ""
|
||||
"<p>Are you sure you want to delete group "
|
||||
"<strong>%(title)s</strong>?</p><p>This action cannot be undone.</p>"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html:60
|
||||
@@ -1036,7 +1067,10 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html:67
|
||||
#, python-format
|
||||
msgid "<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>"
|
||||
msgid ""
|
||||
"<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>"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html:68
|
||||
@@ -1153,7 +1187,9 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py:330
|
||||
#, python-brace-format
|
||||
msgid "Could not share, something went wrong while communicating with the share server - {}"
|
||||
msgid ""
|
||||
"Could not share, something went wrong while communicating with the share "
|
||||
"server - {}"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/diff.py:93
|
||||
@@ -1166,7 +1202,9 @@ msgid "No history found for the specified link, bad link?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/diff.py:98
|
||||
msgid "Not enough history (2 snapshots required) to show difference page for this watch."
|
||||
msgid ""
|
||||
"Not enough history (2 snapshots required) to show difference page for "
|
||||
"this watch."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py:35
|
||||
@@ -1214,7 +1252,9 @@ msgid "Watch added."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:12
|
||||
msgid "This will remove version history (snapshots) for ALL watches, but keep your list of URLs!"
|
||||
msgid ""
|
||||
"This will remove version history (snapshots) for ALL watches, but keep "
|
||||
"your list of URLs!"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:13
|
||||
@@ -1231,7 +1271,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:17
|
||||
msgid "Confirmation text"
|
||||
msgstr "Testo di conferma"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:27
|
||||
msgid "Type in the word"
|
||||
@@ -1280,11 +1320,11 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/diff.html:53
|
||||
msgid "Words"
|
||||
msgstr "Parole"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/diff.html:57
|
||||
msgid "Lines"
|
||||
msgstr "Righe"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/diff.html:61
|
||||
msgid "Ignore Whitespace"
|
||||
@@ -1292,7 +1332,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/diff.html:65
|
||||
msgid "Same/non-changed"
|
||||
msgstr "Uguale/non modificato"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/diff.html:69
|
||||
msgid "Removed"
|
||||
@@ -1334,12 +1374,12 @@ msgstr ""
|
||||
#: changedetectionio/blueprint/ui/templates/diff.html:97
|
||||
#: changedetectionio/blueprint/ui/templates/preview.html:45
|
||||
msgid "Error Text"
|
||||
msgstr "Testo dell'errore"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/diff.html:98
|
||||
#: changedetectionio/blueprint/ui/templates/preview.html:47
|
||||
msgid "Error Screenshot"
|
||||
msgstr "Screenshot dell'errore"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/diff.html:99
|
||||
#: changedetectionio/blueprint/ui/templates/preview.html:50
|
||||
@@ -1349,7 +1389,7 @@ msgstr "Testo"
|
||||
#: changedetectionio/blueprint/ui/templates/diff.html:100
|
||||
#: changedetectionio/blueprint/ui/templates/preview.html:51
|
||||
msgid "Current screenshot"
|
||||
msgstr "Screenshot corrente"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/diff.html:101
|
||||
msgid "Extract Data"
|
||||
@@ -1396,13 +1436,15 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/diff.html:144
|
||||
#: changedetectionio/blueprint/ui/templates/preview.html:80
|
||||
msgid "For now, Differences are performed on text, not graphically, only the latest screenshot is available."
|
||||
msgid ""
|
||||
"For now, Differences are performed on text, not graphically, only the "
|
||||
"latest screenshot is available."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/diff.html:149
|
||||
#: changedetectionio/blueprint/ui/templates/preview.html:86
|
||||
msgid "Current screenshot from most recent request"
|
||||
msgstr "Screenshot corrente dalla richiesta più recente"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/diff.html:151
|
||||
#: changedetectionio/blueprint/ui/templates/preview.html:88
|
||||
@@ -1458,7 +1500,9 @@ msgid "Organisational tag/group name used in the main listing page"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:85
|
||||
msgid "Automatically uses the page title if found, you can also use your own title/description here"
|
||||
msgid ""
|
||||
"Automatically uses the page title if found, you can also use your own "
|
||||
"title/description here"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:95
|
||||
@@ -1466,7 +1510,10 @@ msgid "The interval/amount of time between each check."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:110
|
||||
msgid "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."
|
||||
msgid ""
|
||||
"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."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:123
|
||||
@@ -1478,7 +1525,9 @@ msgid "Basic"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:123
|
||||
msgid "method (default) where your watched site doesn't need Javascript to render."
|
||||
msgid ""
|
||||
"method (default) where your watched site doesn't need Javascript to "
|
||||
"render."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:124
|
||||
@@ -1490,7 +1539,9 @@ msgid "Chrome/Javascript"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:124
|
||||
msgid "method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'."
|
||||
msgid ""
|
||||
"method requires a network connection to a running WebDriver+Chrome "
|
||||
"server, set by the ENV var 'WEBDRIVER_URL'."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:125
|
||||
@@ -1506,7 +1557,9 @@ msgid "Choose a proxy for this watch"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:143
|
||||
msgid "If you're having trouble waiting for the page to be fully rendered (text missing etc), try increasing the 'wait' time here."
|
||||
msgid ""
|
||||
"If you're having trouble waiting for the page to be fully rendered (text "
|
||||
"missing etc), try increasing the 'wait' time here."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:145
|
||||
@@ -1527,7 +1580,9 @@ msgid "Show advanced options"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:157
|
||||
msgid "Run this code before performing change detection, handy for filling in fields and other actions"
|
||||
msgid ""
|
||||
"Run this code before performing change detection, handy for filling in "
|
||||
"fields and other actions"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:158
|
||||
@@ -1584,7 +1639,9 @@ msgid "Visual Selector data is not ready, watch needs to be checked atleast once
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:253
|
||||
msgid "Sorry, this functionality only works with fetchers that support interactive Javascript (so far only Playwright based fetchers)"
|
||||
msgid ""
|
||||
"Sorry, this functionality only works with fetchers that support "
|
||||
"interactive Javascript (so far only Playwright based fetchers)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:254
|
||||
@@ -1602,7 +1659,9 @@ msgid "to one that supports interactive Javascript."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:297
|
||||
msgid "Use the verify (✓) button to test if a condition passes against the current snapshot."
|
||||
msgid ""
|
||||
"Use the verify (✓) button to test if a condition passes against the "
|
||||
"current snapshot."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:298
|
||||
@@ -1630,7 +1689,9 @@ msgid "Limit trigger/ignore/block/extract to;"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:326
|
||||
msgid "Note: Depending on the length and similarity of the text on each line, the algorithm may consider an"
|
||||
msgid ""
|
||||
"Note: Depending on the length and similarity of the text on each line, "
|
||||
"the algorithm may consider an"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:326
|
||||
@@ -1671,11 +1732,16 @@ msgid "Only trigger when unique lines appear"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:332
|
||||
msgid "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."
|
||||
msgid ""
|
||||
"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."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:340
|
||||
msgid "Helps reduce changes detected caused by sites shuffling lines around, combine with"
|
||||
msgid ""
|
||||
"Helps reduce changes detected caused by sites shuffling lines around, "
|
||||
"combine with"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:340
|
||||
@@ -1705,7 +1771,9 @@ msgid "text"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:386
|
||||
msgid "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"
|
||||
msgid ""
|
||||
"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"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:386
|
||||
@@ -1745,7 +1813,9 @@ msgid "Currently:"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:423
|
||||
msgid "Sorry, this functionality only works with fetchers that support Javascript and screenshots (such as playwright etc)."
|
||||
msgid ""
|
||||
"Sorry, this functionality only works with fetchers that support "
|
||||
"Javascript and screenshots (such as playwright etc)."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:424
|
||||
@@ -1801,7 +1871,9 @@ msgid "Are you sure you want to clear all history for:"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:496
|
||||
msgid "This will remove all snapshots and previous versions. This action cannot be undone."
|
||||
msgid ""
|
||||
"This will remove all snapshots and previous versions. This action cannot "
|
||||
"be undone."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:497
|
||||
@@ -1825,7 +1897,9 @@ msgid "Current erroring screenshot from most recent request"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/preview.html:91
|
||||
msgid "Screenshot requires a Content Fetcher ( Sockpuppetbrowser, selenium, etc ) that supports screenshots."
|
||||
msgid ""
|
||||
"Screenshot requires a Content Fetcher ( Sockpuppetbrowser, selenium, etc "
|
||||
") that supports screenshots."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:31
|
||||
@@ -1838,7 +1912,7 @@ msgstr "Monitora questo URL!"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:35
|
||||
msgid "Edit first then Watch"
|
||||
msgstr "Modifica > Monitora"
|
||||
msgstr "Modifica prima poi Monitora"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:45
|
||||
msgid "Create a shareable link"
|
||||
@@ -1894,7 +1968,9 @@ msgid "Clear Histories"
|
||||
msgstr "Cancella cronologie"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:66
|
||||
msgid "<p>Are you sure you want to clear history for the selected items?</p><p>This action cannot be undone.</p>"
|
||||
msgid ""
|
||||
"<p>Are you sure you want to clear history for the selected "
|
||||
"items?</p><p>This action cannot be undone.</p>"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:67
|
||||
@@ -1910,7 +1986,9 @@ msgid "Delete Watches?"
|
||||
msgstr "Eliminare monitoraggi?"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:72
|
||||
msgid "<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>"
|
||||
msgid ""
|
||||
"<p>Are you sure you want to delete the selected "
|
||||
"watches?</strong></p><p>This action cannot be undone.</p>"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:78
|
||||
@@ -1943,8 +2021,8 @@ msgid "Changed"
|
||||
msgstr "Modifica"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:130
|
||||
msgid "No web page change detection watches configured, please add a URL in the box above, or"
|
||||
msgstr "Nessun monitoraggio configurato, aggiungi un URL nella casella sopra, oppure"
|
||||
msgid "No website watches configured, please add a URL in the box above, or"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:130
|
||||
msgid "import a list"
|
||||
@@ -2208,8 +2286,12 @@ msgid "Select Language"
|
||||
msgstr "Seleziona Lingua"
|
||||
|
||||
#: changedetectionio/templates/base.html:270
|
||||
msgid "Language support is in beta, please help us improve by opening a PR on GitHub with any updates."
|
||||
msgstr "Il supporto linguistico è in versione beta, aiutaci a migliorare aprendo una PR su GitHub con eventuali aggiornamenti."
|
||||
msgid ""
|
||||
"Language support is in beta, please help us improve by opening a PR on "
|
||||
"GitHub with any updates."
|
||||
msgstr ""
|
||||
"Il supporto linguistico è in versione beta, aiutaci a migliorare aprendo "
|
||||
"una PR su GitHub con eventuali aggiornamenti."
|
||||
|
||||
#: changedetectionio/templates/login.html:10
|
||||
msgid "Password"
|
||||
@@ -2219,274 +2301,3 @@ msgstr "Password"
|
||||
msgid "Login"
|
||||
msgstr "Accedi"
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py:18
|
||||
#: changedetectionio/widgets/ternary_boolean.py:72
|
||||
msgid "Yes"
|
||||
msgstr "Sì"
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py:19
|
||||
#: changedetectionio/widgets/ternary_boolean.py:73
|
||||
msgid "No"
|
||||
msgstr "No"
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py:20
|
||||
#: changedetectionio/widgets/ternary_boolean.py:74
|
||||
msgid "Main settings"
|
||||
msgstr "Impostazioni principali"
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html:11
|
||||
msgid "Show token/placeholders"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html:18
|
||||
msgid "Token"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html:19
|
||||
msgid "Description"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html:128
|
||||
msgid "Show advanced help and tips"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html:138
|
||||
#, fuzzy
|
||||
msgid "Send test notification"
|
||||
msgstr "Usa notifica predefinita"
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html:140
|
||||
msgid "Add email"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html:140
|
||||
msgid "Add an email address"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html:142
|
||||
#, fuzzy
|
||||
msgid "Notification debug logs"
|
||||
msgstr "Notifiche"
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html:144
|
||||
#, fuzzy
|
||||
msgid "Processing.."
|
||||
msgstr "Processore"
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html:151
|
||||
#, fuzzy
|
||||
msgid "Title for all notifications"
|
||||
msgstr "Usa notifica predefinita"
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html:176
|
||||
#, fuzzy
|
||||
msgid "Format for all notifications"
|
||||
msgstr "Usa notifica predefinita"
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:9
|
||||
msgid "Text to wait for before triggering a change/notification, all text and regex are tested case-insensitive."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:10
|
||||
msgid "Trigger text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:11
|
||||
msgid "Each line is processed separately (think of each line as \"OR\")"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:12
|
||||
msgid "Note: Wrap in forward slash / to use regex example:"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:23
|
||||
msgid "Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:24
|
||||
msgid "Each line processed separately, any line matching will be ignored (removed before creating the checksum)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:25
|
||||
msgid "Regular Expression support, wrap the entire line in forward slash"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:26
|
||||
msgid "Changing this will affect the comparison checksum which may trigger an alert"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:43
|
||||
msgid "Block change-detection while this text is on the page, all text and regex are tested case-insensitive, good for waiting for when a product is available again"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:44
|
||||
msgid "Block text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:45
|
||||
msgid "All lines here must not exist (think of each line as \"OR\")"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:58
|
||||
msgid "Extracts text in the final output (line by line) after other filters using regular expressions or string match:"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:60
|
||||
msgid "Regular expression - example"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:61
|
||||
msgid "Don't forget to consider the white-space at the start of a line"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:62
|
||||
#, fuzzy
|
||||
msgid "Use"
|
||||
msgstr "Pausa"
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:62
|
||||
msgid "type flags (more"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:62
|
||||
#, fuzzy
|
||||
msgid "information here"
|
||||
msgstr "Nessuna informazione"
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:63
|
||||
msgid "Keyword example - example"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:64
|
||||
msgid "Use groups to extract just that text - example"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:64
|
||||
msgid "returns a list of years only"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:65
|
||||
msgid "Example - match lines containing a keyword"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:68
|
||||
msgid "One line per regular-expression/string match"
|
||||
msgstr ""
|
||||
|
||||
#~ msgid "Entry"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Actions"
|
||||
#~ msgstr "Condizioni"
|
||||
|
||||
#~ msgid "Add a row/rule after"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Remove this row/rule"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Verify this rule against current snapshot"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Error - This watch needs Chrome (with playwright/sockpuppetbrowser), but Chrome based fetching is not enabled."
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Alternatively try our"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "very affordable subscription based service which has all this setup for you"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "You may need to"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Enable playwright environment variable"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "and uncomment the"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "in the"
|
||||
#~ msgstr "Silenzia"
|
||||
|
||||
#~ msgid "file"
|
||||
#~ msgstr "Titolo"
|
||||
|
||||
#~ msgid "Set a hourly/week day schedule"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Schedule time limits"
|
||||
#~ msgstr "Tempo di ricontrollo (minuti)"
|
||||
|
||||
#~ msgid "Business hours"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Weekends"
|
||||
#~ msgstr "Settimane"
|
||||
|
||||
#~ msgid "Reset"
|
||||
#~ msgstr "Richiesta"
|
||||
|
||||
#~ msgid "Warning, one or more of your 'days' has a duration that would extend into the next day."
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "This could have unintended consequences."
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "More help and examples about using the scheduler"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Want to use a time schedule?"
|
||||
#~ msgstr "Usa pianificazione oraria"
|
||||
|
||||
#~ msgid "First confirm/save your Time Zone Settings"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Triggers a change if this text appears, AND something changed in the document."
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Triggered text"
|
||||
#~ msgstr "Ignora testo"
|
||||
|
||||
#~ msgid "Ignored for calculating changes, but still shown."
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Ignored text"
|
||||
#~ msgstr "Ignora testo"
|
||||
|
||||
#~ msgid "No change-detection will occur because this text exists."
|
||||
#~ msgstr "Blocca rilevamento modifiche quando il testo corrisponde"
|
||||
|
||||
#~ msgid "Blocked text"
|
||||
#~ msgstr "Ignora testo"
|
||||
|
||||
#~ msgid "Watch List"
|
||||
#~ msgstr "Lista Monitoraggi"
|
||||
|
||||
#~ msgid "Watches"
|
||||
#~ msgstr "Monitoraggi"
|
||||
|
||||
#~ msgid "Queue Status"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Queue"
|
||||
#~ msgstr "In coda"
|
||||
|
||||
#~ msgid "Sitemap Crawler"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Sitemap"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Search"
|
||||
#~ msgstr "Ricerca in corso"
|
||||
|
||||
#~ msgid "URL or Title"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "in"
|
||||
#~ msgstr "Info"
|
||||
|
||||
#~ msgid "Enter search term..."
|
||||
#~ msgstr ""
|
||||
|
||||
|
||||
Binary file not shown.
@@ -39,11 +39,15 @@ msgid "Incorrect password"
|
||||
msgstr "비밀번호"
|
||||
|
||||
#: changedetectionio/forms.py:63 changedetectionio/forms.py:243
|
||||
msgid "At least one time interval (weeks, days, hours, minutes, or seconds) must be specified."
|
||||
msgid ""
|
||||
"At least one time interval (weeks, days, hours, minutes, or seconds) must"
|
||||
" be specified."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py:64
|
||||
msgid "At least one time interval (weeks, days, hours, minutes, or seconds) must be specified when not using global settings."
|
||||
msgid ""
|
||||
"At least one time interval (weeks, days, hours, minutes, or seconds) must"
|
||||
" be specified when not using global settings."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py:164
|
||||
@@ -172,16 +176,18 @@ msgid "Invalid value."
|
||||
msgstr "값이 잘못되었습니다."
|
||||
|
||||
#: changedetectionio/forms.py:732
|
||||
#, fuzzy
|
||||
msgid "Watch"
|
||||
msgstr "모니터"
|
||||
msgstr "# 시계"
|
||||
|
||||
#: changedetectionio/forms.py:733 changedetectionio/forms.py:766
|
||||
msgid "Processor"
|
||||
msgstr "프로세서"
|
||||
|
||||
#: changedetectionio/forms.py:734
|
||||
#, fuzzy
|
||||
msgid "Edit > Watch"
|
||||
msgstr "편집 > 모니터"
|
||||
msgstr "먼저 편집한 다음 보기"
|
||||
|
||||
#: changedetectionio/forms.py:747 changedetectionio/forms.py:994
|
||||
#, fuzzy
|
||||
@@ -357,7 +363,7 @@ msgstr "구하다"
|
||||
|
||||
#: changedetectionio/forms.py:829
|
||||
msgid "Proxy"
|
||||
msgstr "프록시"
|
||||
msgstr "대리"
|
||||
|
||||
#: changedetectionio/forms.py:831
|
||||
msgid "Send a notification when the filter can no longer be found on the page"
|
||||
@@ -368,7 +374,7 @@ msgstr "페이지에서 필터를 더 이상 찾을 수 없으면 알림 보내
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:59
|
||||
#: changedetectionio/forms.py:832
|
||||
msgid "Notifications"
|
||||
msgstr "알림"
|
||||
msgstr "정보 없음"
|
||||
|
||||
#: changedetectionio/forms.py:832
|
||||
#, fuzzy
|
||||
@@ -376,8 +382,9 @@ msgid "Muted"
|
||||
msgstr "무음"
|
||||
|
||||
#: changedetectionio/forms.py:832
|
||||
#, fuzzy
|
||||
msgid "On"
|
||||
msgstr "켜짐"
|
||||
msgstr "없음"
|
||||
|
||||
#: changedetectionio/forms.py:833
|
||||
msgid "Attach screenshot to notification (where possible)"
|
||||
@@ -420,8 +427,9 @@ msgid "Invalid template syntax in \"%(header)s\" header: %(error)s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py:920 changedetectionio/forms.py:932
|
||||
#, fuzzy
|
||||
msgid "Name"
|
||||
msgstr "이름"
|
||||
msgstr "음소거 해제"
|
||||
|
||||
#: changedetectionio/forms.py:921
|
||||
msgid "Proxy URL"
|
||||
@@ -444,8 +452,9 @@ msgid "Plaintext requests"
|
||||
msgstr "일반 텍스트 요청"
|
||||
|
||||
#: changedetectionio/forms.py:946
|
||||
#, fuzzy
|
||||
msgid "Chrome requests"
|
||||
msgstr "Chrome 요청"
|
||||
msgstr "요구"
|
||||
|
||||
#: changedetectionio/forms.py:952
|
||||
msgid "Default proxy"
|
||||
@@ -502,8 +511,9 @@ msgid "API access token security check enabled"
|
||||
msgstr "API 액세스 토큰 보안 확인이 활성화되었습니다."
|
||||
|
||||
#: changedetectionio/forms.py:989
|
||||
#, fuzzy
|
||||
msgid "Notification base URL override"
|
||||
msgstr "알림 기본 URL"
|
||||
msgstr "알림 경고 수"
|
||||
|
||||
#: changedetectionio/forms.py:993
|
||||
msgid "Treat empty pages as a change?"
|
||||
@@ -620,7 +630,9 @@ msgid "A backup is running!"
|
||||
msgstr "백업이 실행 중입니다!"
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html:13
|
||||
msgid "Here you can download and request a new backup, when a backup is completed you will see it listed below."
|
||||
msgid ""
|
||||
"Here you can download and request a new backup, when a backup is "
|
||||
"completed you will see it listed below."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html:19
|
||||
@@ -633,14 +645,16 @@ msgstr "백업을 찾을 수 없습니다."
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html:28
|
||||
msgid "Create backup"
|
||||
msgstr "백업 생성"
|
||||
msgstr "백업"
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html:30
|
||||
msgid "Remove backups"
|
||||
msgstr "백업 삭제"
|
||||
msgstr "백업"
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py:45
|
||||
msgid "Importing 5,000 of the first URLs from your list, the rest can be imported again."
|
||||
msgid ""
|
||||
"Importing 5,000 of the first URLs from your list, the rest can be "
|
||||
"imported again."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py:78
|
||||
@@ -675,7 +689,9 @@ msgstr ""
|
||||
#: changedetectionio/blueprint/imports/importer.py:214
|
||||
#: changedetectionio/blueprint/imports/importer.py:297
|
||||
#, python-brace-format
|
||||
msgid "Error processing row number {}, check all cell data types are correct, row was skipped."
|
||||
msgid ""
|
||||
"Error processing row number {}, check all cell data types are correct, "
|
||||
"row was skipped."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py:218
|
||||
@@ -701,7 +717,9 @@ msgid ".XLSX & Wachete"
|
||||
msgstr ".XLSX 및 와체테"
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html:20
|
||||
msgid "Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):"
|
||||
msgid ""
|
||||
"Enter one URL per line, and optionally add tags for each URL after a "
|
||||
"space, delineated by comma (,):"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html:22
|
||||
@@ -713,7 +731,9 @@ msgid "URLs which do not pass validation will stay in the textarea."
|
||||
msgstr "유효성 검사를 통과하지 못한 URL은 텍스트 영역에 유지됩니다."
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html:44
|
||||
msgid "Copy and Paste your Distill.io watch 'export' file, this should be a JSON file."
|
||||
msgid ""
|
||||
"Copy and Paste your Distill.io watch 'export' file, this should be a JSON"
|
||||
" file."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html:45
|
||||
@@ -837,7 +857,7 @@ msgstr "일반적인"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html:23
|
||||
msgid "Fetching"
|
||||
msgstr "가져오기"
|
||||
msgstr "수색"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html:24
|
||||
msgid "Global Filters"
|
||||
@@ -865,7 +885,7 @@ msgstr "보안 문자 및 프록시"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html:35
|
||||
msgid "Info"
|
||||
msgstr "정보"
|
||||
msgstr "추가 정보"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html:46
|
||||
msgid "Default recheck time for all watches, current system minimum is"
|
||||
@@ -893,7 +913,7 @@ msgstr "활성화된 플러그인이 없습니다."
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html:405
|
||||
msgid "Back"
|
||||
msgstr "뒤로"
|
||||
msgstr "백업"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html:406
|
||||
msgid "Clear Snapshot History"
|
||||
@@ -1009,12 +1029,14 @@ msgid "Watch group / tag"
|
||||
msgstr "감시 그룹/태그"
|
||||
|
||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html:21
|
||||
msgid "Groups allows you to manage filters and notifications for multiple watches under a single organisational tag."
|
||||
msgid ""
|
||||
"Groups allows you to manage filters and notifications for multiple "
|
||||
"watches under a single organisational tag."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html:31
|
||||
msgid "# Watches"
|
||||
msgstr "# 모니터"
|
||||
msgstr "# 시계"
|
||||
|
||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html:32
|
||||
msgid "Tag / Label name"
|
||||
@@ -1035,7 +1057,9 @@ msgstr "그룹을 삭제하시겠습니까?"
|
||||
|
||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html:59
|
||||
#, python-format
|
||||
msgid "<p>Are you sure you want to delete group <strong>%(title)s</strong>?</p><p>This action cannot be undone.</p>"
|
||||
msgid ""
|
||||
"<p>Are you sure you want to delete group "
|
||||
"<strong>%(title)s</strong>?</p><p>This action cannot be undone.</p>"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html:60
|
||||
@@ -1055,7 +1079,10 @@ msgstr "그룹을 연결 해제하시겠습니까?"
|
||||
|
||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html:67
|
||||
#, python-format
|
||||
msgid "<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>"
|
||||
msgid ""
|
||||
"<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>"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html:68
|
||||
@@ -1176,7 +1203,9 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py:330
|
||||
#, python-brace-format
|
||||
msgid "Could not share, something went wrong while communicating with the share server - {}"
|
||||
msgid ""
|
||||
"Could not share, something went wrong while communicating with the share "
|
||||
"server - {}"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/diff.py:93
|
||||
@@ -1189,7 +1218,9 @@ msgid "No history found for the specified link, bad link?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/diff.py:98
|
||||
msgid "Not enough history (2 snapshots required) to show difference page for this watch."
|
||||
msgid ""
|
||||
"Not enough history (2 snapshots required) to show difference page for "
|
||||
"this watch."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py:35
|
||||
@@ -1238,7 +1269,9 @@ msgid "Watch added."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:12
|
||||
msgid "This will remove version history (snapshots) for ALL watches, but keep your list of URLs!"
|
||||
msgid ""
|
||||
"This will remove version history (snapshots) for ALL watches, but keep "
|
||||
"your list of URLs!"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:13
|
||||
@@ -1255,7 +1288,7 @@ msgstr "먼저 링크하세요."
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:17
|
||||
msgid "Confirmation text"
|
||||
msgstr "확인 텍스트"
|
||||
msgstr "정보 없음"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:27
|
||||
msgid "Type in the word"
|
||||
@@ -1304,11 +1337,11 @@ msgstr "에게"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/diff.html:53
|
||||
msgid "Words"
|
||||
msgstr "단어"
|
||||
msgstr "비밀번호"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/diff.html:57
|
||||
msgid "Lines"
|
||||
msgstr "줄"
|
||||
msgstr "로그인"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/diff.html:61
|
||||
msgid "Ignore Whitespace"
|
||||
@@ -1316,7 +1349,7 @@ msgstr "공백 무시"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/diff.html:65
|
||||
msgid "Same/non-changed"
|
||||
msgstr "동일/변경되지 않음"
|
||||
msgstr "변경됨"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/diff.html:69
|
||||
msgid "Removed"
|
||||
@@ -1420,7 +1453,9 @@ msgstr "공유하거나 무시 목록에 추가할 텍스트를 강조 표시합
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/diff.html:144
|
||||
#: changedetectionio/blueprint/ui/templates/preview.html:80
|
||||
msgid "For now, Differences are performed on text, not graphically, only the latest screenshot is available."
|
||||
msgid ""
|
||||
"For now, Differences are performed on text, not graphically, only the "
|
||||
"latest screenshot is available."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/diff.html:149
|
||||
@@ -1455,7 +1490,7 @@ msgstr "정황"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:60
|
||||
msgid "Stats"
|
||||
msgstr "통계"
|
||||
msgstr "설정"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:73
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:313
|
||||
@@ -1482,7 +1517,9 @@ msgid "Organisational tag/group name used in the main listing page"
|
||||
msgstr "기본 목록 페이지에 사용되는 조직 태그/그룹 이름"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:85
|
||||
msgid "Automatically uses the page title if found, you can also use your own title/description here"
|
||||
msgid ""
|
||||
"Automatically uses the page title if found, you can also use your own "
|
||||
"title/description here"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:95
|
||||
@@ -1490,7 +1527,10 @@ msgid "The interval/amount of time between each check."
|
||||
msgstr "각 확인 사이의 간격/시간입니다."
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:110
|
||||
msgid "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."
|
||||
msgid ""
|
||||
"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."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:123
|
||||
@@ -1502,7 +1542,9 @@ msgid "Basic"
|
||||
msgstr "기초적인"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:123
|
||||
msgid "method (default) where your watched site doesn't need Javascript to render."
|
||||
msgid ""
|
||||
"method (default) where your watched site doesn't need Javascript to "
|
||||
"render."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:124
|
||||
@@ -1514,7 +1556,9 @@ msgid "Chrome/Javascript"
|
||||
msgstr "크롬/자바스크립트"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:124
|
||||
msgid "method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'."
|
||||
msgid ""
|
||||
"method requires a network connection to a running WebDriver+Chrome "
|
||||
"server, set by the ENV var 'WEBDRIVER_URL'."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:125
|
||||
@@ -1530,7 +1574,9 @@ msgid "Choose a proxy for this watch"
|
||||
msgstr "이 시계에 대한 RSS 피드"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:143
|
||||
msgid "If you're having trouble waiting for the page to be fully rendered (text missing etc), try increasing the 'wait' time here."
|
||||
msgid ""
|
||||
"If you're having trouble waiting for the page to be fully rendered (text "
|
||||
"missing etc), try increasing the 'wait' time here."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:145
|
||||
@@ -1551,7 +1597,9 @@ msgid "Show advanced options"
|
||||
msgstr "고급 옵션 표시"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:157
|
||||
msgid "Run this code before performing change detection, handy for filling in fields and other actions"
|
||||
msgid ""
|
||||
"Run this code before performing change detection, handy for filling in "
|
||||
"fields and other actions"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:158
|
||||
@@ -1608,7 +1656,9 @@ msgid "Visual Selector data is not ready, watch needs to be checked atleast once
|
||||
msgstr "시각적 선택기 데이터가 준비되지 않았습니다. 시계를 한 번 이상 확인해야 합니다."
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:253
|
||||
msgid "Sorry, this functionality only works with fetchers that support interactive Javascript (so far only Playwright based fetchers)"
|
||||
msgid ""
|
||||
"Sorry, this functionality only works with fetchers that support "
|
||||
"interactive Javascript (so far only Playwright based fetchers)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:254
|
||||
@@ -1626,7 +1676,9 @@ msgid "to one that supports interactive Javascript."
|
||||
msgstr "대화형 Javascript를 지원하는 것입니다."
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:297
|
||||
msgid "Use the verify (✓) button to test if a condition passes against the current snapshot."
|
||||
msgid ""
|
||||
"Use the verify (✓) button to test if a condition passes against the "
|
||||
"current snapshot."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:298
|
||||
@@ -1654,7 +1706,9 @@ msgid "Limit trigger/ignore/block/extract to;"
|
||||
msgstr "트리거/무시/차단/추출을 다음으로 제한합니다."
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:326
|
||||
msgid "Note: Depending on the length and similarity of the text on each line, the algorithm may consider an"
|
||||
msgid ""
|
||||
"Note: Depending on the length and similarity of the text on each line, "
|
||||
"the algorithm may consider an"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:326
|
||||
@@ -1695,11 +1749,16 @@ msgid "Only trigger when unique lines appear"
|
||||
msgstr "고유한 줄이 나타날 때만 트리거됩니다."
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:332
|
||||
msgid "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."
|
||||
msgid ""
|
||||
"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."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:340
|
||||
msgid "Helps reduce changes detected caused by sites shuffling lines around, combine with"
|
||||
msgid ""
|
||||
"Helps reduce changes detected caused by sites shuffling lines around, "
|
||||
"combine with"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:340
|
||||
@@ -1729,7 +1788,9 @@ msgid "text"
|
||||
msgstr "텍스트"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:386
|
||||
msgid "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"
|
||||
msgid ""
|
||||
"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"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:386
|
||||
@@ -1769,7 +1830,9 @@ msgid "Currently:"
|
||||
msgstr "현재:"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:423
|
||||
msgid "Sorry, this functionality only works with fetchers that support Javascript and screenshots (such as playwright etc)."
|
||||
msgid ""
|
||||
"Sorry, this functionality only works with fetchers that support "
|
||||
"Javascript and screenshots (such as playwright etc)."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:424
|
||||
@@ -1825,7 +1888,9 @@ msgid "Are you sure you want to clear all history for:"
|
||||
msgstr "정말로 다음의 기록을 모두 지우시겠습니까?"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:496
|
||||
msgid "This will remove all snapshots and previous versions. This action cannot be undone."
|
||||
msgid ""
|
||||
"This will remove all snapshots and previous versions. This action cannot "
|
||||
"be undone."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html:497
|
||||
@@ -1849,7 +1914,9 @@ msgid "Current erroring screenshot from most recent request"
|
||||
msgstr "가장 최근 요청의 현재 오류 스크린샷"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/preview.html:91
|
||||
msgid "Screenshot requires a Content Fetcher ( Sockpuppetbrowser, selenium, etc ) that supports screenshots."
|
||||
msgid ""
|
||||
"Screenshot requires a Content Fetcher ( Sockpuppetbrowser, selenium, etc "
|
||||
") that supports screenshots."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:31
|
||||
@@ -1858,11 +1925,11 @@ msgstr "새로운 웹 페이지 변경 감지 감시 추가"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:34
|
||||
msgid "Watch this URL!"
|
||||
msgstr "이 URL 모니터!"
|
||||
msgstr "이 URL을 시청하세요!"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:35
|
||||
msgid "Edit first then Watch"
|
||||
msgstr "편집 후 모니터"
|
||||
msgstr "먼저 편집한 다음 보기"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:45
|
||||
msgid "Create a shareable link"
|
||||
@@ -1918,7 +1985,9 @@ msgid "Clear Histories"
|
||||
msgstr "기록 지우기"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:66
|
||||
msgid "<p>Are you sure you want to clear history for the selected items?</p><p>This action cannot be undone.</p>"
|
||||
msgid ""
|
||||
"<p>Are you sure you want to clear history for the selected "
|
||||
"items?</p><p>This action cannot be undone.</p>"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:67
|
||||
@@ -1934,7 +2003,9 @@ msgid "Delete Watches?"
|
||||
msgstr "시계를 삭제하시겠습니까?"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:72
|
||||
msgid "<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>"
|
||||
msgid ""
|
||||
"<p>Are you sure you want to delete the selected "
|
||||
"watches?</strong></p><p>This action cannot be undone.</p>"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:78
|
||||
@@ -1967,7 +2038,7 @@ msgid "Changed"
|
||||
msgstr "변경됨"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:130
|
||||
msgid "No web page change detection watches configured, please add a URL in the box above, or"
|
||||
msgid "No website watches configured, please add a URL in the box above, or"
|
||||
msgstr "구성된 웹사이트 시계가 없습니다. 위 상자에 URL을 추가하세요. 또는"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:130
|
||||
@@ -2005,7 +2076,7 @@ msgstr "대기 중"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:250
|
||||
msgid "History"
|
||||
msgstr "기록"
|
||||
msgstr "역사"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:251
|
||||
msgid "Preview"
|
||||
@@ -2191,7 +2262,7 @@ msgstr "설정"
|
||||
|
||||
#: changedetectionio/templates/base.html:83
|
||||
msgid "IMPORT"
|
||||
msgstr "가져오기"
|
||||
msgstr "수입"
|
||||
|
||||
#: changedetectionio/templates/base.html:86
|
||||
msgid "BACKUPS"
|
||||
@@ -2234,7 +2305,9 @@ msgid "Select Language"
|
||||
msgstr "언어 선택"
|
||||
|
||||
#: changedetectionio/templates/base.html:270
|
||||
msgid "Language support is in beta, please help us improve by opening a PR on GitHub with any updates."
|
||||
msgid ""
|
||||
"Language support is in beta, please help us improve by opening a PR on "
|
||||
"GitHub with any updates."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/login.html:10
|
||||
@@ -2245,162 +2318,6 @@ msgstr "비밀번호"
|
||||
msgid "Login"
|
||||
msgstr "로그인"
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py:18
|
||||
#: changedetectionio/widgets/ternary_boolean.py:72
|
||||
msgid "Yes"
|
||||
msgstr "예"
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py:19
|
||||
#: changedetectionio/widgets/ternary_boolean.py:73
|
||||
msgid "No"
|
||||
msgstr "아니오"
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py:20
|
||||
#: changedetectionio/widgets/ternary_boolean.py:74
|
||||
msgid "Main settings"
|
||||
msgstr "기본 설정"
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html:11
|
||||
msgid "Show token/placeholders"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html:18
|
||||
msgid "Token"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html:19
|
||||
#, fuzzy
|
||||
msgid "Description"
|
||||
msgstr "덧셈"
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html:128
|
||||
#, fuzzy
|
||||
msgid "Show advanced help and tips"
|
||||
msgstr "고급 옵션 표시"
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html:138
|
||||
#, fuzzy
|
||||
msgid "Send test notification"
|
||||
msgstr "기본 알림 사용"
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html:140
|
||||
msgid "Add email"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html:140
|
||||
msgid "Add an email address"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html:142
|
||||
#, fuzzy
|
||||
msgid "Notification debug logs"
|
||||
msgstr "알림 디버그 로그"
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html:144
|
||||
#, fuzzy
|
||||
msgid "Processing.."
|
||||
msgstr "프로세서"
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html:151
|
||||
#, fuzzy
|
||||
msgid "Title for all notifications"
|
||||
msgstr "기본 알림 사용"
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html:176
|
||||
#, fuzzy
|
||||
msgid "Format for all notifications"
|
||||
msgstr "기본 알림 사용"
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:9
|
||||
msgid "Text to wait for before triggering a change/notification, all text and regex are tested case-insensitive."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:10
|
||||
msgid "Trigger text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:11
|
||||
msgid "Each line is processed separately (think of each line as \"OR\")"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:12
|
||||
msgid "Note: Wrap in forward slash / to use regex example:"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:23
|
||||
msgid "Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:24
|
||||
msgid "Each line processed separately, any line matching will be ignored (removed before creating the checksum)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:25
|
||||
msgid "Regular Expression support, wrap the entire line in forward slash"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:26
|
||||
msgid "Changing this will affect the comparison checksum which may trigger an alert"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:43
|
||||
msgid "Block change-detection while this text is on the page, all text and regex are tested case-insensitive, good for waiting for when a product is available again"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:44
|
||||
msgid "Block text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:45
|
||||
msgid "All lines here must not exist (think of each line as \"OR\")"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:58
|
||||
msgid "Extracts text in the final output (line by line) after other filters using regular expressions or string match:"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:60
|
||||
msgid "Regular expression - example"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:61
|
||||
msgid "Don't forget to consider the white-space at the start of a line"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:62
|
||||
#, fuzzy
|
||||
msgid "Use"
|
||||
msgstr "정지시키다"
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:62
|
||||
msgid "type flags (more"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:62
|
||||
#, fuzzy
|
||||
msgid "information here"
|
||||
msgstr "정보 없음"
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:63
|
||||
msgid "Keyword example - example"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:64
|
||||
msgid "Use groups to extract just that text - example"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:64
|
||||
msgid "returns a list of years only"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:65
|
||||
msgid "Example - match lines containing a keyword"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:68
|
||||
msgid "One line per regular-expression/string match"
|
||||
msgstr ""
|
||||
|
||||
#~ msgid "Invalid time format. Use HH:MM."
|
||||
#~ msgstr "시간 형식이 잘못되었습니다. HH:MM을 사용하세요."
|
||||
|
||||
@@ -2782,7 +2699,9 @@ msgstr ""
|
||||
#~ msgid "Visual / Image screenshot change detection"
|
||||
#~ msgstr "시각적/이미지 스크린샷 변경 감지"
|
||||
|
||||
#~ msgid "Compares screenshots using fast OpenCV algorithm, 10-100x faster than SSIM"
|
||||
#~ msgid ""
|
||||
#~ "Compares screenshots using fast OpenCV "
|
||||
#~ "algorithm, 10-100x faster than SSIM"
|
||||
#~ msgstr "SSIM보다 10~100배 빠른 빠른 OpenCV 알고리즘을 사용하여 스크린샷을 비교합니다."
|
||||
|
||||
#~ msgid "Visual"
|
||||
@@ -2803,120 +2722,3 @@ msgstr ""
|
||||
#~ msgid "Detects all text changes where possible"
|
||||
#~ msgstr "가능한 경우 모든 텍스트 변경 사항을 감지합니다."
|
||||
|
||||
#~ msgid "Entry"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Actions"
|
||||
#~ msgstr "정황"
|
||||
|
||||
#~ msgid "Add a row/rule after"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Remove this row/rule"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Verify this rule against current snapshot"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Error - This watch needs Chrome (with playwright/sockpuppetbrowser), but Chrome based fetching is not enabled."
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Alternatively try our"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "very affordable subscription based service which has all this setup for you"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "You may need to"
|
||||
#~ msgstr "당신은"
|
||||
|
||||
#~ msgid "Enable playwright environment variable"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "and uncomment the"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "in the"
|
||||
#~ msgstr "그만큼"
|
||||
|
||||
#~ msgid "file"
|
||||
#~ msgstr "제목"
|
||||
|
||||
#~ msgid "Set a hourly/week day schedule"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Schedule time limits"
|
||||
#~ msgstr "재확인 시간(분)"
|
||||
|
||||
#~ msgid "Business hours"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Weekends"
|
||||
#~ msgstr "주"
|
||||
|
||||
#~ msgid "Reset"
|
||||
#~ msgstr "요구"
|
||||
|
||||
#~ msgid "Warning, one or more of your 'days' has a duration that would extend into the next day."
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "This could have unintended consequences."
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "More help and examples about using the scheduler"
|
||||
#~ msgstr "여기에 더 많은 도움말과 예시가 있습니다."
|
||||
|
||||
#~ msgid "Want to use a time schedule?"
|
||||
#~ msgstr "시간 스케줄러 사용"
|
||||
|
||||
#~ msgid "First confirm/save your Time Zone Settings"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Triggers a change if this text appears, AND something changed in the document."
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Triggered text"
|
||||
#~ msgstr "오류 텍스트"
|
||||
|
||||
#~ msgid "Ignored for calculating changes, but still shown."
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Ignored text"
|
||||
#~ msgstr "오류 텍스트"
|
||||
|
||||
#~ msgid "No change-detection will occur because this text exists."
|
||||
#~ msgstr "텍스트가 일치하는 동안 변경 감지 차단"
|
||||
|
||||
#~ msgid "Blocked text"
|
||||
#~ msgstr "오류 텍스트"
|
||||
|
||||
#~ msgid "Watch List"
|
||||
#~ msgstr "모니터 목록"
|
||||
|
||||
#~ msgid "Watches"
|
||||
#~ msgstr "모니터"
|
||||
|
||||
#~ msgid "Queue Status"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Queue"
|
||||
#~ msgstr "대기 중"
|
||||
|
||||
#~ msgid "Sitemap Crawler"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Sitemap"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Search"
|
||||
#~ msgstr "수색"
|
||||
|
||||
#~ msgid "URL or Title"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "in"
|
||||
#~ msgstr "추가 정보"
|
||||
|
||||
#~ msgid "Enter search term..."
|
||||
#~ msgstr ""
|
||||
|
||||
|
||||
@@ -1988,7 +1988,7 @@ msgid "Changed"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:130
|
||||
msgid "No web page change detection watches configured, please add a URL in the box above, or"
|
||||
msgid "No website watches configured, please add a URL in the box above, or"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:130
|
||||
@@ -2265,150 +2265,3 @@ msgstr ""
|
||||
msgid "Login"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py:18
|
||||
#: changedetectionio/widgets/ternary_boolean.py:72
|
||||
msgid "Yes"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py:19
|
||||
#: changedetectionio/widgets/ternary_boolean.py:73
|
||||
msgid "No"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py:20
|
||||
#: changedetectionio/widgets/ternary_boolean.py:74
|
||||
msgid "Main settings"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html:11
|
||||
msgid "Show token/placeholders"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html:18
|
||||
msgid "Token"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html:19
|
||||
msgid "Description"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html:128
|
||||
msgid "Show advanced help and tips"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html:138
|
||||
msgid "Send test notification"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html:140
|
||||
msgid "Add email"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html:140
|
||||
msgid "Add an email address"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html:142
|
||||
msgid "Notification debug logs"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html:144
|
||||
msgid "Processing.."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html:151
|
||||
msgid "Title for all notifications"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/_common_fields.html:176
|
||||
msgid "Format for all notifications"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:9
|
||||
msgid "Text to wait for before triggering a change/notification, all text and regex are tested case-insensitive."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:10
|
||||
msgid "Trigger text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:11
|
||||
msgid "Each line is processed separately (think of each line as \"OR\")"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:12
|
||||
msgid "Note: Wrap in forward slash / to use regex example:"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:23
|
||||
msgid "Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:24
|
||||
msgid "Each line processed separately, any line matching will be ignored (removed before creating the checksum)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:25
|
||||
msgid "Regular Expression support, wrap the entire line in forward slash"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:26
|
||||
msgid "Changing this will affect the comparison checksum which may trigger an alert"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:43
|
||||
msgid "Block change-detection while this text is on the page, all text and regex are tested case-insensitive, good for waiting for when a product is available again"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:44
|
||||
msgid "Block text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:45
|
||||
msgid "All lines here must not exist (think of each line as \"OR\")"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:58
|
||||
msgid "Extracts text in the final output (line by line) after other filters using regular expressions or string match:"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:60
|
||||
msgid "Regular expression - example"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:61
|
||||
msgid "Don't forget to consider the white-space at the start of a line"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:62
|
||||
msgid "Use"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:62
|
||||
msgid "type flags (more"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:62
|
||||
msgid "information here"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:63
|
||||
msgid "Keyword example - example"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:64
|
||||
msgid "Use groups to extract just that text - example"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:64
|
||||
msgid "returns a list of years only"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:65
|
||||
msgid "Example - match lines containing a keyword"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html:68
|
||||
msgid "One line per regular-expression/string match"
|
||||
msgstr ""
|
||||
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
+954
-578
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
from wtforms import Field
|
||||
from wtforms import widgets
|
||||
from markupsafe import Markup
|
||||
from flask_babel import lazy_gettext as _l
|
||||
|
||||
class TernaryNoneBooleanWidget:
|
||||
"""
|
||||
@@ -14,9 +14,9 @@ class TernaryNoneBooleanWidget:
|
||||
boolean_mode = getattr(field, 'boolean_mode', False)
|
||||
|
||||
# Get custom text or use defaults
|
||||
yes_text = getattr(field, 'yes_text', _l('Yes'))
|
||||
no_text = getattr(field, 'no_text', _l('No'))
|
||||
none_text = getattr(field, 'none_text', _l('Main settings'))
|
||||
yes_text = getattr(field, 'yes_text', 'Yes')
|
||||
no_text = getattr(field, 'no_text', 'No')
|
||||
none_text = getattr(field, 'none_text', 'Main settings')
|
||||
|
||||
# True option
|
||||
checked_true = ' checked' if field.data is True else ''
|
||||
@@ -63,14 +63,14 @@ class TernaryNoneBooleanField(Field):
|
||||
"""
|
||||
widget = TernaryNoneBooleanWidget()
|
||||
|
||||
def __init__(self, label=None, validators=None, false_values=None, boolean_mode=False,
|
||||
yes_text=None, no_text=None, none_text=None, **kwargs):
|
||||
def __init__(self, label=None, validators=None, false_values=None, boolean_mode=False,
|
||||
yes_text="Yes", no_text="No", none_text="Main settings", **kwargs):
|
||||
super(TernaryNoneBooleanField, self).__init__(label, validators, **kwargs)
|
||||
|
||||
self.boolean_mode = boolean_mode
|
||||
self.yes_text = yes_text if yes_text is not None else _l('Yes')
|
||||
self.no_text = no_text if no_text is not None else _l('No')
|
||||
self.none_text = none_text if none_text is not None else _l('Main settings')
|
||||
self.yes_text = yes_text
|
||||
self.no_text = no_text
|
||||
self.none_text = none_text
|
||||
|
||||
if false_values is None:
|
||||
self.false_values = {'false', ''}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user