mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-06-26 10:30:47 +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 |
@@ -11,7 +11,6 @@ recursive-include changedetectionio/realtime *
|
|||||||
recursive-include changedetectionio/static *
|
recursive-include changedetectionio/static *
|
||||||
recursive-include changedetectionio/templates *
|
recursive-include changedetectionio/templates *
|
||||||
recursive-include changedetectionio/tests *
|
recursive-include changedetectionio/tests *
|
||||||
recursive-include changedetectionio/translations *
|
|
||||||
recursive-include changedetectionio/widgets *
|
recursive-include changedetectionio/widgets *
|
||||||
prune changedetectionio/static/package-lock.json
|
prune changedetectionio/static/package-lock.json
|
||||||
prune changedetectionio/static/styles/node_modules
|
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
|
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
|
## Filters
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
|
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
|
||||||
# Semver means never use .01, or 00. Should be .1.
|
# Semver means never use .01, or 00. Should be .1.
|
||||||
__version__ = '0.52.4'
|
__version__ = '0.51.4'
|
||||||
|
|
||||||
from changedetectionio.strtobool import strtobool
|
from changedetectionio.strtobool import strtobool
|
||||||
from json.decoder import JSONDecodeError
|
from json.decoder import JSONDecodeError
|
||||||
|
|||||||
@@ -64,17 +64,8 @@ class Watch(Resource):
|
|||||||
@validate_openapi_request('getWatch')
|
@validate_openapi_request('getWatch')
|
||||||
def get(self, uuid):
|
def get(self, uuid):
|
||||||
"""Get information about a single watch, recheck, pause, or mute."""
|
"""Get information about a single watch, recheck, pause, or mute."""
|
||||||
import time
|
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
watch = None
|
watch = deepcopy(self.datastore.data['watching'].get(uuid))
|
||||||
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)
|
|
||||||
|
|
||||||
if not watch:
|
if not watch:
|
||||||
abort(404, message='No watch exists with the UUID of {}'.format(uuid))
|
abort(404, message='No watch exists with the UUID of {}'.format(uuid))
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from blinker import signal
|
from blinker import signal
|
||||||
|
|
||||||
from .processors.exceptions import ProcessorException
|
from .processors.exceptions import ProcessorException
|
||||||
import changedetectionio.content_fetchers.exceptions as content_fetchers_exceptions
|
import changedetectionio.content_fetchers.exceptions as content_fetchers_exceptions
|
||||||
from changedetectionio.processors.text_json_diff.processor import FilterNotFoundInResponse
|
from changedetectionio.processors.text_json_diff.processor import FilterNotFoundInResponse
|
||||||
@@ -8,6 +9,7 @@ from changedetectionio.flask_app import watch_check_update
|
|||||||
import asyncio
|
import asyncio
|
||||||
import importlib
|
import importlib
|
||||||
import os
|
import os
|
||||||
|
import queue
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -15,48 +17,36 @@ from loguru import logger
|
|||||||
# Async version of update_worker
|
# Async version of update_worker
|
||||||
# Processes jobs from AsyncSignalPriorityQueue instead of threaded queue
|
# Processes jobs from AsyncSignalPriorityQueue instead of threaded queue
|
||||||
|
|
||||||
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.
|
Async worker function that processes watch check jobs from the queue.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
worker_id: Unique identifier for this worker
|
worker_id: Unique identifier for this worker
|
||||||
q: AsyncSignalPriorityQueue containing jobs to process
|
q: AsyncSignalPriorityQueue containing jobs to process
|
||||||
notification_q: Standard queue for notifications
|
notification_q: Standard queue for notifications
|
||||||
app: Flask application instance
|
app: Flask application instance
|
||||||
datastore: Application datastore
|
datastore: Application datastore
|
||||||
executor: ThreadPoolExecutor for queue operations (optional)
|
|
||||||
"""
|
"""
|
||||||
# Set a descriptive name for this task
|
# Set a descriptive name for this task
|
||||||
task = asyncio.current_task()
|
task = asyncio.current_task()
|
||||||
if task:
|
if task:
|
||||||
task.set_name(f"async-worker-{worker_id}")
|
task.set_name(f"async-worker-{worker_id}")
|
||||||
|
|
||||||
logger.info(f"Starting async worker {worker_id}")
|
logger.info(f"Starting async worker {worker_id}")
|
||||||
|
|
||||||
while not app.config.exit.is_set():
|
while not app.config.exit.is_set():
|
||||||
update_handler = None
|
update_handler = None
|
||||||
watch = None
|
watch = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Use sync interface via run_in_executor since each worker has its own event loop
|
# Use native janus async interface - no threads needed!
|
||||||
loop = asyncio.get_event_loop()
|
queued_item_data = await asyncio.wait_for(q.async_get(), timeout=1.0)
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
# No jobs available, continue loop
|
# No jobs available, continue loop
|
||||||
continue
|
continue
|
||||||
except Exception as e:
|
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}")
|
logger.critical(f"CRITICAL: Worker {worker_id} failed to get queue item: {type(e).__name__}: {e}")
|
||||||
|
|
||||||
# Log queue health for debugging
|
# Log queue health for debugging
|
||||||
@@ -424,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)}"})
|
datastore.update_watch(uuid=uuid, update_obj={'last_error': f"Worker error: {str(e)}"})
|
||||||
|
|
||||||
finally:
|
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
|
# Always cleanup - this runs whether there was an exception or not
|
||||||
if uuid:
|
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:
|
try:
|
||||||
# Mark UUID as no longer being processed by this worker
|
# Mark UUID as no longer being processed by this worker
|
||||||
worker_handler.set_uuid_processing(uuid, worker_id=worker_id, processing=False)
|
worker_handler.set_uuid_processing(uuid, worker_id=worker_id, processing=False)
|
||||||
@@ -469,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")
|
logger.debug(f"Worker {worker_id} completed watch {uuid} in {time.time()-fetch_start_time:.2f}s")
|
||||||
except Exception as cleanup_error:
|
except Exception as cleanup_error:
|
||||||
logger.error(f"Worker {worker_id} error during cleanup: {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)
|
# Brief pause before continuing to avoid tight error loops (only on error)
|
||||||
if 'e' in locals():
|
if 'e' in locals():
|
||||||
await asyncio.sleep(1.0)
|
await asyncio.sleep(1.0)
|
||||||
|
|||||||
@@ -92,12 +92,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
|||||||
|
|
||||||
# Be sure we're written fresh
|
# Be sure we're written fresh
|
||||||
datastore.sync_to_json()
|
datastore.sync_to_json()
|
||||||
zip_thread = threading.Thread(
|
zip_thread = threading.Thread(target=create_backup, args=(datastore.datastore_path, datastore.data.get("watching")))
|
||||||
target=create_backup,
|
|
||||||
args=(datastore.datastore_path, datastore.data.get("watching")),
|
|
||||||
daemon=True,
|
|
||||||
name="BackupCreator"
|
|
||||||
)
|
|
||||||
zip_thread.start()
|
zip_thread.start()
|
||||||
backup_threads.append(zip_thread)
|
backup_threads.append(zip_thread)
|
||||||
flash(gettext("Backup building in background, check back in a few minutes."))
|
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
|
from loguru import logger
|
||||||
|
|
||||||
browsersteps_sessions = {}
|
browsersteps_sessions = {}
|
||||||
browsersteps_watch_to_session = {} # Maps watch_uuid -> browsersteps_session_id
|
|
||||||
io_interface_context = None
|
io_interface_context = None
|
||||||
import json
|
import json
|
||||||
import hashlib
|
import hashlib
|
||||||
from flask import Response
|
from flask import Response
|
||||||
import asyncio
|
import asyncio
|
||||||
import threading
|
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):
|
def run_async_in_browser_loop(coro):
|
||||||
"""Run async coroutine using the dedicated browser steps event loop"""
|
"""Run async coroutine using the existing async worker event loop"""
|
||||||
_ensure_browser_steps_loop()
|
from changedetectionio import worker_handler
|
||||||
|
|
||||||
if _browser_steps_loop and not _browser_steps_loop.is_closed():
|
# Use the existing async worker event loop instead of creating a new one
|
||||||
logger.debug("Browser steps using dedicated event loop")
|
if worker_handler.USE_ASYNC_WORKERS and worker_handler.async_loop and not worker_handler.async_loop.is_closed():
|
||||||
future = asyncio.run_coroutine_threadsafe(coro, _browser_steps_loop)
|
logger.debug("Browser steps using existing async worker event loop")
|
||||||
|
future = asyncio.run_coroutine_threadsafe(coro, worker_handler.async_loop)
|
||||||
return future.result()
|
return future.result()
|
||||||
else:
|
else:
|
||||||
raise RuntimeError("Browser steps event loop is not available")
|
# Fallback: create a new event loop (for sync workers or if async loop not available)
|
||||||
|
logger.debug("Browser steps creating temporary event loop")
|
||||||
def cleanup_expired_sessions():
|
loop = asyncio.new_event_loop()
|
||||||
"""Remove expired browsersteps sessions and cleanup their resources"""
|
asyncio.set_event_loop(loop)
|
||||||
global browsersteps_sessions, browsersteps_watch_to_session
|
try:
|
||||||
|
return loop.run_until_complete(coro)
|
||||||
expired_session_ids = []
|
finally:
|
||||||
|
loop.close()
|
||||||
# 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()
|
|
||||||
|
|
||||||
def construct_blueprint(datastore: ChangeDetectionStore):
|
def construct_blueprint(datastore: ChangeDetectionStore):
|
||||||
browser_steps_blueprint = Blueprint('browser_steps', __name__, template_folder="templates")
|
browser_steps_blueprint = Blueprint('browser_steps', __name__, template_folder="templates")
|
||||||
@@ -246,9 +123,6 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
|||||||
if not watch_uuid:
|
if not watch_uuid:
|
||||||
return make_response('No Watch UUID specified', 500)
|
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("Starting connection with playwright")
|
||||||
logger.debug("browser_steps.py connecting")
|
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(
|
browsersteps_sessions[browsersteps_session_id] = run_async_in_browser_loop(
|
||||||
start_browsersteps_session(watch_uuid)
|
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:
|
except Exception as e:
|
||||||
if 'ECONNREFUSED' in str(e):
|
if 'ECONNREFUSED' in str(e):
|
||||||
return make_response('Unable to start the Playwright Browser session, is sockpuppetbrowser running? Network configuration is OK?', 401)
|
return make_response('Unable to start the Playwright Browser session, is sockpuppetbrowser running? Network configuration is OK?', 401)
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ def construct_single_watch_routes(rss_blueprint, datastore):
|
|||||||
if len(dates) < 2:
|
if len(dates) < 2:
|
||||||
return f"Watch {uuid} does not have enough history snapshots to show changes (need at least 2)", 400
|
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)
|
# Get the number of diffs to include (default: 5)
|
||||||
rss_diff_length = datastore.data['settings']['application'].get('rss_diff_length', 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)
|
date_index_from, date_index_to)
|
||||||
|
|
||||||
# Create and populate feed entry
|
# Create and populate feed entry
|
||||||
guid = f"{uuid}/{timestamp_to}"
|
guid = f"{watch['uuid']}/{timestamp_to}"
|
||||||
fe = fg.add_entry()
|
fe = fg.add_entry()
|
||||||
title_suffix = f"Change @ {res['original_context']['change_datetime']}"
|
title_suffix = f"Change @ {res['original_context']['change_datetime']}"
|
||||||
populate_feed_entry(fe, watch, res.get('body', ''), guid, timestamp_to,
|
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
|
# Only include unviewed watches
|
||||||
if not watch.viewed:
|
if not watch.viewed:
|
||||||
# Include a link to the diff page (use uuid from loop, don't modify watch dict)
|
# Add uuid to watch for proper functioning
|
||||||
diff_link = {'href': url_for('ui.ui_diff.diff_history_page', uuid=uuid, _external=True)}
|
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
|
# Get watch label
|
||||||
watch_label = get_watch_label(datastore, watch)
|
watch_label = get_watch_label(datastore, watch)
|
||||||
|
|||||||
@@ -85,11 +85,9 @@
|
|||||||
|
|
||||||
<div class="tab-pane-inner" id="notifications">
|
<div class="tab-pane-inner" id="notifications">
|
||||||
<fieldset>
|
<fieldset>
|
||||||
{{ render_common_settings_form(form.application.form, emailprefix, settings_application, extra_notification_token_placeholder_info) }}
|
<div class="field-group">
|
||||||
</fieldset>
|
{{ render_common_settings_form(form.application.form, emailprefix, settings_application, extra_notification_token_placeholder_info) }}
|
||||||
<fieldset class="pure-group">
|
</div>
|
||||||
{{ render_checkbox_field(form.application.form.notification_html_word_diff_enabled) }}
|
|
||||||
<span class="pure-form-message-inline">HTML notifications - Use "word by word" difference where possible.</span>
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<div class="pure-control-group" id="notification-base-url">
|
<div class="pure-control-group" id="notification-base-url">
|
||||||
{{ render_field(form.application.form.base_url, class="m-d") }}
|
{{ render_field(form.application.form.base_url, class="m-d") }}
|
||||||
@@ -130,7 +128,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
{{ render_field(form.requests.form.timeout) }}
|
{{ 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>
|
||||||
<div class="pure-control-group inline-radio">
|
<div class="pure-control-group inline-radio">
|
||||||
{{ render_field(form.requests.form.default_ua) }}
|
{{ render_field(form.requests.form.default_ua) }}
|
||||||
@@ -221,7 +219,7 @@ nav
|
|||||||
<a id="chrome-extension-link"
|
<a id="chrome-extension-link"
|
||||||
title="Try our new Chrome Extension!"
|
title="Try our new Chrome Extension!"
|
||||||
href="https://chromewebstore.google.com/detail/changedetectionio-website/kefcfmgmlhmankjmnbijimhofdjekbop">
|
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
|
Chrome Webstore
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
@@ -262,14 +260,14 @@ nav
|
|||||||
Ensure the settings below are correct, they are used to manage the time schedule for checking your web page watches.
|
Ensure the settings below are correct, they are used to manage the time schedule for checking your web page watches.
|
||||||
</div>
|
</div>
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
<p><strong>UTC Time & Date from Server:</strong> <span id="utc-time" >{{ utc_time }}</span></p>
|
<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><strong>Local Time & Date in Browser:</strong> <span class="local-time" data-utc="{{ utc_time }}"></span></p>
|
||||||
<div>
|
<p>
|
||||||
{{ render_field(form.application.form.scheduler_timezone_default) }}
|
{{ render_field(form.application.form.scheduler_timezone_default) }}
|
||||||
<datalist id="timezones" style="display: none;">
|
<datalist id="timezones" style="display: none;">
|
||||||
{%- for timezone in available_timezones -%}<option value="{{ timezone }}">{{ timezone }}</option>{%- endfor -%}
|
{%- for timezone in available_timezones -%}<option value="{{ timezone }}">{{ timezone }}</option>{%- endfor -%}
|
||||||
</datalist>
|
</datalist>
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tab-pane-inner" id="ui-options">
|
<div class="tab-pane-inner" id="ui-options">
|
||||||
@@ -338,7 +336,7 @@ nav
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="pure-control-group" id="extra-proxies-setting">
|
||||||
{{ render_fieldlist_with_inline_errors(form.requests.form.extra_proxies) }}
|
{{ 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>{{ "{:,}".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 class="title-col inline"> <a href="{{url_for('watchlist.index', tag=uuid) }}">{{ tag.title }}</a></td>
|
||||||
<td>
|
<td>
|
||||||
<a class="pure-button pure-button-primary" href="{{ url_for('tags.form_tag_edit', uuid=uuid) }}">{{ _('Edit') }}</a>
|
<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 button-error"
|
<a class="pure-button button-error"
|
||||||
href="{{ url_for('tags.delete', uuid=uuid) }}"
|
href="{{ url_for('tags.delete', uuid=uuid) }}"
|
||||||
data-requires-confirm
|
data-requires-confirm
|
||||||
|
|||||||
@@ -238,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])
|
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."))
|
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
|
# 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
|
# But in the case something is added we should save straight away
|
||||||
datastore.needs_write_urgent = True
|
datastore.needs_write_urgent = True
|
||||||
@@ -332,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)
|
'url': url_for('rss.rss_single_watch', uuid=watch['uuid'], token=app_rss_token)
|
||||||
},
|
},
|
||||||
'settings_application': datastore.data['settings']['application'],
|
'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),
|
'ui_edit_stats_extras': collect_ui_edit_stats_extras(watch),
|
||||||
'visual_selector_data_ready': datastore.visualselector_data_is_ready(watch_uuid=uuid),
|
'visual_selector_data_ready': datastore.visualselector_data_is_ready(watch_uuid=uuid),
|
||||||
'timezone_default_config': datastore.data['settings']['application'].get('scheduler_timezone_default'),
|
'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)
|
sent_obj = process_notification(n_object, datastore)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(e)
|
|
||||||
e_str = str(e)
|
e_str = str(e)
|
||||||
# Remove this text which is not important and floods the container
|
# Remove this text which is not important and floods the container
|
||||||
e_str = e_str.replace(
|
e_str = e_str.replace(
|
||||||
|
|||||||
@@ -87,7 +87,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</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>
|
<a id="jump-next-diff" title="{{ _('Jump to next difference') }}">{{ _('Jump') }}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ Math: {{ 1 + 1 }}") }}
|
|||||||
|
|
||||||
<div class="tab-pane-inner" id="browser-steps">
|
<div class="tab-pane-inner" id="browser-steps">
|
||||||
{% if capabilities.supports_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">
|
<img class="beta-logo" src="{{url_for('static_content', group='images', filename='beta-logo.png')}}" alt="New beta functionality">
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
|||||||
datastore=datastore,
|
datastore=datastore,
|
||||||
errored_count=errored_count,
|
errored_count=errored_count,
|
||||||
form=form,
|
form=form,
|
||||||
generate_tag_colors=processors.generate_processor_badge_colors,
|
|
||||||
guid=datastore.data['app_guid'],
|
guid=datastore.data['app_guid'],
|
||||||
has_proxies=datastore.proxy_list,
|
has_proxies=datastore.proxy_list,
|
||||||
hosted_sticky=os.getenv("SALTED_PASS", False) == False,
|
hosted_sticky=os.getenv("SALTED_PASS", False) == False,
|
||||||
|
|||||||
@@ -22,33 +22,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
|
|
||||||
/* Auto-generated processor badge colors */
|
/* Auto-generated processor badge colors */
|
||||||
{{ processor_badge_css|safe }}
|
{{ 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>
|
</style>
|
||||||
<div class="box" id="form-quick-watch-add">
|
<div class="box" id="form-quick-watch-add">
|
||||||
|
|
||||||
@@ -109,7 +82,7 @@ html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} {
|
|||||||
<!-- tag list -->
|
<!-- tag list -->
|
||||||
{%- for uuid, tag in tags -%}
|
{%- for uuid, tag in tags -%}
|
||||||
{%- if tag != "" -%}
|
{%- 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 -%}
|
{%- endif -%}
|
||||||
{%- endfor -%}
|
{%- endfor -%}
|
||||||
</div>
|
</div>
|
||||||
@@ -196,7 +169,7 @@ html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} {
|
|||||||
<div class="flex-wrapper">
|
<div class="flex-wrapper">
|
||||||
{% if 'favicons_enabled' not in ui_settings or ui_settings['favicons_enabled'] %}
|
{% 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' #}
|
<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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div>
|
<div>
|
||||||
@@ -218,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>
|
<span class="processor-badge processor-badge-{{ watch['processor'] }}" title="{{ processor_descriptions.get(watch['processor'], watch['processor']) }}">{{ processor_badge_texts[watch['processor']] }}</span>
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
{%- for watch_tag_uuid, watch_tag in datastore.get_all_tags_for_watch(watch['uuid']).items() -%}
|
{%- 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 -%}
|
{%- endfor -%}
|
||||||
</div>
|
</div>
|
||||||
<div class="status-icons">
|
<div class="status-icons">
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import gc
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
@@ -186,33 +185,20 @@ class fetcher(Fetcher):
|
|||||||
super().screenshot_step(step_n=step_n)
|
super().screenshot_step(step_n=step_n)
|
||||||
screenshot = await capture_full_page_async(page=self.page, screenshot_format=self.screenshot_format)
|
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:
|
if self.browser_steps_screenshot_path is not None:
|
||||||
destination = os.path.join(self.browser_steps_screenshot_path, 'step_{}.jpeg'.format(step_n))
|
destination = os.path.join(self.browser_steps_screenshot_path, 'step_{}.jpeg'.format(step_n))
|
||||||
logger.debug(f"Saving step screenshot to {destination}")
|
logger.debug(f"Saving step screenshot to {destination}")
|
||||||
with open(destination, 'wb') as f:
|
with open(destination, 'wb') as f:
|
||||||
f.write(screenshot)
|
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):
|
async def save_step_html(self, step_n):
|
||||||
super().save_step_html(step_n=step_n)
|
super().save_step_html(step_n=step_n)
|
||||||
content = await self.page.content()
|
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))
|
destination = os.path.join(self.browser_steps_screenshot_path, 'step_{}.html'.format(step_n))
|
||||||
logger.debug(f"Saving step HTML to {destination}")
|
logger.debug(f"Saving step HTML to {destination}")
|
||||||
with open(destination, 'w', encoding='utf-8') as f:
|
with open(destination, 'w', encoding='utf-8') as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
# Clear local reference
|
|
||||||
del content
|
|
||||||
gc.collect()
|
|
||||||
|
|
||||||
async def run(self,
|
async def run(self,
|
||||||
fetch_favicon=True,
|
fetch_favicon=True,
|
||||||
@@ -319,12 +305,6 @@ class fetcher(Fetcher):
|
|||||||
|
|
||||||
if self.status_code != 200 and not ignore_status_codes:
|
if self.status_code != 200 and not ignore_status_codes:
|
||||||
screenshot = await capture_full_page_async(self.page, screenshot_format=self.screenshot_format)
|
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)
|
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:
|
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()
|
await browser.close()
|
||||||
raise EmptyReply(url=url, status_code=response.status)
|
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:
|
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
|
# 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)
|
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:
|
except Exception as e:
|
||||||
# It's likely the screenshot was too long/big and something crashed
|
# It's likely the screenshot was too long/big and something crashed
|
||||||
raise ScreenshotUnavailable(url=url, status_code=self.status_code)
|
raise ScreenshotUnavailable(url=url, status_code=self.status_code)
|
||||||
@@ -413,10 +389,6 @@ class fetcher(Fetcher):
|
|||||||
pass
|
pass
|
||||||
browser = None
|
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
|
# Plugin registration for built-in fetcher
|
||||||
class PlaywrightFetcherPlugin:
|
class PlaywrightFetcherPlugin:
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class fetcher(Fetcher):
|
|||||||
proxy_url = None
|
proxy_url = None
|
||||||
|
|
||||||
# Capability flags
|
# Capability flags
|
||||||
supports_browser_steps = False
|
supports_browser_steps = True
|
||||||
supports_screenshots = True
|
supports_screenshots = True
|
||||||
supports_xpath_element_data = True
|
supports_xpath_element_data = True
|
||||||
|
|
||||||
|
|||||||
@@ -57,15 +57,14 @@ class SignalPriorityQueue(queue.PriorityQueue):
|
|||||||
def put(self, item, block=True, timeout=None):
|
def put(self, item, block=True, timeout=None):
|
||||||
# Call the parent's put method first
|
# Call the parent's put method first
|
||||||
super().put(item, block, timeout)
|
super().put(item, block, timeout)
|
||||||
|
|
||||||
# After putting the item in the queue, check if it has a UUID and emit signal
|
# 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:
|
if hasattr(item, 'item') and isinstance(item.item, dict) and 'uuid' in item.item:
|
||||||
uuid = item.item['uuid']
|
uuid = item.item['uuid']
|
||||||
# Get the signal and send it if it exists
|
# Get the signal and send it if it exists
|
||||||
watch_check_update = signal('watch_check_update')
|
watch_check_update = signal('watch_check_update')
|
||||||
if watch_check_update:
|
if watch_check_update:
|
||||||
# NOTE: This would block other workers from .put/.get while this signal sends
|
# Send the watch_uuid parameter
|
||||||
# Signal handlers may iterate the queue/datastore while holding locks
|
|
||||||
watch_check_update.send(watch_uuid=uuid)
|
watch_check_update.send(watch_uuid=uuid)
|
||||||
|
|
||||||
# Send queue_length signal with current queue size
|
# Send queue_length signal with current queue size
|
||||||
@@ -313,15 +312,14 @@ class AsyncSignalPriorityQueue(asyncio.PriorityQueue):
|
|||||||
async def put(self, item):
|
async def put(self, item):
|
||||||
# Call the parent's put method first
|
# Call the parent's put method first
|
||||||
await super().put(item)
|
await super().put(item)
|
||||||
|
|
||||||
# After putting the item in the queue, check if it has a UUID and emit signal
|
# 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:
|
if hasattr(item, 'item') and isinstance(item.item, dict) and 'uuid' in item.item:
|
||||||
uuid = item.item['uuid']
|
uuid = item.item['uuid']
|
||||||
# Get the signal and send it if it exists
|
# Get the signal and send it if it exists
|
||||||
watch_check_update = signal('watch_check_update')
|
watch_check_update = signal('watch_check_update')
|
||||||
if watch_check_update:
|
if watch_check_update:
|
||||||
# NOTE: This would block other workers from .put/.get while this signal sends
|
# Send the watch_uuid parameter
|
||||||
# Signal handlers may iterate the queue/datastore while holding locks
|
|
||||||
watch_check_update.send(watch_uuid=uuid)
|
watch_check_update.send(watch_uuid=uuid)
|
||||||
|
|
||||||
# Send queue_length signal with current queue size
|
# Send queue_length signal with current queue size
|
||||||
|
|||||||
@@ -297,25 +297,6 @@ def _jinja2_filter_fetcher_status_icons(fetcher_name):
|
|||||||
|
|
||||||
return ''
|
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
|
# Import login_optionally_required from auth_decorator
|
||||||
from changedetectionio.auth_decorator import login_optionally_required
|
from changedetectionio.auth_decorator import login_optionally_required
|
||||||
|
|
||||||
@@ -863,13 +844,13 @@ def changedetection_app(config=None, datastore_o=None):
|
|||||||
worker_handler.start_workers(n_workers, update_q, notification_q, app, datastore)
|
worker_handler.start_workers(n_workers, update_q, notification_q, app, datastore)
|
||||||
|
|
||||||
# @todo handle ctrl break
|
# @todo handle ctrl break
|
||||||
ticker_thread = threading.Thread(target=ticker_thread_check_time_launch_checks, daemon=True, name="TickerThread-ScheduleChecker").start()
|
ticker_thread = threading.Thread(target=ticker_thread_check_time_launch_checks).start()
|
||||||
threading.Thread(target=notification_runner, daemon=True, name="NotificationRunner").start()
|
threading.Thread(target=notification_runner).start()
|
||||||
|
|
||||||
in_pytest = "pytest" in sys.modules or "PYTEST_CURRENT_TEST" in os.environ
|
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
|
# 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:
|
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
|
# Return the Flask app - the Socket.IO will be attached to it but initialized separately
|
||||||
# This avoids circular dependencies
|
# This avoids circular dependencies
|
||||||
@@ -914,7 +895,7 @@ def notification_runner():
|
|||||||
# At the moment only one thread runs (single runner)
|
# At the moment only one thread runs (single runner)
|
||||||
n_object = notification_q.get(block=False)
|
n_object = notification_q.get(block=False)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
app.config.exit.wait(1)
|
time.sleep(1)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|
||||||
@@ -951,7 +932,7 @@ def notification_runner():
|
|||||||
app.config['watch_check_update_SIGNAL'].send(app_context=app, watch_uuid=n_object.get('uuid'))
|
app.config['watch_check_update_SIGNAL'].send(app_context=app, watch_uuid=n_object.get('uuid'))
|
||||||
|
|
||||||
# Process notifications
|
# 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
|
# Trim the log length
|
||||||
notification_debug_log = notification_debug_log[-100:]
|
notification_debug_log = notification_debug_log[-100:]
|
||||||
|
|
||||||
@@ -1009,7 +990,7 @@ def ticker_thread_check_time_launch_checks():
|
|||||||
# Re #438 - Don't place more watches in the queue to be checked if the queue is already large
|
# Re #438 - Don't place more watches in the queue to be checked if the queue is already large
|
||||||
while update_q.qsize() >= 2000:
|
while update_q.qsize() >= 2000:
|
||||||
logger.warning(f"Recheck watches queue size limit reached ({MAX_QUEUE_SIZE}), skipping adding more items")
|
logger.warning(f"Recheck watches queue size limit reached ({MAX_QUEUE_SIZE}), skipping adding more items")
|
||||||
app.config.exit.wait(10.0)
|
time.sleep(3)
|
||||||
|
|
||||||
|
|
||||||
recheck_time_system_seconds = int(datastore.threshold_seconds)
|
recheck_time_system_seconds = int(datastore.threshold_seconds)
|
||||||
@@ -1107,5 +1088,8 @@ def ticker_thread_check_time_launch_checks():
|
|||||||
# Reset for next time
|
# Reset for next time
|
||||||
watch.jitter_seconds = 0
|
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
|
# Should be low so we can break this out in testing
|
||||||
app.config.exit.wait(1)
|
app.config.exit.wait(1)
|
||||||
|
|||||||
@@ -781,8 +781,8 @@ class SingleBrowserStep(Form):
|
|||||||
|
|
||||||
class processor_text_json_diff_form(commonSettingsForm):
|
class processor_text_json_diff_form(commonSettingsForm):
|
||||||
|
|
||||||
url = fields.URLField('Web Page URL', validators=[validateURL()])
|
url = fields.URLField('URL', validators=[validateURL()])
|
||||||
tags = StringTagUUID('Group Tag', [validators.Optional()], default='')
|
tags = StringTagUUID('Group tag', [validators.Optional()], default='')
|
||||||
|
|
||||||
time_between_check = EnhancedFormField(
|
time_between_check = EnhancedFormField(
|
||||||
TimeBetweenCheckForm,
|
TimeBetweenCheckForm,
|
||||||
@@ -991,7 +991,6 @@ class globalSettingsApplicationForm(commonSettingsForm):
|
|||||||
render_kw={"placeholder": os.getenv('BASE_URL', 'Not set')}
|
render_kw={"placeholder": os.getenv('BASE_URL', 'Not set')}
|
||||||
)
|
)
|
||||||
empty_pages_are_a_change = BooleanField(_l('Treat empty pages as a change?'), default=False)
|
empty_pages_are_a_change = BooleanField(_l('Treat empty pages as a change?'), default=False)
|
||||||
notification_html_word_diff_enabled = BooleanField(_l('Notification HTML as word-by-word difference'), default=True, validators=[validators.Optional()])
|
|
||||||
fetch_backend = RadioField(_l('Fetch Method'), default="html_requests", choices=content_fetchers.available_fetchers(), validators=[ValidateContentFetcherIsReady()])
|
fetch_backend = RadioField(_l('Fetch Method'), default="html_requests", choices=content_fetchers.available_fetchers(), validators=[ValidateContentFetcherIsReady()])
|
||||||
global_ignore_text = StringListField(_l('Ignore Text'), [ValidateListRegex()])
|
global_ignore_text = StringListField(_l('Ignore Text'), [ValidateListRegex()])
|
||||||
global_subtractive_selectors = StringListField(_l('Remove elements'), [ValidateCSSJSONXPATHInput(allow_json=False)])
|
global_subtractive_selectors = StringListField(_l('Remove elements'), [ValidateCSSJSONXPATHInput(allow_json=False)])
|
||||||
|
|||||||
@@ -29,22 +29,18 @@ def get_timeago_locale(flask_locale):
|
|||||||
"""
|
"""
|
||||||
locale_map = {
|
locale_map = {
|
||||||
'zh': 'zh_CN', # Chinese Simplified
|
'zh': 'zh_CN', # Chinese Simplified
|
||||||
'zh_Hant_TW': 'zh_TW', # Flask-Babel normalizes zh_TW to zh_Hant_TW
|
|
||||||
'pt': 'pt_PT', # Portuguese (Portugal)
|
'pt': 'pt_PT', # Portuguese (Portugal)
|
||||||
'sv': 'sv_SE', # Swedish
|
'sv': 'sv_SE', # Swedish
|
||||||
'no': 'nb_NO', # Norwegian Bokmål
|
'no': 'nb_NO', # Norwegian Bokmål
|
||||||
'hi': 'in_HI', # Hindi
|
'hi': 'in_HI', # Hindi
|
||||||
'cs': 'en', # Czech not supported by timeago, fallback to English
|
'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)
|
return locale_map.get(flask_locale, flask_locale)
|
||||||
|
|
||||||
# Language metadata: flag icon CSS class and native name
|
# Language metadata: flag icon CSS class and native name
|
||||||
# Using flag-icons library: https://flagicons.lipis.dev/
|
# Using flag-icons library: https://flagicons.lipis.dev/
|
||||||
LANGUAGE_DATA = {
|
LANGUAGE_DATA = {
|
||||||
'en_GB': {'flag': 'fi fi-gb fis', 'name': 'English (UK)'},
|
'en': {'flag': 'fi fi-gb fis', 'name': 'English'},
|
||||||
'en_US': {'flag': 'fi fi-us fis', 'name': 'English (US)'},
|
|
||||||
'de': {'flag': 'fi fi-de fis', 'name': 'Deutsch'},
|
'de': {'flag': 'fi fi-de fis', 'name': 'Deutsch'},
|
||||||
'fr': {'flag': 'fi fi-fr fis', 'name': 'Français'},
|
'fr': {'flag': 'fi fi-fr fis', 'name': 'Français'},
|
||||||
'ko': {'flag': 'fi fi-kr fis', 'name': '한국어'},
|
'ko': {'flag': 'fi fi-kr fis', 'name': '한국어'},
|
||||||
@@ -75,7 +71,10 @@ def get_available_languages():
|
|||||||
"""
|
"""
|
||||||
translations_dir = Path(__file__).parent / 'translations'
|
translations_dir = Path(__file__).parent / 'translations'
|
||||||
|
|
||||||
available = {}
|
# Always include English as base language
|
||||||
|
available = {
|
||||||
|
'en': LANGUAGE_DATA['en']
|
||||||
|
}
|
||||||
|
|
||||||
# Scan for translation directories
|
# Scan for translation directories
|
||||||
if translations_dir.exists():
|
if translations_dir.exists():
|
||||||
@@ -86,10 +85,6 @@ def get_available_languages():
|
|||||||
if po_file.exists():
|
if po_file.exists():
|
||||||
available[lang_dir.name] = LANGUAGE_DATA[lang_dir.name]
|
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
|
return available
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ class model(dict):
|
|||||||
'ssim_threshold': '0.96', # Default SSIM threshold for screenshot comparison
|
'ssim_threshold': '0.96', # Default SSIM threshold for screenshot comparison
|
||||||
'notification_body': default_notification_body,
|
'notification_body': default_notification_body,
|
||||||
'notification_format': default_notification_format,
|
'notification_format': default_notification_format,
|
||||||
'notification_html_word_diff': True,
|
|
||||||
'notification_title': default_notification_title,
|
'notification_title': default_notification_title,
|
||||||
'notification_urls': [], # Apprise URL list
|
'notification_urls': [], # Apprise URL list
|
||||||
'pager_size': 50,
|
'pager_size': 50,
|
||||||
|
|||||||
@@ -10,13 +10,9 @@ from pathlib import Path
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from .. import jinja2_custom as safe_jinja
|
from .. import jinja2_custom as safe_jinja
|
||||||
|
from ..diff import ADDED_PLACEMARKER_OPEN
|
||||||
from ..html_tools import TRANSLATE_WHITESPACE_TABLE
|
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_compress_worker(conn, filepath, mode=None):
|
def _brotli_compress_worker(conn, filepath, mode=None):
|
||||||
"""
|
"""
|
||||||
@@ -33,7 +29,6 @@ def _brotli_compress_worker(conn, filepath, mode=None):
|
|||||||
try:
|
try:
|
||||||
# Receive data from parent process via pipe (avoids pickle overhead)
|
# Receive data from parent process via pipe (avoids pickle overhead)
|
||||||
contents = conn.recv()
|
contents = conn.recv()
|
||||||
logger.debug(f"Starting brotli compression of {len(contents)} bytes.")
|
|
||||||
|
|
||||||
if mode is not None:
|
if mode is not None:
|
||||||
compressed_data = brotli.compress(contents, mode=mode)
|
compressed_data = brotli.compress(contents, mode=mode)
|
||||||
@@ -45,10 +40,9 @@ def _brotli_compress_worker(conn, filepath, mode=None):
|
|||||||
|
|
||||||
# Send success status back
|
# Send success status back
|
||||||
conn.send(True)
|
conn.send(True)
|
||||||
logger.debug(f"Finished brotli compression - From {len(contents)} to {len(compressed_data)} bytes.")
|
|
||||||
# No need for explicit cleanup - process exit frees all memory
|
# No need for explicit cleanup - process exit frees all memory
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.critical(f"Brotli compression worker failed: {e}")
|
logger.error(f"Brotli compression worker failed: {e}")
|
||||||
conn.send(False)
|
conn.send(False)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -72,6 +66,7 @@ def _brotli_subprocess_save(contents, filepath, mode=None, timeout=30, fallback_
|
|||||||
Raises:
|
Raises:
|
||||||
Exception: if compression fails and fallback_uncompressed is False
|
Exception: if compression fails and fallback_uncompressed is False
|
||||||
"""
|
"""
|
||||||
|
import brotli
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -149,6 +144,11 @@ def _brotli_subprocess_save(contents, filepath, mode=None, timeout=30, fallback_
|
|||||||
else:
|
else:
|
||||||
raise Exception(f"Brotli compression subprocess failed for {filepath}")
|
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):
|
class model(watch_base):
|
||||||
__newest_history_key = None
|
__newest_history_key = None
|
||||||
@@ -492,6 +492,7 @@ class model(watch_base):
|
|||||||
|
|
||||||
self.ensure_data_dir_exists()
|
self.ensure_data_dir_exists()
|
||||||
|
|
||||||
|
threshold = int(os.getenv('SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD', 1024))
|
||||||
skip_brotli = strtobool(os.getenv('DISABLE_BROTLI_TEXT_SNAPSHOT', 'False'))
|
skip_brotli = strtobool(os.getenv('DISABLE_BROTLI_TEXT_SNAPSHOT', 'False'))
|
||||||
|
|
||||||
# Binary data - detect file type and save without compression
|
# Binary data - detect file type and save without compression
|
||||||
@@ -515,7 +516,7 @@ class model(watch_base):
|
|||||||
|
|
||||||
# Text data - use brotli compression if enabled and above threshold
|
# Text data - use brotli compression if enabled and above threshold
|
||||||
else:
|
else:
|
||||||
if not skip_brotli and len(contents) > BROTLI_COMPRESS_SIZE_THRESHOLD:
|
if not skip_brotli and len(contents) > threshold:
|
||||||
# Compressed text
|
# Compressed text
|
||||||
import brotli
|
import brotli
|
||||||
snapshot_fname = f"{snapshot_id}.txt.br"
|
snapshot_fname = f"{snapshot_id}.txt.br"
|
||||||
|
|||||||
@@ -309,9 +309,6 @@ def process_notification(n_object: NotificationContextData, datastore):
|
|||||||
if not isinstance(n_object, NotificationContextData):
|
if not isinstance(n_object, NotificationContextData):
|
||||||
raise TypeError(f"Expected NotificationContextData, got {type(n_object)}")
|
raise TypeError(f"Expected NotificationContextData, got {type(n_object)}")
|
||||||
|
|
||||||
if not n_object.get('notification_urls'):
|
|
||||||
return None
|
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if n_object.get('notification_timestamp'):
|
if n_object.get('notification_timestamp'):
|
||||||
logger.trace(f"Time since queued {now-n_object['notification_timestamp']:.3f}s")
|
logger.trace(f"Time since queued {now-n_object['notification_timestamp']:.3f}s")
|
||||||
@@ -351,15 +348,16 @@ def process_notification(n_object: NotificationContextData, datastore):
|
|||||||
apprise.plugins.N_MGR.remove('discord')
|
apprise.plugins.N_MGR.remove('discord')
|
||||||
apprise.plugins.N_MGR.add(NotifyDiscordCustom, schemas='discord')
|
apprise.plugins.N_MGR.add(NotifyDiscordCustom, schemas='discord')
|
||||||
|
|
||||||
# Should always be false for 'text' mode or its too hard to read, otherwise it's a setting (for html style).
|
if not n_object.get('notification_urls'):
|
||||||
word_diff_enable = requested_output_format_original == 'text' or (
|
return None
|
||||||
n_object.get('notification_html_word_diff_enabled', True) and requested_output_format_original.startswith('html'))
|
|
||||||
|
|
||||||
n_object.update(add_rendered_diff_to_notification_vars(
|
n_object.update(add_rendered_diff_to_notification_vars(
|
||||||
notification_scan_text=n_object.get('notification_body', '')+n_object.get('notification_title', ''),
|
notification_scan_text=n_object.get('notification_body', '')+n_object.get('notification_title', ''),
|
||||||
current_snapshot=n_object.get('current_snapshot'),
|
current_snapshot=n_object.get('current_snapshot'),
|
||||||
prev_snapshot=n_object.get('prev_snapshot'),
|
prev_snapshot=n_object.get('prev_snapshot'),
|
||||||
word_diff=word_diff_enable
|
# Should always be false for 'text' mode or its too hard to read
|
||||||
|
# But otherwise, this could be some setting
|
||||||
|
word_diff=False if requested_output_format_original == 'text' else True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -250,7 +250,6 @@ class NotificationService:
|
|||||||
if n_object.get('notification_format') == USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH:
|
if n_object.get('notification_format') == USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH:
|
||||||
n_object['notification_format'] = self.datastore.data['settings']['application'].get('notification_format')
|
n_object['notification_format'] = self.datastore.data['settings']['application'].get('notification_format')
|
||||||
|
|
||||||
n_object['notification_html_word_diff_enabled'] = self.datastore.data['settings']['application'].get('notification_html_word_diff_enabled', True)
|
|
||||||
|
|
||||||
triggered_text = ''
|
triggered_text = ''
|
||||||
if len(trigger_text):
|
if len(trigger_text):
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ class RecheckPriorityQueue:
|
|||||||
|
|
||||||
def get(self, block: bool = True, timeout: Optional[float] = None):
|
def get(self, block: bool = True, timeout: Optional[float] = None):
|
||||||
"""Thread-safe sync get with priority ordering"""
|
"""Thread-safe sync get with priority ordering"""
|
||||||
import queue
|
|
||||||
try:
|
try:
|
||||||
# Wait for notification
|
# Wait for notification
|
||||||
self.sync_q.get(block=block, timeout=timeout)
|
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)}")
|
logger.debug(f"Successfully retrieved item: {self._get_item_uuid(item)}")
|
||||||
return item
|
return item
|
||||||
|
|
||||||
except queue.Empty:
|
|
||||||
# Queue is empty with timeout - expected behavior, re-raise without logging
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
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
|
raise
|
||||||
|
|
||||||
# ASYNC INTERFACE (for workers)
|
# ASYNC INTERFACE (for workers)
|
||||||
|
|||||||
@@ -98,12 +98,11 @@ pytest -vv -s --maxfail=1 tests/test_rss.py
|
|||||||
pytest -vv -s --maxfail=1 tests/test_unique_lines.py
|
pytest -vv -s --maxfail=1 tests/test_unique_lines.py
|
||||||
|
|
||||||
# Try high concurrency
|
# 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
|
# Check file:// will pickup a file when enabled
|
||||||
echo "Hello world" > /tmp/test-file.txt
|
echo "Hello world" > /tmp/test-file.txt
|
||||||
ALLOW_FILE_URI=yes pytest -vv -s tests/test_security.py
|
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
|
* Allows users to select their preferred language
|
||||||
*/
|
*/
|
||||||
|
|
||||||
$(document).ready(function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const $languageButton = $('.language-selector');
|
const languageButton = document.getElementById('language-selector');
|
||||||
const $languageModal = $('#language-modal');
|
const languageModal = document.getElementById('language-modal');
|
||||||
const $closeButton = $('#close-language-modal');
|
const closeButton = document.getElementById('close-language-modal');
|
||||||
|
|
||||||
if (!$languageButton.length || !$languageModal.length) {
|
if (!languageButton || !languageModal) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open modal when language button is clicked
|
// Open modal when language button is clicked
|
||||||
$languageButton.on('click', function(e) {
|
languageButton.addEventListener('click', function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// Update all language links to include current hash in the redirect parameter
|
// Update all language links to include current hash in the redirect parameter
|
||||||
@@ -21,53 +21,51 @@ $(document).ready(function() {
|
|||||||
const currentHash = window.location.hash;
|
const currentHash = window.location.hash;
|
||||||
|
|
||||||
if (currentHash) {
|
if (currentHash) {
|
||||||
const $languageOptions = $languageModal.find('.language-option');
|
const languageOptions = languageModal.querySelectorAll('.language-option');
|
||||||
$languageOptions.each(function() {
|
languageOptions.forEach(function(option) {
|
||||||
const $option = $(this);
|
const url = new URL(option.href, window.location.origin);
|
||||||
const url = new URL($option.attr('href'), window.location.origin);
|
|
||||||
// Update the redirect parameter to include the hash
|
// Update the redirect parameter to include the hash
|
||||||
const redirectPath = currentPath + currentHash;
|
const redirectPath = currentPath + currentHash;
|
||||||
url.searchParams.set('redirect', redirectPath);
|
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
|
// Close modal when cancel button is clicked
|
||||||
if ($closeButton.length) {
|
if (closeButton) {
|
||||||
$closeButton.on('click', function() {
|
closeButton.addEventListener('click', function() {
|
||||||
$languageModal[0].close();
|
languageModal.close();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close modal when clicking outside (on backdrop)
|
// Close modal when clicking outside (on backdrop)
|
||||||
$languageModal.on('click', function(e) {
|
languageModal.addEventListener('click', function(e) {
|
||||||
const rect = this.getBoundingClientRect();
|
const rect = languageModal.getBoundingClientRect();
|
||||||
if (
|
if (
|
||||||
e.clientY < rect.top ||
|
e.clientY < rect.top ||
|
||||||
e.clientY > rect.bottom ||
|
e.clientY > rect.bottom ||
|
||||||
e.clientX < rect.left ||
|
e.clientX < rect.left ||
|
||||||
e.clientX > rect.right
|
e.clientX > rect.right
|
||||||
) {
|
) {
|
||||||
$languageModal[0].close();
|
languageModal.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close modal on Escape key
|
// Close modal on Escape key
|
||||||
$languageModal.on('cancel', function(e) {
|
languageModal.addEventListener('cancel', function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
$languageModal[0].close();
|
languageModal.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Highlight current language
|
// Highlight current language
|
||||||
const currentLocale = $('html').attr('lang') || 'en';
|
const currentLocale = document.documentElement.lang || 'en';
|
||||||
const $languageOptions = $languageModal.find('.language-option');
|
const languageOptions = languageModal.querySelectorAll('.language-option');
|
||||||
$languageOptions.each(function() {
|
languageOptions.forEach(function(option) {
|
||||||
const $option = $(this);
|
if (option.dataset.locale === currentLocale) {
|
||||||
if ($option.attr('data-locale') === currentLocale) {
|
option.classList.add('active');
|
||||||
$option.addClass('active');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -74,9 +74,6 @@ $(document).ready(function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Cache DOM elements for performance
|
|
||||||
const queueBubble = document.getElementById('queue-bubble');
|
|
||||||
|
|
||||||
// Only try to connect if authentication isn't required or user is authenticated
|
// Only try to connect if authentication isn't required or user is authenticated
|
||||||
// The 'is_authenticated' variable will be set in the template
|
// The 'is_authenticated' variable will be set in the template
|
||||||
if (typeof is_authenticated !== 'undefined' ? is_authenticated : true) {
|
if (typeof is_authenticated !== 'undefined' ? is_authenticated : true) {
|
||||||
@@ -118,40 +115,7 @@ $(document).ready(function () {
|
|||||||
|
|
||||||
socket.on('queue_size', function (data) {
|
socket.on('queue_size', function (data) {
|
||||||
console.log(`${data.event_timestamp} - Queue size update: ${data.q_length}`);
|
console.log(`${data.event_timestamp} - Queue size update: ${data.q_length}`);
|
||||||
|
// Update queue size display if implemented in the UI
|
||||||
// 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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Listen for operation results
|
// 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?'
|
// Rewrite this is a plugin.. is all this JS really 'worth it?'
|
||||||
|
|
||||||
window.addEventListener('hashchange', function () {
|
window.addEventListener('hashchange', function () {
|
||||||
// Only remove active from tab elements, not menu items
|
var tabs = document.getElementsByClassName('active');
|
||||||
var tabs = document.querySelectorAll('.tabs li.active');
|
while (tabs[0]) {
|
||||||
tabs.forEach(function(tab) {
|
tabs[0].classList.remove('active');
|
||||||
tab.classList.remove('active');
|
document.body.classList.remove('full-width');
|
||||||
});
|
}
|
||||||
document.body.classList.remove('full-width');
|
|
||||||
set_active_tab();
|
set_active_tab();
|
||||||
}, false);
|
}, false);
|
||||||
|
|
||||||
@@ -23,9 +22,9 @@ if (!has_errors.length) {
|
|||||||
|
|
||||||
function set_active_tab() {
|
function set_active_tab() {
|
||||||
document.body.classList.remove('full-width');
|
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) {
|
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.
|
* Toggles theme between light and dark mode.
|
||||||
*/
|
*/
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
|
const button = document.getElementById("toggle-light-mode");
|
||||||
|
|
||||||
$(".toggle-light-mode").on("click", function () {
|
button.onclick = () => {
|
||||||
const isDark = $("html").attr("data-darkmode") === "true";
|
const htmlElement = document.getElementsByTagName("html");
|
||||||
$("html").attr("data-darkmode", !isDark);
|
const isDarkMode = htmlElement[0].dataset.darkmode === "true";
|
||||||
setCookieValue(!isDark);
|
htmlElement[0].dataset.darkmode = !isDarkMode;
|
||||||
});
|
setCookieValue(!isDarkMode);
|
||||||
|
};
|
||||||
|
|
||||||
const setCookieValue = (value) => {
|
const setCookieValue = (value) => {
|
||||||
document.cookie = `css_dark_mode=${value};max-age=31536000;path=/`
|
document.cookie = `css_dark_mode=${value};max-age=31536000;path=/`
|
||||||
|
|||||||
@@ -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 */
|
/* default */
|
||||||
.icon-dark {
|
.icon-dark {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-darkmode="true"] {
|
html[data-darkmode="true"] {
|
||||||
.toggle-light-mode {
|
#toggle-light-mode {
|
||||||
.icon-light {
|
.icon-light {
|
||||||
display: none;
|
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;
|
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,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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
* -- BASE STYLES --
|
* -- BASE STYLES --
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@use "settings" as *;
|
|
||||||
@use "parts/variables";
|
@use "parts/variables";
|
||||||
@use "parts/arrows";
|
@use "parts/arrows";
|
||||||
@use "parts/browser-steps";
|
@use "parts/browser-steps";
|
||||||
@@ -24,14 +23,6 @@
|
|||||||
@use "parts/widgets";
|
@use "parts/widgets";
|
||||||
@use "parts/diff_image";
|
@use "parts/diff_image";
|
||||||
@use "parts/modal";
|
@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";
|
|
||||||
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -80,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 {
|
#search-result-info {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
@@ -160,13 +165,7 @@ body.spinner-active {
|
|||||||
}
|
}
|
||||||
|
|
||||||
section.content {
|
section.content {
|
||||||
@media only screen and (max-width: $desktop-wide-breakpoint) {
|
padding-top: 100px;
|
||||||
padding-top: 80px;
|
|
||||||
}
|
|
||||||
@media only screen and (min-width: $desktop-wide-breakpoint) {
|
|
||||||
padding-top: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
padding-bottom: 1em;
|
padding-bottom: 1em;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -184,13 +183,13 @@ code {
|
|||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
padding: 2px 5px;
|
padding: 2px 5px;
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
line-height: 1.2rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Processor type badges - colors auto-generated from processor names */
|
/* Processor type badges - colors auto-generated from processor names */
|
||||||
.processor-badge {
|
.processor-badge {
|
||||||
@extend .inline-tag;
|
@extend .inline-tag;
|
||||||
font-weight: 900;
|
font-size: 0.85em;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.watch-tag-list {
|
.watch-tag-list {
|
||||||
@@ -523,9 +522,6 @@ footer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sticky-tab {
|
.sticky-tab {
|
||||||
@media only screen and (max-width: $desktop-wide-breakpoint) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 60px;
|
top: 60px;
|
||||||
font-size: 65%;
|
font-size: 65%;
|
||||||
@@ -670,7 +666,7 @@ footer {
|
|||||||
|
|
||||||
|
|
||||||
@media only screen and (max-width: 760px),
|
@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 {
|
.edit-form {
|
||||||
padding: 0.5em;
|
padding: 0.5em;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -682,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'] {
|
input[type='text'] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pure-table {
|
.pure-table {
|
||||||
@@ -761,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;
|
$form-edge-padding: 20px;
|
||||||
|
|
||||||
.pure-form-stacked {
|
.pure-form-stacked {
|
||||||
@@ -769,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 {
|
.tab-pane-inner {
|
||||||
|
|
||||||
@@ -1097,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
|
# 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)
|
# 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():
|
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()
|
self.save_data_thread.start()
|
||||||
|
|
||||||
def rehydrate_entity(self, uuid, entity, processor_override=None):
|
def rehydrate_entity(self, uuid, entity, processor_override=None):
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ _MAP = {
|
|||||||
|
|
||||||
|
|
||||||
def strtobool(value):
|
def strtobool(value):
|
||||||
if not value:
|
|
||||||
return False
|
|
||||||
try:
|
try:
|
||||||
return _MAP[str(value).lower()]
|
return _MAP[str(value).lower()]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
{% from '_helpers.html' import render_field, render_checkbox_field %}
|
{% from '_helpers.html' import render_field %}
|
||||||
|
|
||||||
{% macro show_token_placeholders(extra_notification_token_placeholder_info, suffix="") %}
|
{% macro show_token_placeholders(extra_notification_token_placeholder_info, suffix="") %}
|
||||||
|
|
||||||
@@ -8,7 +8,9 @@
|
|||||||
<span class="pure-form-message-inline">
|
<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.
|
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>
|
</span><br>
|
||||||
<div data-target="#notification-tokens-info{{ suffix }}" class="toggle-show pure-button button-tag button-xsmall">Show extra help and tokens</div>
|
<div data-target="#notification-tokens-info{{ suffix }}" class="toggle-show pure-button button-tag button-xsmall">Show
|
||||||
|
token/placeholders
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pure-controls" style="display: none;" id="notification-tokens-info{{ suffix }}">
|
<div class="pure-controls" style="display: none;" id="notification-tokens-info{{ suffix }}">
|
||||||
<table class="pure-table" id="token-table">
|
<table class="pure-table" id="token-table">
|
||||||
@@ -103,30 +105,11 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<br>
|
|
||||||
<div class="pure-form-message-inline">
|
<span class="pure-form-message-inline">
|
||||||
Warning: Contents of <code>{{ '{{diff}}' }}</code>, <code>{{ '{{diff_removed}}' }}</code>, and <code>{{ '{{diff_added}}' }}</code> depend on how the difference algorithm perceives the change. <br>
|
Warning: Contents of <code>{{ '{{diff}}' }}</code>, <code>{{ '{{diff_removed}}' }}</code>, and <code>{{ '{{diff_added}}' }}</code> depend on how the difference algorithm perceives the change. <br>
|
||||||
For example, an addition or removal could be perceived as a change in some cases. <a target="newwindow" href="https://github.com/dgtlmoon/changedetection.io/wiki/Using-the-%7B%7Bdiff%7D%7D,-%7B%7Bdiff_added%7D%7D,-and-%7B%7Bdiff_removed%7D%7D-notification-tokens">More Here</a> <br>
|
For example, an addition or removal could be perceived as a change in some cases. <a target="newwindow" href="https://github.com/dgtlmoon/changedetection.io/wiki/Using-the-%7B%7Bdiff%7D%7D,-%7B%7Bdiff_added%7D%7D,-and-%7B%7Bdiff_removed%7D%7D-notification-tokens">More Here</a> <br>
|
||||||
</div>
|
</span>
|
||||||
<br><br>
|
|
||||||
<div class="pure-form-message-inline">
|
|
||||||
<ul>
|
|
||||||
<li><span class="pure-form-message-inline">
|
|
||||||
For JSON payloads, use <strong>|tojson</strong> without quotes for automatic escaping, for example - <code>{ "name": {{ '{{ watch_title|tojson }}' }} }</code>
|
|
||||||
</span></li>
|
|
||||||
<li><span class="pure-form-message-inline">
|
|
||||||
URL encoding, use <strong>|urlencode</strong>, for example - <code>gets://hook-website.com/test.php?title={{ '{{ watch_title|urlencode }}' }}</code>
|
|
||||||
</span></li>
|
|
||||||
<li><span class="pure-form-message-inline">
|
|
||||||
Regular-expression replace, use <strong>|regex_replace</strong>, for example - <code>{{ "{{ \"hello world 123\" | regex_replace('[0-9]+', 'no-more-numbers') }}" }}</code>
|
|
||||||
</span></li>
|
|
||||||
<li><span class="pure-form-message-inline">
|
|
||||||
For a complete reference of all Jinja2 built-in filters, users can refer to the <a
|
|
||||||
href="https://jinja.palletsprojects.com/en/3.1.x/templates/#builtin-filters">https://jinja.palletsprojects.com/en/3.1.x/templates/#builtin-filters</a>
|
|
||||||
</span></li>
|
|
||||||
</ul>
|
|
||||||
<br>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
@@ -162,17 +145,32 @@
|
|||||||
<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>
|
</div>
|
||||||
|
|
||||||
<div class="pure-control-group grey-form-border">
|
<div class="pure-control-group grey-form-border">
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
{{ render_field(form.notification_title, class="m-d notification-title", placeholder=settings_application['notification_title']) }}
|
{{ 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>
|
||||||
<div>
|
<div class="pure-control-group">
|
||||||
{{ render_field(form.notification_body , rows=5, class="notification-body", placeholder=settings_application['notification_body']) }}
|
{{ render_field(form.notification_body , rows=5, class="notification-body", placeholder=settings_application['notification_body']) }}
|
||||||
{{ show_token_placeholders(extra_notification_token_placeholder_info=extra_notification_token_placeholder_info) }}
|
{{ show_token_placeholders(extra_notification_token_placeholder_info=extra_notification_token_placeholder_info) }}
|
||||||
|
<div class="pure-form-message-inline">
|
||||||
|
<ul>
|
||||||
|
<li><span class="pure-form-message-inline">
|
||||||
|
For JSON payloads, use <strong>|tojson</strong> without quotes for automatic escaping, for example - <code>{ "name": {{ '{{ watch_title|tojson }}' }} }</code>
|
||||||
|
</span></li>
|
||||||
|
<li><span class="pure-form-message-inline">
|
||||||
|
URL encoding, use <strong>|urlencode</strong>, for example - <code>gets://hook-website.com/test.php?title={{ '{{ watch_title|urlencode }}' }}</code>
|
||||||
|
</span></li>
|
||||||
|
<li><span class="pure-form-message-inline">
|
||||||
|
Regular-expression replace, use <strong>|regex_replace</strong>, for example - <code>{{ "{{ \"hello world 123\" | regex_replace('[0-9]+', 'no-more-numbers') }}" }}</code>
|
||||||
|
</span></li>
|
||||||
|
<li><span class="pure-form-message-inline">
|
||||||
|
For a complete reference of all Jinja2 built-in filters, users can refer to the <a href="https://jinja.palletsprojects.com/en/3.1.x/templates/#builtin-filters">https://jinja.palletsprojects.com/en/3.1.x/templates/#builtin-filters</a>
|
||||||
|
</span></li>
|
||||||
|
</ul>
|
||||||
|
<br>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="">
|
||||||
{{ render_field(form.notification_format , class="notification-format") }}
|
{{ 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>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
{% for idx, entry_errors in field.errors|enumerate %}
|
{% for idx, entry_errors in field.errors|enumerate %}
|
||||||
{% if entry_errors is mapping and entry_errors %}
|
{% if entry_errors is mapping and entry_errors %}
|
||||||
{# Only show entries that have actual errors #}
|
{# Only show entries that have actual errors #}
|
||||||
<li><strong>{{ _('Entry') }} {{ idx + 1 }}:</strong>
|
<li><strong>Entry {{ idx + 1 }}:</strong>
|
||||||
<ul>
|
<ul>
|
||||||
{% for field_name, messages in entry_errors.items() %}
|
{% for field_name, messages in entry_errors.items() %}
|
||||||
{% for message in messages %}
|
{% for message in messages %}
|
||||||
@@ -150,7 +150,7 @@
|
|||||||
{% for subfield in fieldlist[0] %}
|
{% for subfield in fieldlist[0] %}
|
||||||
<div class="fieldlist-header-cell">{{ subfield.label }}</div>
|
<div class="fieldlist-header-cell">{{ subfield.label }}</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<div class="fieldlist-header-cell">{{ _('Actions') }}</div>
|
<div class="fieldlist-header-cell">Actions</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="fieldlist-body">
|
<div class="fieldlist-body">
|
||||||
{% for form_row in fieldlist %}
|
{% for form_row in fieldlist %}
|
||||||
@@ -169,9 +169,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<div class="fieldlist-cell fieldlist-actions">
|
<div class="fieldlist-cell fieldlist-actions">
|
||||||
<button type="button" class="addRuleRow" title="{{ _('Add a row/rule after') }}">+</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="removeRuleRow" title="Remove this row/rule">-</button>
|
||||||
<button type="button" class="verifyRuleRow" title="{{ _('Verify this rule against current snapshot') }}">✓</button>
|
<button type="button" class="verifyRuleRow" title="Verify this rule against current snapshot">✓</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -181,8 +181,8 @@
|
|||||||
|
|
||||||
|
|
||||||
{% macro playwright_warning() %}
|
{% 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><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>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>
|
<br>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
@@ -237,17 +237,18 @@
|
|||||||
<span id="scheduler-icon-label" style="">
|
<span id="scheduler-icon-label" style="">
|
||||||
{{ render_checkbox_field(form.time_schedule_limit.enabled) }}
|
{{ render_checkbox_field(form.time_schedule_limit.enabled) }}
|
||||||
<div class="pure-form-message-inline">
|
<div class="pure-form-message-inline">
|
||||||
{{ _('Set a hourly/week day schedule') }}
|
Set a hourly/week day schedule
|
||||||
</div>
|
</div>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<br>
|
<br>
|
||||||
<div id="schedule-day-limits-wrapper">
|
<div id="schedule-day-limits-wrapper">
|
||||||
<label>{{ _('Schedule time limits') }}</label><a data-template="business-hours"
|
<label>Schedule time limits</label><a data-template="business-hours"
|
||||||
class="set-schedule pure-button button-secondary button-xsmall">{{ _('Business hours') }}</a>
|
class="set-schedule pure-button button-secondary button-xsmall">Business
|
||||||
<a data-template="weekend" class="set-schedule pure-button button-secondary button-xsmall">{{ _('Weekends') }}</a>
|
hours</a>
|
||||||
<a data-template="reset" class="set-schedule pure-button button-xsmall">{{ _('Reset') }}</a><br>
|
<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>
|
<br>
|
||||||
|
|
||||||
<ul id="day-wrapper">
|
<ul id="day-wrapper">
|
||||||
@@ -256,8 +257,8 @@
|
|||||||
{{ render_nolabel_field(form.time_schedule_limit[day]) }}
|
{{ render_nolabel_field(form.time_schedule_limit[day]) }}
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<li id="timespan-warning">{{ _("Warning, one or more of your 'days' has a duration that would extend into the next day.") }}<br>
|
<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>
|
This could have unintended consequences.</li>
|
||||||
<li id="timezone-info">
|
<li id="timezone-info">
|
||||||
{{ render_field(form.time_schedule_limit.timezone, placeholder=timezone_default_config) }} <span id="local-time-in-tz"></span>
|
{{ render_field(form.time_schedule_limit.timezone, placeholder=timezone_default_config) }} <span id="local-time-in-tz"></span>
|
||||||
<datalist id="timezones" style="display: none;">
|
<datalist id="timezones" style="display: none;">
|
||||||
@@ -267,12 +268,12 @@
|
|||||||
</ul>
|
</ul>
|
||||||
<br>
|
<br>
|
||||||
<span class="pure-form-message-inline">
|
<span class="pure-form-message-inline">
|
||||||
<a href="https://changedetection.io/tutorials">{{ _('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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="pure-form-message-inline">
|
<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>
|
</span>
|
||||||
<br>
|
<br>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -281,8 +282,8 @@
|
|||||||
|
|
||||||
{% macro highlight_trigger_ignored_explainer() %}
|
{% macro highlight_trigger_ignored_explainer() %}
|
||||||
<p>
|
<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="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="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="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>
|
</p>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
@@ -53,10 +53,10 @@
|
|||||||
<div class="home-menu pure-menu pure-menu-horizontal" id="nav-menu">
|
<div class="home-menu pure-menu pure-menu-horizontal" id="nav-menu">
|
||||||
|
|
||||||
{% if has_password and not current_user.is_authenticated %}
|
{% 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>
|
<strong>Change</strong>Detection.io</a>
|
||||||
{% else %}
|
{% 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>
|
<strong>Change</strong>Detection.io</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_diff_url and is_safe_valid_url(current_diff_url) %}
|
{% if current_diff_url and is_safe_valid_url(current_diff_url) %}
|
||||||
@@ -71,20 +71,64 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<ul class="pure-menu-list" id="top-right-menu">
|
<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 current_user.is_authenticated or not has_password %}
|
||||||
{% if not current_diff_url %}
|
{% if not current_diff_url %}
|
||||||
<li class="pure-menu-item menu-collapsible">
|
<li class="pure-menu-item">
|
||||||
<button class="toggle-button" id="open-search-modal" type="button" title="{{ _('Search, or Use Alt+S Key') }}">
|
<a href="{{ url_for('tags.tags_overview_page')}}" class="pure-menu-link">{{ _('GROUPS') }}</a>
|
||||||
{% include "svgs/search-icon.svg" %}
|
</li>
|
||||||
</button>
|
<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>
|
</li>
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<li class="pure-menu-item">
|
||||||
|
<a class="pure-menu-link" href="https://changedetection.io">Website Change Detection and Notification.</a>
|
||||||
|
</li>
|
||||||
{% endif %}
|
{% 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">
|
<li class="pure-menu-item" id="heart-us">
|
||||||
<svg
|
<svg
|
||||||
fill="#ff0000"
|
fill="#ff0000"
|
||||||
@@ -94,37 +138,24 @@
|
|||||||
id="svg-heart"
|
id="svg-heart"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
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>
|
</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 -->
|
</li>
|
||||||
<div class="mobile-menu-overlay" id="mobile-menu-overlay"></div>
|
<li class="pure-menu-item">
|
||||||
<div class="mobile-menu-drawer" id="mobile-menu-drawer">
|
<a class="github-link" href="https://github.com/dgtlmoon/changedetection.io">
|
||||||
<ul class="mobile-menu-items">
|
{% include "svgs/github.svg" %}
|
||||||
{% include "menu.html" %}
|
</a>
|
||||||
<li class="pure-menu-item menu-collapsible">
|
</li>
|
||||||
{%- if right_sticky -%}<div>{{ right_sticky }}</div>{%- endif -%}
|
|
||||||
<a href="https://changedetection.io/?ref={{ guid }}">Let us host your instance!</a><br>
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div id="pure-menu-horizontal-spinner"></div>
|
<div id="pure-menu-horizontal-spinner"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{% if hosted_sticky %}
|
{% if hosted_sticky %}
|
||||||
<div class="sticky-tab" id="hosted-sticky">
|
<div class="sticky-tab" id="hosted-sticky">
|
||||||
<a href="https://changedetection.io/?ref={{guid}}">Let us host your instance!</a>
|
<a href="https://changedetection.io/?ref={{guid}}">Let us host your instance!</a>
|
||||||
@@ -187,62 +218,32 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<header>
|
||||||
|
{% block header %}{% endblock %}
|
||||||
|
</header>
|
||||||
|
|
||||||
<div class="content-wrapper">
|
{% with messages = get_flashed_messages(with_categories = true) %}
|
||||||
{#
|
{% if
|
||||||
{% if current_user.is_authenticated or not has_password %}
|
messages %}
|
||||||
<aside class="action-sidebar">
|
<ul class="messages">
|
||||||
<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') }}">
|
{% for category, message in messages %}
|
||||||
<svg class="action-icon" viewBox="0 0 24 24">
|
<li class="{{ category }}">{{ message }}</li>
|
||||||
<circle cx="12" cy="12" r="10"/>
|
{% endfor %}
|
||||||
<path d="M12 6v6l4 2"/>
|
</ul>
|
||||||
</svg>
|
{% endif %}
|
||||||
<span class="action-label">{{ _('Watches') }}</span>
|
{% endwith %}
|
||||||
</a>
|
{% if session['share-link'] %}
|
||||||
<a href="{{ url_for('queue_status') }}" class="action-sidebar-item {% if request.endpoint == 'queue_status' %}active{% endif %}" id="queue-action-item" title="{{ _('Queue Status') }}">
|
<ul class="messages with-share-link">
|
||||||
<svg class="action-icon" viewBox="0 0 24 24">
|
<li class="message">
|
||||||
<line x1="8" y1="6" x2="21" y2="6"/>
|
Share this link:
|
||||||
<line x1="8" y1="12" x2="21" y2="12"/>
|
<span id="share-link">{{ session['share-link'] }}</span>
|
||||||
<line x1="8" y1="18" x2="21" y2="18"/>
|
<img style="height: 1em; display: inline-block" src="{{url_for('static_content', group='images', filename='copy.svg')}}" >
|
||||||
<line x1="3" y1="6" x2="3.01" y2="6"/>
|
</li>
|
||||||
<line x1="3" y1="12" x2="3.01" y2="12"/>
|
</ul>
|
||||||
<line x1="3" y1="18" x2="3.01" y2="18"/>
|
{% endif %}
|
||||||
</svg>
|
{% block content %}{% endblock %}
|
||||||
<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>
|
|
||||||
</section>
|
</section>
|
||||||
<script src="{{url_for('static_content', group='js', filename='toggle-theme.js')}}" defer></script>
|
<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="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>
|
<div id="realtime-conn-error" style="display:none">{{ _('Real-time updates offline') }}</div>
|
||||||
@@ -261,7 +262,7 @@
|
|||||||
<div class="language-list">
|
<div class="language-list">
|
||||||
{% for locale, lang_data in available_languages.items()|sort %}
|
{% 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 }}">
|
<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>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -274,103 +275,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</dialog>
|
</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='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>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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)
|
request.addfinalizer(teardown)
|
||||||
yield app
|
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
|
# Add our URL to the import page
|
||||||
test_url = url_for('test_endpoint', _external=True)
|
test_url = url_for('test_endpoint', _external=True)
|
||||||
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)
|
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
|
### test regex error handling
|
||||||
res = client.post(
|
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',
|
data={"extract_text": '/something bad\d{3/XYZ',
|
||||||
"url": test_url,
|
"url": test_url,
|
||||||
"fetch_backend": "html_requests",
|
"fetch_backend": "html_requests",
|
||||||
|
|||||||
@@ -4,47 +4,25 @@ import time
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
from flask import url_for
|
from flask import url_for
|
||||||
from loguru import logger
|
|
||||||
from .. import strtobool
|
|
||||||
from .util import wait_for_all_checks, delete_all_watches
|
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):
|
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()
|
for one in r:
|
||||||
sys_fetch_workers = int(os.getenv("FETCH_WORKERS", 10))
|
test_url = url_for('test_endpoint', content_type="text/html", content=str(one), _external=True)
|
||||||
workers = range(1, sys_fetch_workers)
|
res = client.post(
|
||||||
now = time.time()
|
url_for("imports.import_page"),
|
||||||
|
data={"urls": test_url},
|
||||||
|
follow_redirects=True
|
||||||
|
)
|
||||||
|
|
||||||
for one in workers:
|
assert b"1 Imported" in res.data
|
||||||
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)
|
|
||||||
|
|
||||||
wait_for_all_checks(client)
|
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
|
# Essentially just triggers the DB write/update
|
||||||
res = client.post(
|
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
|
assert b"Settings updated." in res.data
|
||||||
|
|
||||||
# Wait for the sync DB save to happen
|
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
|
|
||||||
json_db_file = os.path.join(live_server.app.config['DATASTORE'].datastore_path, 'url-watches.json')
|
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)
|
json_obj = json.load(f)
|
||||||
|
|
||||||
# assert the right amount of watches was found in the JSON
|
# 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"
|
assert len(json_obj['watching']) == len(r), "Correct number of watches was found in the JSON"
|
||||||
|
i=0
|
||||||
i = 0
|
|
||||||
# each one should have a history.txt containing just one line
|
# each one should have a history.txt containing just one line
|
||||||
for w in json_obj['watching'].keys():
|
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')
|
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}"
|
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
|
# Same like in model.Watch
|
||||||
with open(history_txt_index_file, "r") as f:
|
with open(history_txt_index_file, "r") as f:
|
||||||
tmp_history = dict(i.strip().split(',', 2) for i in f.readlines())
|
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
|
# Find the snapshot one
|
||||||
for fname in files_in_watch_dir:
|
for fname in files_in_watch_dir:
|
||||||
if fname != 'history.txt' and 'html' not in fname:
|
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
|
# contents should match what we requested as content returned from the test url
|
||||||
if fname.endswith('.br'):
|
with open(os.path.join(live_server.app.config['DATASTORE'].datastore_path, w, fname), 'r') as snapshot_f:
|
||||||
with open(full_snapshot_history_path, 'rb') as f:
|
contents = snapshot_f.read()
|
||||||
contents = brotli.decompress(f.read()).decode('utf-8')
|
watch_url = json_obj['watching'][w]['url']
|
||||||
else:
|
u = urlparse(watch_url)
|
||||||
with open(full_snapshot_history_path, 'r') as snapshot_f:
|
q = parse_qs(u[4])
|
||||||
contents = snapshot_f.read()
|
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"
|
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"
|
||||||
|
|
||||||
|
|||||||
@@ -25,13 +25,12 @@ def test_content_filter_live_preview(client, live_server, measure_memory_usage,
|
|||||||
|
|
||||||
test_url = url_for('test_endpoint', _external=True)
|
test_url = url_for('test_endpoint', _external=True)
|
||||||
|
|
||||||
|
res = client.post(
|
||||||
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)
|
url_for("ui.ui_views.form_quick_watch_add"),
|
||||||
res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
data={"url": test_url, "tags": ''},
|
||||||
assert b'Queued 1 watch for rechecking.' in res.data
|
follow_redirects=True
|
||||||
|
)
|
||||||
wait_for_all_checks(client)
|
uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
|
||||||
|
|
||||||
res = client.post(
|
res = client.post(
|
||||||
url_for("ui.ui_edit.edit_page", uuid=uuid),
|
url_for("ui.ui_edit.edit_page", uuid=uuid),
|
||||||
data={
|
data={
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import re
|
|||||||
from flask import url_for
|
from flask import url_for
|
||||||
from loguru import logger
|
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
|
from . util import extract_UUID_from_client
|
||||||
import logging
|
import logging
|
||||||
import base64
|
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']))
|
uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
|
||||||
screenshot_dir = os.path.join(datastore_path, str(uuid))
|
with open(os.path.join(datastore_path, str(uuid), 'last-screenshot.png'), 'wb') as f:
|
||||||
os.makedirs(screenshot_dir, exist_ok=True)
|
|
||||||
with open(os.path.join(screenshot_dir, 'last-screenshot.png'), 'wb') as f:
|
|
||||||
f.write(base64.b64decode(testimage_png))
|
f.write(base64.b64decode(testimage_png))
|
||||||
|
|
||||||
# Goto the edit page, add our ignore text
|
# 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
|
# Trigger a check
|
||||||
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||||
wait_for_all_checks(client)
|
wait_for_all_checks(client)
|
||||||
wait_for_notification_endpoint_output(datastore_path=datastore_path)
|
time.sleep(6)
|
||||||
|
|
||||||
# Check no errors were recorded
|
# Check no errors were recorded
|
||||||
res = client.get(url_for("watchlist.index"))
|
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)
|
set_more_modified_response(datastore_path=datastore_path)
|
||||||
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||||
wait_for_all_checks(client)
|
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
|
# Verify what was sent as a notification, this file should exist
|
||||||
with open(os.path.join(datastore_path, "notification.txt"), "r") as f:
|
with open(os.path.join(datastore_path, "notification.txt"), "r") as f:
|
||||||
notification_submission = f.read()
|
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
|
assert b"Updated watch." in res.data
|
||||||
|
|
||||||
wait_for_all_checks(client)
|
time.sleep(2)
|
||||||
wait_for_notification_endpoint_output(datastore_path=datastore_path)
|
|
||||||
|
|
||||||
# Verify what was sent as a notification, this file should exist
|
# Verify what was sent as a notification, this file should exist
|
||||||
with open(os.path.join(datastore_path, "notification.txt"), "r") as f:
|
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)
|
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||||
wait_for_all_checks(client)
|
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
|
# 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
|
assert res.status_code != 500
|
||||||
|
|
||||||
# Give apprise time to fire
|
# 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:
|
with open(os.path.join(datastore_path, "notification.txt"), 'r') as f:
|
||||||
x = f.read()
|
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"
|
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
|
# 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
|
######### Test global/system settings
|
||||||
res = client.post(
|
res = client.post(
|
||||||
url_for("ui.ui_notification.ajax_callback_send_notification_test")+f"/{uuid}",
|
url_for("ui.ui_notification.ajax_callback_send_notification_test")+f"/{uuid}",
|
||||||
@@ -528,11 +525,10 @@ def test_single_send_test_notification_on_watch(client, live_server, measure_mem
|
|||||||
assert 'title="Changed into">Example text:' not in x
|
assert 'title="Changed into">Example text:' not in x
|
||||||
assert 'span' not in x
|
assert 'span' not in x
|
||||||
assert 'Example text:' 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"))
|
os.unlink(os.path.join(datastore_path, "notification.txt"))
|
||||||
|
|
||||||
def _test_color_notifications(client, notification_body_token, datastore_path, word_diff_enabled = True):
|
def _test_color_notifications(client, notification_body_token, datastore_path):
|
||||||
|
|
||||||
set_original_response(datastore_path=datastore_path)
|
set_original_response(datastore_path=datastore_path)
|
||||||
|
|
||||||
@@ -551,7 +547,6 @@ def _test_color_notifications(client, notification_body_token, datastore_path, w
|
|||||||
"application-minutes_between_check": 180,
|
"application-minutes_between_check": 180,
|
||||||
"application-notification_body": notification_body_token,
|
"application-notification_body": notification_body_token,
|
||||||
"application-notification_format": "htmlcolor",
|
"application-notification_format": "htmlcolor",
|
||||||
"application-notification_html_word_diff_enabled": 'y' if word_diff_enabled else '',
|
|
||||||
"application-notification_urls": test_notification_url,
|
"application-notification_urls": test_notification_url,
|
||||||
"application-notification_title": "New ChangeDetection.io Notification - {{ watch_url }}",
|
"application-notification_title": "New ChangeDetection.io Notification - {{ watch_url }}",
|
||||||
},
|
},
|
||||||
@@ -560,29 +555,29 @@ def _test_color_notifications(client, notification_body_token, datastore_path, w
|
|||||||
assert b'Settings updated' in res.data
|
assert b'Settings updated' in res.data
|
||||||
|
|
||||||
test_url = url_for('test_endpoint', _external=True)
|
test_url = url_for('test_endpoint', _external=True)
|
||||||
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)
|
res = client.post(
|
||||||
res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
url_for("ui.ui_views.form_quick_watch_add"),
|
||||||
assert b'Queued 1 watch for rechecking.' in res.data
|
data={"url": test_url, "tags": 'nice one'},
|
||||||
|
follow_redirects=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert b"Watch added" in res.data
|
||||||
|
|
||||||
wait_for_all_checks(client)
|
wait_for_all_checks(client)
|
||||||
extras='XXX ' if word_diff_enabled else ''
|
|
||||||
set_modified_response(datastore_path=datastore_path, extras=extras)
|
set_modified_response(datastore_path=datastore_path)
|
||||||
|
|
||||||
|
|
||||||
res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||||
assert b'Queued 1 watch for rechecking.' in res.data
|
assert b'Queued 1 watch for rechecking.' in res.data
|
||||||
|
|
||||||
wait_for_all_checks(client)
|
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:
|
with open(os.path.join(datastore_path, "notification.txt"), 'r') as f:
|
||||||
contents = f.read()
|
x = f.read()
|
||||||
s = f'<span style="{HTML_CHANGED_STYLE}" role="note" aria-label="Changed text" title="Changed text">Which is across multiple lines</span><br>'
|
s = f'<span style="{HTML_CHANGED_STYLE}" role="note" aria-label="Changed text" title="Changed text">Which is across multiple lines</span><br>'
|
||||||
assert s in contents
|
assert s in x
|
||||||
if word_diff_enabled:
|
|
||||||
assert '>XXX</span>' in contents
|
|
||||||
else:
|
|
||||||
assert '>XXX</span>' not in contents
|
|
||||||
|
|
||||||
client.get(
|
client.get(
|
||||||
url_for("ui.form_delete", uuid="all"),
|
url_for("ui.form_delete", uuid="all"),
|
||||||
@@ -591,12 +586,6 @@ def _test_color_notifications(client, notification_body_token, datastore_path, w
|
|||||||
|
|
||||||
# Just checks the format of the colour notifications was correct
|
# Just checks the format of the colour notifications was correct
|
||||||
def test_html_color_notifications(client, live_server, measure_memory_usage, datastore_path):
|
def test_html_color_notifications(client, live_server, measure_memory_usage, datastore_path):
|
||||||
# Word-level diff only triggers when difflib.SequenceMatcher identifies a single-line to single-line replacement.
|
_test_color_notifications(client, '{{diff}}',datastore_path=datastore_path)
|
||||||
# If you have multiple changed lines close together, you need at least 1 unchanged content line (not empty) between them to
|
_test_color_notifications(client, '{{diff_full}}',datastore_path=datastore_path)
|
||||||
# prevent them from being grouped into a multi-line replacement that falls back to line-level diff.
|
|
||||||
|
|
||||||
_test_color_notifications(client, '{{diff}}',datastore_path=datastore_path, word_diff_enabled = True)
|
|
||||||
_test_color_notifications(client, '{{diff_full}}',datastore_path=datastore_path, word_diff_enabled = True)
|
|
||||||
|
|
||||||
_test_color_notifications(client, '{{diff}}',datastore_path=datastore_path, word_diff_enabled = False)
|
|
||||||
_test_color_notifications(client, '{{diff_full}}',datastore_path=datastore_path, word_diff_enabled = False)
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
from flask import url_for
|
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
|
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 initial checks to complete
|
||||||
wait_for_all_checks(client)
|
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
|
# Trigger a change
|
||||||
set_modified_response(datastore_path=datastore_path)
|
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)
|
res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||||
wait_for_all_checks(client)
|
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
|
# Get RSS token
|
||||||
rss_token = extract_rss_token_from_UI(client)
|
rss_token = extract_rss_token_from_UI(client)
|
||||||
assert rss_token is not None
|
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
|
assert b"Watch added" in res.data
|
||||||
|
|
||||||
wait_for_all_checks(client)
|
wait_for_all_checks(client)
|
||||||
assert wait_for_watch_history(client, min_history_count=1, timeout=10), "Initial snapshots not saved"
|
|
||||||
|
|
||||||
# Trigger changes
|
# Trigger changes
|
||||||
set_modified_response(datastore_path=datastore_path)
|
set_modified_response(datastore_path=datastore_path)
|
||||||
res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||||
wait_for_all_checks(client)
|
wait_for_all_checks(client)
|
||||||
assert wait_for_watch_history(client, min_history_count=2, timeout=10), "History not accumulated"
|
|
||||||
|
|
||||||
# Get RSS token
|
# Get RSS token
|
||||||
rss_token = extract_rss_token_from_UI(client)
|
rss_token = extract_rss_token_from_UI(client)
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from changedetectionio import html_tools
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
import html_tools
|
||||||
|
|
||||||
# test generation guide.
|
# test generation guide.
|
||||||
# 1. Do not include encoding in the xml declaration if the test object is a str type.
|
# 1. Do not include encoding in the xml declaration if the test object is a str type.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import logging
|
|||||||
import time
|
import time
|
||||||
import os
|
import os
|
||||||
|
|
||||||
def set_original_response(datastore_path, extra_title='', extras=''):
|
def set_original_response(datastore_path, extra_title=''):
|
||||||
test_return_data = f"""<html>
|
test_return_data = f"""<html>
|
||||||
<head><title>head title{extra_title}</title></head>
|
<head><title>head title{extra_title}</title></head>
|
||||||
<body>
|
<body>
|
||||||
@@ -15,9 +15,6 @@ def set_original_response(datastore_path, extra_title='', extras=''):
|
|||||||
<p>Which is across multiple lines</p>
|
<p>Which is across multiple lines</p>
|
||||||
<br>
|
<br>
|
||||||
So let's see what happens. <br>
|
So let's see what happens. <br>
|
||||||
with more text that helps word-diff if needed<br>
|
|
||||||
and more text that helps word-diff if needed<br>
|
|
||||||
and even more text {extras}that helps word-diff if needed<br>
|
|
||||||
<span class="foobar-detection" style='display:none'></span>
|
<span class="foobar-detection" style='display:none'></span>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -27,17 +24,14 @@ def set_original_response(datastore_path, extra_title='', extras=''):
|
|||||||
f.write(test_return_data)
|
f.write(test_return_data)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def set_modified_response(datastore_path, extras=''):
|
def set_modified_response(datastore_path):
|
||||||
test_return_data =f"""<html>
|
test_return_data = """<html>
|
||||||
<head><title>modified head title</title></head>
|
<head><title>modified head title</title></head>
|
||||||
<body>
|
<body>
|
||||||
Some initial text<br>
|
Some initial text<br>
|
||||||
<p>which has this one new line</p>
|
<p>which has this one new line</p>
|
||||||
<br>
|
<br>
|
||||||
So let's see what happens. <br>
|
So let's see what happens. <br>
|
||||||
with more text that helps word-diff if needed<br>
|
|
||||||
and more text that helps word-diff if needed<br>
|
|
||||||
and even more text {extras}that helps word-diff if needed<br>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
@@ -98,8 +92,8 @@ def wait_for_notification_endpoint_output(datastore_path):
|
|||||||
#@todo - could check the apprise object directly instead of looking for this file
|
#@todo - could check the apprise object directly instead of looking for this file
|
||||||
from os.path import isfile
|
from os.path import isfile
|
||||||
notification_file = os.path.join(datastore_path, "notification.txt")
|
notification_file = os.path.join(datastore_path, "notification.txt")
|
||||||
for i in range(1, 100):
|
for i in range(1, 20):
|
||||||
time.sleep(0.3)
|
time.sleep(1)
|
||||||
if isfile(notification_file):
|
if isfile(notification_file):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -170,45 +164,14 @@ def wait_for_all_checks(client=None):
|
|||||||
if q_length == 0 and not any_workers_busy:
|
if q_length == 0 and not any_workers_busy:
|
||||||
if empty_since is None:
|
if empty_since is None:
|
||||||
empty_since = time.time()
|
empty_since = time.time()
|
||||||
# Brief stabilization period for async workers
|
elif time.time() - empty_since >= 0.15: # Shorter wait
|
||||||
elif time.time() - empty_since >= 0.3:
|
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
empty_since = None
|
empty_since = None
|
||||||
|
|
||||||
attempt += 1
|
attempt += 1
|
||||||
time.sleep(0.3)
|
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
|
# Replaced by new_live_server_setup and calling per function scope in conftest.py
|
||||||
def live_server_setup(live_server):
|
def live_server_setup(live_server):
|
||||||
return True
|
return True
|
||||||
@@ -226,8 +189,6 @@ def new_live_server_setup(live_server):
|
|||||||
|
|
||||||
@live_server.app.route('/test-endpoint')
|
@live_server.app.route('/test-endpoint')
|
||||||
def test_endpoint():
|
def test_endpoint():
|
||||||
from loguru import logger
|
|
||||||
logger.debug(f"/test-endpoint hit {request}")
|
|
||||||
ctype = request.args.get('content_type')
|
ctype = request.args.get('content_type')
|
||||||
status_code = request.args.get('status_code')
|
status_code = request.args.get('status_code')
|
||||||
content = request.args.get('content') or None
|
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):
|
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 = 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.localdomain', 'cdio')
|
||||||
four_o_four_url = four_o_four_url.replace('localhost', '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"),
|
url_for("ui.form_delete", uuid="all"),
|
||||||
follow_redirects=True
|
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.
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.
@@ -7,7 +7,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PROJECT VERSION\n"
|
"Project-Id-Version: PROJECT VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||||
"POT-Creation-Date: 2026-01-03 14:31+0100\n"
|
"POT-Creation-Date: 2026-01-02 16:07+0100\n"
|
||||||
"PO-Revision-Date: 2026-01-02 15:32+0100\n"
|
"PO-Revision-Date: 2026-01-02 15:32+0100\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language: it\n"
|
"Language: it\n"
|
||||||
@@ -19,21 +19,21 @@ msgstr ""
|
|||||||
"Generated-By: Babel 2.17.0\n"
|
"Generated-By: Babel 2.17.0\n"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:241
|
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:241
|
||||||
#: changedetectionio/flask_app.py:214 changedetectionio/flask_app.py:226
|
#: changedetectionio/flask_app.py:213 changedetectionio/flask_app.py:225
|
||||||
#: changedetectionio/flask_app.py:247
|
#: changedetectionio/flask_app.py:246
|
||||||
#: changedetectionio/realtime/socket_server.py:171
|
#: changedetectionio/realtime/socket_server.py:171
|
||||||
msgid "Not yet"
|
msgid "Not yet"
|
||||||
msgstr "Non ancora"
|
msgstr "Non ancora"
|
||||||
|
|
||||||
#: changedetectionio/flask_app.py:534
|
#: changedetectionio/flask_app.py:468
|
||||||
msgid "Already logged in"
|
|
||||||
msgstr "Già autenticato"
|
|
||||||
|
|
||||||
#: changedetectionio/flask_app.py:536
|
|
||||||
msgid "You must be logged in, please log in."
|
msgid "You must be logged in, please log in."
|
||||||
msgstr "Devi essere autenticato, effettua l'accesso."
|
msgstr "Devi essere autenticato, effettua l'accesso."
|
||||||
|
|
||||||
#: changedetectionio/flask_app.py:551
|
#: changedetectionio/flask_app.py:495
|
||||||
|
msgid "Already logged in"
|
||||||
|
msgstr "Già autenticato"
|
||||||
|
|
||||||
|
#: changedetectionio/flask_app.py:522
|
||||||
msgid "Incorrect password"
|
msgid "Incorrect password"
|
||||||
msgstr "Password errata"
|
msgstr "Password errata"
|
||||||
|
|
||||||
@@ -175,15 +175,16 @@ msgstr "Valore non valido."
|
|||||||
|
|
||||||
#: changedetectionio/forms.py:732
|
#: changedetectionio/forms.py:732
|
||||||
msgid "Watch"
|
msgid "Watch"
|
||||||
msgstr "Monitora"
|
msgstr "Osserva"
|
||||||
|
|
||||||
#: changedetectionio/forms.py:733 changedetectionio/forms.py:766
|
#: changedetectionio/forms.py:733 changedetectionio/forms.py:766
|
||||||
msgid "Processor"
|
msgid "Processor"
|
||||||
msgstr "Processore"
|
msgstr "Processore"
|
||||||
|
|
||||||
#: changedetectionio/forms.py:734
|
#: changedetectionio/forms.py:734
|
||||||
|
#, fuzzy
|
||||||
msgid "Edit > Watch"
|
msgid "Edit > Watch"
|
||||||
msgstr "Modifica > Monitora"
|
msgstr "Modifica prima poi Monitora"
|
||||||
|
|
||||||
#: changedetectionio/forms.py:747 changedetectionio/forms.py:994
|
#: changedetectionio/forms.py:747 changedetectionio/forms.py:994
|
||||||
msgid "Fetch Method"
|
msgid "Fetch Method"
|
||||||
@@ -414,8 +415,9 @@ msgid "Invalid template syntax in \"%(header)s\" header: %(error)s"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: changedetectionio/forms.py:920 changedetectionio/forms.py:932
|
#: changedetectionio/forms.py:920 changedetectionio/forms.py:932
|
||||||
|
#, fuzzy
|
||||||
msgid "Name"
|
msgid "Name"
|
||||||
msgstr "Nome"
|
msgstr "Riattiva audio"
|
||||||
|
|
||||||
#: changedetectionio/forms.py:921
|
#: changedetectionio/forms.py:921
|
||||||
msgid "Proxy URL"
|
msgid "Proxy URL"
|
||||||
@@ -438,8 +440,9 @@ msgid "Plaintext requests"
|
|||||||
msgstr "Richieste in chiaro"
|
msgstr "Richieste in chiaro"
|
||||||
|
|
||||||
#: changedetectionio/forms.py:946
|
#: changedetectionio/forms.py:946
|
||||||
|
#, fuzzy
|
||||||
msgid "Chrome requests"
|
msgid "Chrome requests"
|
||||||
msgstr "Richieste Chrome"
|
msgstr "Richiesta"
|
||||||
|
|
||||||
#: changedetectionio/forms.py:952
|
#: changedetectionio/forms.py:952
|
||||||
msgid "Default proxy"
|
msgid "Default proxy"
|
||||||
@@ -494,8 +497,9 @@ msgid "API access token security check enabled"
|
|||||||
msgstr "Controllo sicurezza token API attivo"
|
msgstr "Controllo sicurezza token API attivo"
|
||||||
|
|
||||||
#: changedetectionio/forms.py:989
|
#: changedetectionio/forms.py:989
|
||||||
|
#, fuzzy
|
||||||
msgid "Notification base URL override"
|
msgid "Notification base URL override"
|
||||||
msgstr "URL base notifiche"
|
msgstr "Notifiche"
|
||||||
|
|
||||||
#: changedetectionio/forms.py:993
|
#: changedetectionio/forms.py:993
|
||||||
msgid "Treat empty pages as a change?"
|
msgid "Treat empty pages as a change?"
|
||||||
@@ -601,8 +605,6 @@ msgid "Backups were deleted."
|
|||||||
msgstr "I backup sono stati eliminati."
|
msgstr "I backup sono stati eliminati."
|
||||||
|
|
||||||
#: changedetectionio/blueprint/backups/templates/overview.html:6
|
#: changedetectionio/blueprint/backups/templates/overview.html:6
|
||||||
#: changedetectionio/templates/base.html:282
|
|
||||||
#: changedetectionio/templates/base.html:290
|
|
||||||
msgid "Backups"
|
msgid "Backups"
|
||||||
msgstr "Backup"
|
msgstr "Backup"
|
||||||
|
|
||||||
@@ -1022,7 +1024,7 @@ msgstr ""
|
|||||||
|
|
||||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html:31
|
#: changedetectionio/blueprint/tags/templates/groups-overview.html:31
|
||||||
msgid "# Watches"
|
msgid "# Watches"
|
||||||
msgstr "# Monitoraggi"
|
msgstr ""
|
||||||
|
|
||||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html:32
|
#: changedetectionio/blueprint/tags/templates/groups-overview.html:32
|
||||||
msgid "Tag / Label name"
|
msgid "Tag / Label name"
|
||||||
@@ -1269,7 +1271,7 @@ msgstr ""
|
|||||||
|
|
||||||
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:17
|
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:17
|
||||||
msgid "Confirmation text"
|
msgid "Confirmation text"
|
||||||
msgstr "Testo di conferma"
|
msgstr ""
|
||||||
|
|
||||||
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:27
|
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:27
|
||||||
msgid "Type in the word"
|
msgid "Type in the word"
|
||||||
@@ -1288,8 +1290,7 @@ msgid "Clear History!"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:39
|
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:39
|
||||||
#: changedetectionio/templates/base.html:379
|
#: changedetectionio/templates/base.html:274
|
||||||
#: changedetectionio/templates/base.html:399
|
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "Annulla"
|
msgstr "Annulla"
|
||||||
|
|
||||||
@@ -1319,11 +1320,11 @@ msgstr ""
|
|||||||
|
|
||||||
#: changedetectionio/blueprint/ui/templates/diff.html:53
|
#: changedetectionio/blueprint/ui/templates/diff.html:53
|
||||||
msgid "Words"
|
msgid "Words"
|
||||||
msgstr "Parole"
|
msgstr ""
|
||||||
|
|
||||||
#: changedetectionio/blueprint/ui/templates/diff.html:57
|
#: changedetectionio/blueprint/ui/templates/diff.html:57
|
||||||
msgid "Lines"
|
msgid "Lines"
|
||||||
msgstr "Righe"
|
msgstr ""
|
||||||
|
|
||||||
#: changedetectionio/blueprint/ui/templates/diff.html:61
|
#: changedetectionio/blueprint/ui/templates/diff.html:61
|
||||||
msgid "Ignore Whitespace"
|
msgid "Ignore Whitespace"
|
||||||
@@ -1331,7 +1332,7 @@ msgstr ""
|
|||||||
|
|
||||||
#: changedetectionio/blueprint/ui/templates/diff.html:65
|
#: changedetectionio/blueprint/ui/templates/diff.html:65
|
||||||
msgid "Same/non-changed"
|
msgid "Same/non-changed"
|
||||||
msgstr "Uguale/non modificato"
|
msgstr ""
|
||||||
|
|
||||||
#: changedetectionio/blueprint/ui/templates/diff.html:69
|
#: changedetectionio/blueprint/ui/templates/diff.html:69
|
||||||
msgid "Removed"
|
msgid "Removed"
|
||||||
@@ -1373,12 +1374,12 @@ msgstr ""
|
|||||||
#: changedetectionio/blueprint/ui/templates/diff.html:97
|
#: changedetectionio/blueprint/ui/templates/diff.html:97
|
||||||
#: changedetectionio/blueprint/ui/templates/preview.html:45
|
#: changedetectionio/blueprint/ui/templates/preview.html:45
|
||||||
msgid "Error Text"
|
msgid "Error Text"
|
||||||
msgstr "Testo dell'errore"
|
msgstr ""
|
||||||
|
|
||||||
#: changedetectionio/blueprint/ui/templates/diff.html:98
|
#: changedetectionio/blueprint/ui/templates/diff.html:98
|
||||||
#: changedetectionio/blueprint/ui/templates/preview.html:47
|
#: changedetectionio/blueprint/ui/templates/preview.html:47
|
||||||
msgid "Error Screenshot"
|
msgid "Error Screenshot"
|
||||||
msgstr "Screenshot dell'errore"
|
msgstr ""
|
||||||
|
|
||||||
#: changedetectionio/blueprint/ui/templates/diff.html:99
|
#: changedetectionio/blueprint/ui/templates/diff.html:99
|
||||||
#: changedetectionio/blueprint/ui/templates/preview.html:50
|
#: changedetectionio/blueprint/ui/templates/preview.html:50
|
||||||
@@ -1388,7 +1389,7 @@ msgstr "Testo"
|
|||||||
#: changedetectionio/blueprint/ui/templates/diff.html:100
|
#: changedetectionio/blueprint/ui/templates/diff.html:100
|
||||||
#: changedetectionio/blueprint/ui/templates/preview.html:51
|
#: changedetectionio/blueprint/ui/templates/preview.html:51
|
||||||
msgid "Current screenshot"
|
msgid "Current screenshot"
|
||||||
msgstr "Screenshot corrente"
|
msgstr ""
|
||||||
|
|
||||||
#: changedetectionio/blueprint/ui/templates/diff.html:101
|
#: changedetectionio/blueprint/ui/templates/diff.html:101
|
||||||
msgid "Extract Data"
|
msgid "Extract Data"
|
||||||
@@ -1443,7 +1444,7 @@ msgstr ""
|
|||||||
#: changedetectionio/blueprint/ui/templates/diff.html:149
|
#: changedetectionio/blueprint/ui/templates/diff.html:149
|
||||||
#: changedetectionio/blueprint/ui/templates/preview.html:86
|
#: changedetectionio/blueprint/ui/templates/preview.html:86
|
||||||
msgid "Current screenshot from most recent request"
|
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/diff.html:151
|
||||||
#: changedetectionio/blueprint/ui/templates/preview.html:88
|
#: changedetectionio/blueprint/ui/templates/preview.html:88
|
||||||
@@ -1911,7 +1912,7 @@ msgstr "Monitora questo URL!"
|
|||||||
|
|
||||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:35
|
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:35
|
||||||
msgid "Edit first then Watch"
|
msgid "Edit first then Watch"
|
||||||
msgstr "Modifica > Monitora"
|
msgstr "Modifica prima poi Monitora"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:45
|
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:45
|
||||||
msgid "Create a shareable link"
|
msgid "Create a shareable link"
|
||||||
@@ -2021,7 +2022,7 @@ msgstr "Modifica"
|
|||||||
|
|
||||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:130
|
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:130
|
||||||
msgid "No website 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 "Nessun monitoraggio configurato, aggiungi un URL nella casella sopra, oppure"
|
msgstr ""
|
||||||
|
|
||||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:130
|
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:130
|
||||||
msgid "import a list"
|
msgid "import a list"
|
||||||
@@ -2048,7 +2049,7 @@ msgid "No information"
|
|||||||
msgstr "Nessuna informazione"
|
msgstr "Nessuna informazione"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:234
|
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:234
|
||||||
#: changedetectionio/templates/base.html:353
|
#: changedetectionio/templates/base.html:248
|
||||||
msgid "Checking now"
|
msgid "Checking now"
|
||||||
msgstr "Controllo in corso"
|
msgstr "Controllo in corso"
|
||||||
|
|
||||||
@@ -2115,9 +2116,7 @@ msgstr "Valore riquadro di selezione troppo lungo"
|
|||||||
|
|
||||||
#: changedetectionio/processors/image_ssim_diff/forms.py:23
|
#: changedetectionio/processors/image_ssim_diff/forms.py:23
|
||||||
msgid "Bounding box must be in format: x,y,width,height (integers only)"
|
msgid "Bounding box must be in format: x,y,width,height (integers only)"
|
||||||
msgstr ""
|
msgstr "Il riquadro deve essere nel formato: x,y,larghezza,altezza (solo numeri interi)"
|
||||||
"Il riquadro deve essere nel formato: x,y,larghezza,altezza (solo numeri "
|
|
||||||
"interi)"
|
|
||||||
|
|
||||||
#: changedetectionio/processors/image_ssim_diff/forms.py:29
|
#: changedetectionio/processors/image_ssim_diff/forms.py:29
|
||||||
msgid "Bounding box values must be non-negative"
|
msgid "Bounding box values must be non-negative"
|
||||||
@@ -2170,9 +2169,7 @@ msgstr "Rilevamento modifiche screenshot visivi"
|
|||||||
|
|
||||||
#: changedetectionio/processors/image_ssim_diff/processor.py:22
|
#: changedetectionio/processors/image_ssim_diff/processor.py:22
|
||||||
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 ""
|
msgstr "Confronta screenshot con algoritmo OpenCV veloce, 10-100x più veloce di SSIM"
|
||||||
"Confronta screenshot con algoritmo OpenCV veloce, 10-100x più veloce di "
|
|
||||||
"SSIM"
|
|
||||||
|
|
||||||
#: changedetectionio/processors/restock_diff/forms.py:15
|
#: changedetectionio/processors/restock_diff/forms.py:15
|
||||||
msgid "Re-stock detection"
|
msgid "Re-stock detection"
|
||||||
@@ -2236,233 +2233,59 @@ msgstr "Modifiche testo/HTML, JSON e PDF"
|
|||||||
msgid "Detects all text changes where possible"
|
msgid "Detects all text changes where possible"
|
||||||
msgstr "Rileva tutte le modifiche di testo possibili"
|
msgstr "Rileva tutte le modifiche di testo possibili"
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:25
|
#: changedetectionio/templates/base.html:77
|
||||||
msgid "Entry"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:153
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Actions"
|
|
||||||
msgstr "Condizioni"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:172
|
|
||||||
msgid "Add a row/rule after"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:173
|
|
||||||
msgid "Remove this row/rule"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:174
|
|
||||||
msgid "Verify this rule against current snapshot"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:184
|
|
||||||
msgid ""
|
|
||||||
"Error - This watch needs Chrome (with playwright/sockpuppetbrowser), but "
|
|
||||||
"Chrome based fetching is not enabled."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:184
|
|
||||||
msgid "Alternatively try our"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:184
|
|
||||||
msgid ""
|
|
||||||
"very affordable subscription based service which has all this setup for "
|
|
||||||
"you"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:185
|
|
||||||
msgid "You may need to"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:185
|
|
||||||
msgid "Enable playwright environment variable"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:185
|
|
||||||
msgid "and uncomment the"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:185
|
|
||||||
#, fuzzy
|
|
||||||
msgid "in the"
|
|
||||||
msgstr "Silenzia"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:185
|
|
||||||
#, fuzzy
|
|
||||||
msgid "file"
|
|
||||||
msgstr "Titolo"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:240
|
|
||||||
msgid "Set a hourly/week day schedule"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:247
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Schedule time limits"
|
|
||||||
msgstr "Tempo di ricontrollo (minuti)"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:248
|
|
||||||
msgid "Business hours"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:249
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Weekends"
|
|
||||||
msgstr "Settimane"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:250
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Reset"
|
|
||||||
msgstr "Richiesta"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:259
|
|
||||||
msgid ""
|
|
||||||
"Warning, one or more of your 'days' has a duration that would extend into"
|
|
||||||
" the next day."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:260
|
|
||||||
msgid "This could have unintended consequences."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:270
|
|
||||||
msgid "More help and examples about using the scheduler"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:275
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Want to use a time schedule?"
|
|
||||||
msgstr "Usa pianificazione oraria"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:275
|
|
||||||
msgid "First confirm/save your Time Zone Settings"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:284
|
|
||||||
msgid ""
|
|
||||||
"Triggers a change if this text appears, AND something changed in the "
|
|
||||||
"document."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:284
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Triggered text"
|
|
||||||
msgstr "Ignora testo"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:285
|
|
||||||
msgid "Ignored for calculating changes, but still shown."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:285
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Ignored text"
|
|
||||||
msgstr "Ignora testo"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:286
|
|
||||||
#, fuzzy
|
|
||||||
msgid "No change-detection will occur because this text exists."
|
|
||||||
msgstr "Blocca rilevamento modifiche quando il testo corrisponde"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:286
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Blocked text"
|
|
||||||
msgstr "Ignora testo"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:78
|
|
||||||
#: changedetectionio/templates/base.html:168
|
|
||||||
msgid "GROUPS"
|
msgid "GROUPS"
|
||||||
msgstr "GRUPPI"
|
msgstr "GRUPPI"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:81
|
#: changedetectionio/templates/base.html:80
|
||||||
#: changedetectionio/templates/base.html:169
|
|
||||||
msgid "SETTINGS"
|
msgid "SETTINGS"
|
||||||
msgstr "IMPOSTAZIONI"
|
msgstr "IMPOSTAZIONI"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:84
|
#: changedetectionio/templates/base.html:83
|
||||||
#: changedetectionio/templates/base.html:170
|
|
||||||
msgid "IMPORT"
|
msgid "IMPORT"
|
||||||
msgstr "IMPORTA"
|
msgstr "IMPORTA"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:87
|
#: changedetectionio/templates/base.html:86
|
||||||
#: changedetectionio/templates/base.html:171
|
|
||||||
msgid "BACKUPS"
|
msgid "BACKUPS"
|
||||||
msgstr "BACKUP"
|
msgstr "BACKUP"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:91
|
#: changedetectionio/templates/base.html:90
|
||||||
#: changedetectionio/templates/base.html:173
|
|
||||||
msgid "EDIT"
|
msgid "EDIT"
|
||||||
msgstr "MODIFICA"
|
msgstr "MODIFICA"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:101
|
#: changedetectionio/templates/base.html:100
|
||||||
#: changedetectionio/templates/base.html:177
|
|
||||||
msgid "LOG OUT"
|
msgid "LOG OUT"
|
||||||
msgstr "ESCI"
|
msgstr "ESCI"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:108
|
#: changedetectionio/templates/base.html:109
|
||||||
msgid "Search, or Use Alt+S Key"
|
msgid "Search, or Use Alt+S Key"
|
||||||
msgstr "Cerca, o usa il tasto Alt+S"
|
msgstr "Cerca, o usa il tasto Alt+S"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:114
|
#: changedetectionio/templates/base.html:116
|
||||||
msgid "Toggle Light/Dark Mode"
|
msgid "Toggle Light/Dark Mode"
|
||||||
msgstr "Cambia Modalità Chiaro/Scuro"
|
msgstr "Cambia Modalità Chiaro/Scuro"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:115
|
#: changedetectionio/templates/base.html:117
|
||||||
msgid "Toggle light/dark mode"
|
msgid "Toggle light/dark mode"
|
||||||
msgstr "Cambia modalità chiaro/scuro"
|
msgstr "Cambia modalità chiaro/scuro"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:125
|
#: changedetectionio/templates/base.html:127
|
||||||
msgid "Change Language"
|
msgid "Change Language"
|
||||||
msgstr "Cambia Lingua"
|
msgstr "Cambia Lingua"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:126
|
#: changedetectionio/templates/base.html:128
|
||||||
msgid "Change language"
|
msgid "Change language"
|
||||||
msgstr "Cambia lingua"
|
msgstr "Cambia lingua"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:253
|
#: changedetectionio/templates/base.html:249
|
||||||
#, fuzzy
|
|
||||||
msgid "Watch List"
|
|
||||||
msgstr "Lista Monitoraggi"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:258
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Watches"
|
|
||||||
msgstr "Monitoraggi"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:261
|
|
||||||
msgid "Queue Status"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:270
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Queue"
|
|
||||||
msgstr "In coda"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:274
|
|
||||||
#: changedetectionio/templates/base.html:279
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Settings"
|
|
||||||
msgstr "IMPOSTAZIONI"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:293
|
|
||||||
msgid "Sitemap Crawler"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:318
|
|
||||||
msgid "Sitemap"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:354
|
|
||||||
msgid "Real-time updates offline"
|
msgid "Real-time updates offline"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:364
|
#: changedetectionio/templates/base.html:259
|
||||||
msgid "Select Language"
|
msgid "Select Language"
|
||||||
msgstr "Seleziona Lingua"
|
msgstr "Seleziona Lingua"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:375
|
#: changedetectionio/templates/base.html:270
|
||||||
msgid ""
|
msgid ""
|
||||||
"Language support is in beta, please help us improve by opening a PR on "
|
"Language support is in beta, please help us improve by opening a PR on "
|
||||||
"GitHub with any updates."
|
"GitHub with any updates."
|
||||||
@@ -2470,30 +2293,11 @@ msgstr ""
|
|||||||
"Il supporto linguistico è in versione beta, aiutaci a migliorare aprendo "
|
"Il supporto linguistico è in versione beta, aiutaci a migliorare aprendo "
|
||||||
"una PR su GitHub con eventuali aggiornamenti."
|
"una PR su GitHub con eventuali aggiornamenti."
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:387
|
#: changedetectionio/templates/login.html:10
|
||||||
#: changedetectionio/templates/base.html:400
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Search"
|
|
||||||
msgstr "Ricerca in corso"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:392
|
|
||||||
msgid "URL or Title"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:392
|
|
||||||
#, fuzzy
|
|
||||||
msgid "in"
|
|
||||||
msgstr "Info"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:393
|
|
||||||
msgid "Enter search term..."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/login.html:11
|
|
||||||
msgid "Password"
|
msgid "Password"
|
||||||
msgstr "Password"
|
msgstr "Password"
|
||||||
|
|
||||||
#: changedetectionio/templates/login.html:17
|
#: changedetectionio/templates/login.html:16
|
||||||
msgid "Login"
|
msgid "Login"
|
||||||
msgstr "Accedi"
|
msgstr "Accedi"
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -7,7 +7,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PROJECT VERSION\n"
|
"Project-Id-Version: PROJECT VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||||
"POT-Creation-Date: 2026-01-03 14:31+0100\n"
|
"POT-Creation-Date: 2026-01-02 16:07+0100\n"
|
||||||
"PO-Revision-Date: 2026-01-02 11:40+0100\n"
|
"PO-Revision-Date: 2026-01-02 11:40+0100\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language: ko\n"
|
"Language: ko\n"
|
||||||
@@ -19,21 +19,21 @@ msgstr ""
|
|||||||
"Generated-By: Babel 2.17.0\n"
|
"Generated-By: Babel 2.17.0\n"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:241
|
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:241
|
||||||
#: changedetectionio/flask_app.py:214 changedetectionio/flask_app.py:226
|
#: changedetectionio/flask_app.py:213 changedetectionio/flask_app.py:225
|
||||||
#: changedetectionio/flask_app.py:247
|
#: changedetectionio/flask_app.py:246
|
||||||
#: changedetectionio/realtime/socket_server.py:171
|
#: changedetectionio/realtime/socket_server.py:171
|
||||||
msgid "Not yet"
|
msgid "Not yet"
|
||||||
msgstr "아직 아님"
|
msgstr "아직 아님"
|
||||||
|
|
||||||
#: changedetectionio/flask_app.py:534
|
#: changedetectionio/flask_app.py:468
|
||||||
msgid "Already logged in"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/flask_app.py:536
|
|
||||||
msgid "You must be logged in, please log in."
|
msgid "You must be logged in, please log in."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: changedetectionio/flask_app.py:551
|
#: changedetectionio/flask_app.py:495
|
||||||
|
msgid "Already logged in"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: changedetectionio/flask_app.py:522
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
msgid "Incorrect password"
|
msgid "Incorrect password"
|
||||||
msgstr "비밀번호"
|
msgstr "비밀번호"
|
||||||
@@ -176,16 +176,18 @@ msgid "Invalid value."
|
|||||||
msgstr "값이 잘못되었습니다."
|
msgstr "값이 잘못되었습니다."
|
||||||
|
|
||||||
#: changedetectionio/forms.py:732
|
#: changedetectionio/forms.py:732
|
||||||
|
#, fuzzy
|
||||||
msgid "Watch"
|
msgid "Watch"
|
||||||
msgstr "모니터"
|
msgstr "# 시계"
|
||||||
|
|
||||||
#: changedetectionio/forms.py:733 changedetectionio/forms.py:766
|
#: changedetectionio/forms.py:733 changedetectionio/forms.py:766
|
||||||
msgid "Processor"
|
msgid "Processor"
|
||||||
msgstr "프로세서"
|
msgstr "프로세서"
|
||||||
|
|
||||||
#: changedetectionio/forms.py:734
|
#: changedetectionio/forms.py:734
|
||||||
|
#, fuzzy
|
||||||
msgid "Edit > Watch"
|
msgid "Edit > Watch"
|
||||||
msgstr "편집 > 모니터"
|
msgstr "먼저 편집한 다음 보기"
|
||||||
|
|
||||||
#: changedetectionio/forms.py:747 changedetectionio/forms.py:994
|
#: changedetectionio/forms.py:747 changedetectionio/forms.py:994
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
@@ -361,7 +363,7 @@ msgstr "구하다"
|
|||||||
|
|
||||||
#: changedetectionio/forms.py:829
|
#: changedetectionio/forms.py:829
|
||||||
msgid "Proxy"
|
msgid "Proxy"
|
||||||
msgstr "프록시"
|
msgstr "대리"
|
||||||
|
|
||||||
#: changedetectionio/forms.py:831
|
#: changedetectionio/forms.py:831
|
||||||
msgid "Send a notification when the filter can no longer be found on the page"
|
msgid "Send a notification when the filter can no longer be found on the page"
|
||||||
@@ -372,7 +374,7 @@ msgstr "페이지에서 필터를 더 이상 찾을 수 없으면 알림 보내
|
|||||||
#: changedetectionio/blueprint/ui/templates/edit.html:59
|
#: changedetectionio/blueprint/ui/templates/edit.html:59
|
||||||
#: changedetectionio/forms.py:832
|
#: changedetectionio/forms.py:832
|
||||||
msgid "Notifications"
|
msgid "Notifications"
|
||||||
msgstr "알림"
|
msgstr "정보 없음"
|
||||||
|
|
||||||
#: changedetectionio/forms.py:832
|
#: changedetectionio/forms.py:832
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
@@ -425,8 +427,9 @@ msgid "Invalid template syntax in \"%(header)s\" header: %(error)s"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: changedetectionio/forms.py:920 changedetectionio/forms.py:932
|
#: changedetectionio/forms.py:920 changedetectionio/forms.py:932
|
||||||
|
#, fuzzy
|
||||||
msgid "Name"
|
msgid "Name"
|
||||||
msgstr "이름"
|
msgstr "음소거 해제"
|
||||||
|
|
||||||
#: changedetectionio/forms.py:921
|
#: changedetectionio/forms.py:921
|
||||||
msgid "Proxy URL"
|
msgid "Proxy URL"
|
||||||
@@ -449,8 +452,9 @@ msgid "Plaintext requests"
|
|||||||
msgstr "일반 텍스트 요청"
|
msgstr "일반 텍스트 요청"
|
||||||
|
|
||||||
#: changedetectionio/forms.py:946
|
#: changedetectionio/forms.py:946
|
||||||
|
#, fuzzy
|
||||||
msgid "Chrome requests"
|
msgid "Chrome requests"
|
||||||
msgstr "Chrome 요청"
|
msgstr "요구"
|
||||||
|
|
||||||
#: changedetectionio/forms.py:952
|
#: changedetectionio/forms.py:952
|
||||||
msgid "Default proxy"
|
msgid "Default proxy"
|
||||||
@@ -507,8 +511,9 @@ msgid "API access token security check enabled"
|
|||||||
msgstr "API 액세스 토큰 보안 확인이 활성화되었습니다."
|
msgstr "API 액세스 토큰 보안 확인이 활성화되었습니다."
|
||||||
|
|
||||||
#: changedetectionio/forms.py:989
|
#: changedetectionio/forms.py:989
|
||||||
|
#, fuzzy
|
||||||
msgid "Notification base URL override"
|
msgid "Notification base URL override"
|
||||||
msgstr "알림 기본 URL"
|
msgstr "알림 경고 수"
|
||||||
|
|
||||||
#: changedetectionio/forms.py:993
|
#: changedetectionio/forms.py:993
|
||||||
msgid "Treat empty pages as a change?"
|
msgid "Treat empty pages as a change?"
|
||||||
@@ -617,8 +622,6 @@ msgid "Backups were deleted."
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: changedetectionio/blueprint/backups/templates/overview.html:6
|
#: changedetectionio/blueprint/backups/templates/overview.html:6
|
||||||
#: changedetectionio/templates/base.html:282
|
|
||||||
#: changedetectionio/templates/base.html:290
|
|
||||||
msgid "Backups"
|
msgid "Backups"
|
||||||
msgstr "백업"
|
msgstr "백업"
|
||||||
|
|
||||||
@@ -642,11 +645,11 @@ msgstr "백업을 찾을 수 없습니다."
|
|||||||
|
|
||||||
#: changedetectionio/blueprint/backups/templates/overview.html:28
|
#: changedetectionio/blueprint/backups/templates/overview.html:28
|
||||||
msgid "Create backup"
|
msgid "Create backup"
|
||||||
msgstr "백업 생성"
|
msgstr "백업"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/backups/templates/overview.html:30
|
#: changedetectionio/blueprint/backups/templates/overview.html:30
|
||||||
msgid "Remove backups"
|
msgid "Remove backups"
|
||||||
msgstr "백업 삭제"
|
msgstr "백업"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/imports/importer.py:45
|
#: changedetectionio/blueprint/imports/importer.py:45
|
||||||
msgid ""
|
msgid ""
|
||||||
@@ -854,7 +857,7 @@ msgstr "일반적인"
|
|||||||
|
|
||||||
#: changedetectionio/blueprint/settings/templates/settings.html:23
|
#: changedetectionio/blueprint/settings/templates/settings.html:23
|
||||||
msgid "Fetching"
|
msgid "Fetching"
|
||||||
msgstr "가져오기"
|
msgstr "수색"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/settings/templates/settings.html:24
|
#: changedetectionio/blueprint/settings/templates/settings.html:24
|
||||||
msgid "Global Filters"
|
msgid "Global Filters"
|
||||||
@@ -882,7 +885,7 @@ msgstr "보안 문자 및 프록시"
|
|||||||
|
|
||||||
#: changedetectionio/blueprint/settings/templates/settings.html:35
|
#: changedetectionio/blueprint/settings/templates/settings.html:35
|
||||||
msgid "Info"
|
msgid "Info"
|
||||||
msgstr "정보"
|
msgstr "추가 정보"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/settings/templates/settings.html:46
|
#: changedetectionio/blueprint/settings/templates/settings.html:46
|
||||||
msgid "Default recheck time for all watches, current system minimum is"
|
msgid "Default recheck time for all watches, current system minimum is"
|
||||||
@@ -910,7 +913,7 @@ msgstr "활성화된 플러그인이 없습니다."
|
|||||||
|
|
||||||
#: changedetectionio/blueprint/settings/templates/settings.html:405
|
#: changedetectionio/blueprint/settings/templates/settings.html:405
|
||||||
msgid "Back"
|
msgid "Back"
|
||||||
msgstr "뒤로"
|
msgstr "백업"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/settings/templates/settings.html:406
|
#: changedetectionio/blueprint/settings/templates/settings.html:406
|
||||||
msgid "Clear Snapshot History"
|
msgid "Clear Snapshot History"
|
||||||
@@ -1033,7 +1036,7 @@ msgstr ""
|
|||||||
|
|
||||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html:31
|
#: changedetectionio/blueprint/tags/templates/groups-overview.html:31
|
||||||
msgid "# Watches"
|
msgid "# Watches"
|
||||||
msgstr "# 모니터"
|
msgstr "# 시계"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html:32
|
#: changedetectionio/blueprint/tags/templates/groups-overview.html:32
|
||||||
msgid "Tag / Label name"
|
msgid "Tag / Label name"
|
||||||
@@ -1285,7 +1288,7 @@ msgstr "먼저 링크하세요."
|
|||||||
|
|
||||||
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:17
|
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:17
|
||||||
msgid "Confirmation text"
|
msgid "Confirmation text"
|
||||||
msgstr "확인 텍스트"
|
msgstr "정보 없음"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:27
|
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:27
|
||||||
msgid "Type in the word"
|
msgid "Type in the word"
|
||||||
@@ -1304,8 +1307,7 @@ msgid "Clear History!"
|
|||||||
msgstr "기록 지우기"
|
msgstr "기록 지우기"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:39
|
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:39
|
||||||
#: changedetectionio/templates/base.html:379
|
#: changedetectionio/templates/base.html:274
|
||||||
#: changedetectionio/templates/base.html:399
|
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "취소"
|
msgstr "취소"
|
||||||
|
|
||||||
@@ -1335,11 +1337,11 @@ msgstr "에게"
|
|||||||
|
|
||||||
#: changedetectionio/blueprint/ui/templates/diff.html:53
|
#: changedetectionio/blueprint/ui/templates/diff.html:53
|
||||||
msgid "Words"
|
msgid "Words"
|
||||||
msgstr "단어"
|
msgstr "비밀번호"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/ui/templates/diff.html:57
|
#: changedetectionio/blueprint/ui/templates/diff.html:57
|
||||||
msgid "Lines"
|
msgid "Lines"
|
||||||
msgstr "줄"
|
msgstr "로그인"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/ui/templates/diff.html:61
|
#: changedetectionio/blueprint/ui/templates/diff.html:61
|
||||||
msgid "Ignore Whitespace"
|
msgid "Ignore Whitespace"
|
||||||
@@ -1347,7 +1349,7 @@ msgstr "공백 무시"
|
|||||||
|
|
||||||
#: changedetectionio/blueprint/ui/templates/diff.html:65
|
#: changedetectionio/blueprint/ui/templates/diff.html:65
|
||||||
msgid "Same/non-changed"
|
msgid "Same/non-changed"
|
||||||
msgstr "동일/변경되지 않음"
|
msgstr "변경됨"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/ui/templates/diff.html:69
|
#: changedetectionio/blueprint/ui/templates/diff.html:69
|
||||||
msgid "Removed"
|
msgid "Removed"
|
||||||
@@ -1488,7 +1490,7 @@ msgstr "정황"
|
|||||||
|
|
||||||
#: changedetectionio/blueprint/ui/templates/edit.html:60
|
#: changedetectionio/blueprint/ui/templates/edit.html:60
|
||||||
msgid "Stats"
|
msgid "Stats"
|
||||||
msgstr "통계"
|
msgstr "설정"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/ui/templates/edit.html:73
|
#: changedetectionio/blueprint/ui/templates/edit.html:73
|
||||||
#: changedetectionio/blueprint/ui/templates/edit.html:313
|
#: changedetectionio/blueprint/ui/templates/edit.html:313
|
||||||
@@ -1923,11 +1925,11 @@ msgstr "새로운 웹 페이지 변경 감지 감시 추가"
|
|||||||
|
|
||||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:34
|
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:34
|
||||||
msgid "Watch this URL!"
|
msgid "Watch this URL!"
|
||||||
msgstr "이 URL 모니터!"
|
msgstr "이 URL을 시청하세요!"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:35
|
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:35
|
||||||
msgid "Edit first then Watch"
|
msgid "Edit first then Watch"
|
||||||
msgstr "편집 후 모니터"
|
msgstr "먼저 편집한 다음 보기"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:45
|
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:45
|
||||||
msgid "Create a shareable link"
|
msgid "Create a shareable link"
|
||||||
@@ -2064,7 +2066,7 @@ msgid "No information"
|
|||||||
msgstr "정보 없음"
|
msgstr "정보 없음"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:234
|
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:234
|
||||||
#: changedetectionio/templates/base.html:353
|
#: changedetectionio/templates/base.html:248
|
||||||
msgid "Checking now"
|
msgid "Checking now"
|
||||||
msgstr "지금 확인 중"
|
msgstr "지금 확인 중"
|
||||||
|
|
||||||
@@ -2074,7 +2076,7 @@ msgstr "대기 중"
|
|||||||
|
|
||||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:250
|
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:250
|
||||||
msgid "History"
|
msgid "History"
|
||||||
msgstr "기록"
|
msgstr "역사"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:251
|
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:251
|
||||||
msgid "Preview"
|
msgid "Preview"
|
||||||
@@ -2250,264 +2252,69 @@ msgstr "웹페이지 텍스트/HTML, JSON 및 PDF 변경"
|
|||||||
msgid "Detects all text changes where possible"
|
msgid "Detects all text changes where possible"
|
||||||
msgstr "가능한 경우 모든 텍스트 변경 사항을 감지합니다."
|
msgstr "가능한 경우 모든 텍스트 변경 사항을 감지합니다."
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:25
|
#: changedetectionio/templates/base.html:77
|
||||||
msgid "Entry"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:153
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Actions"
|
|
||||||
msgstr "정황"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:172
|
|
||||||
msgid "Add a row/rule after"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:173
|
|
||||||
msgid "Remove this row/rule"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:174
|
|
||||||
msgid "Verify this rule against current snapshot"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:184
|
|
||||||
msgid ""
|
|
||||||
"Error - This watch needs Chrome (with playwright/sockpuppetbrowser), but "
|
|
||||||
"Chrome based fetching is not enabled."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:184
|
|
||||||
msgid "Alternatively try our"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:184
|
|
||||||
msgid ""
|
|
||||||
"very affordable subscription based service which has all this setup for "
|
|
||||||
"you"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:185
|
|
||||||
#, fuzzy
|
|
||||||
msgid "You may need to"
|
|
||||||
msgstr "당신은"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:185
|
|
||||||
msgid "Enable playwright environment variable"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:185
|
|
||||||
msgid "and uncomment the"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:185
|
|
||||||
#, fuzzy
|
|
||||||
msgid "in the"
|
|
||||||
msgstr "그만큼"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:185
|
|
||||||
#, fuzzy
|
|
||||||
msgid "file"
|
|
||||||
msgstr "제목"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:240
|
|
||||||
msgid "Set a hourly/week day schedule"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:247
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Schedule time limits"
|
|
||||||
msgstr "재확인 시간(분)"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:248
|
|
||||||
msgid "Business hours"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:249
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Weekends"
|
|
||||||
msgstr "주"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:250
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Reset"
|
|
||||||
msgstr "요구"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:259
|
|
||||||
msgid ""
|
|
||||||
"Warning, one or more of your 'days' has a duration that would extend into"
|
|
||||||
" the next day."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:260
|
|
||||||
msgid "This could have unintended consequences."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:270
|
|
||||||
#, fuzzy
|
|
||||||
msgid "More help and examples about using the scheduler"
|
|
||||||
msgstr "여기에 더 많은 도움말과 예시가 있습니다."
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:275
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Want to use a time schedule?"
|
|
||||||
msgstr "시간 스케줄러 사용"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:275
|
|
||||||
msgid "First confirm/save your Time Zone Settings"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:284
|
|
||||||
msgid ""
|
|
||||||
"Triggers a change if this text appears, AND something changed in the "
|
|
||||||
"document."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:284
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Triggered text"
|
|
||||||
msgstr "오류 텍스트"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:285
|
|
||||||
msgid "Ignored for calculating changes, but still shown."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:285
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Ignored text"
|
|
||||||
msgstr "오류 텍스트"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:286
|
|
||||||
#, fuzzy
|
|
||||||
msgid "No change-detection will occur because this text exists."
|
|
||||||
msgstr "텍스트가 일치하는 동안 변경 감지 차단"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:286
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Blocked text"
|
|
||||||
msgstr "오류 텍스트"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:78
|
|
||||||
#: changedetectionio/templates/base.html:168
|
|
||||||
msgid "GROUPS"
|
msgid "GROUPS"
|
||||||
msgstr "여러 떼"
|
msgstr "여러 떼"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:81
|
#: changedetectionio/templates/base.html:80
|
||||||
#: changedetectionio/templates/base.html:169
|
|
||||||
msgid "SETTINGS"
|
msgid "SETTINGS"
|
||||||
msgstr "설정"
|
msgstr "설정"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:84
|
#: changedetectionio/templates/base.html:83
|
||||||
#: changedetectionio/templates/base.html:170
|
|
||||||
msgid "IMPORT"
|
msgid "IMPORT"
|
||||||
msgstr "가져오기"
|
msgstr "수입"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:87
|
#: changedetectionio/templates/base.html:86
|
||||||
#: changedetectionio/templates/base.html:171
|
|
||||||
msgid "BACKUPS"
|
msgid "BACKUPS"
|
||||||
msgstr "백업"
|
msgstr "백업"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:91
|
#: changedetectionio/templates/base.html:90
|
||||||
#: changedetectionio/templates/base.html:173
|
|
||||||
msgid "EDIT"
|
msgid "EDIT"
|
||||||
msgstr "편집하다"
|
msgstr "편집하다"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:101
|
#: changedetectionio/templates/base.html:100
|
||||||
#: changedetectionio/templates/base.html:177
|
|
||||||
msgid "LOG OUT"
|
msgid "LOG OUT"
|
||||||
msgstr "로그아웃"
|
msgstr "로그아웃"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:108
|
#: changedetectionio/templates/base.html:109
|
||||||
msgid "Search, or Use Alt+S Key"
|
msgid "Search, or Use Alt+S Key"
|
||||||
msgstr "검색 또는 Alt+S 키 사용"
|
msgstr "검색 또는 Alt+S 키 사용"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:114
|
#: changedetectionio/templates/base.html:116
|
||||||
msgid "Toggle Light/Dark Mode"
|
msgid "Toggle Light/Dark Mode"
|
||||||
msgstr "밝은/어두운 모드 전환"
|
msgstr "밝은/어두운 모드 전환"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:115
|
#: changedetectionio/templates/base.html:117
|
||||||
msgid "Toggle light/dark mode"
|
msgid "Toggle light/dark mode"
|
||||||
msgstr "밝은/어두운 모드 전환"
|
msgstr "밝은/어두운 모드 전환"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:125
|
#: changedetectionio/templates/base.html:127
|
||||||
msgid "Change Language"
|
msgid "Change Language"
|
||||||
msgstr "언어 변경"
|
msgstr "언어 변경"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:126
|
#: changedetectionio/templates/base.html:128
|
||||||
msgid "Change language"
|
msgid "Change language"
|
||||||
msgstr "언어 변경"
|
msgstr "언어 변경"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:253
|
#: changedetectionio/templates/base.html:249
|
||||||
#, fuzzy
|
|
||||||
msgid "Watch List"
|
|
||||||
msgstr "모니터 목록"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:258
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Watches"
|
|
||||||
msgstr "모니터"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:261
|
|
||||||
msgid "Queue Status"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:270
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Queue"
|
|
||||||
msgstr "대기 중"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:274
|
|
||||||
#: changedetectionio/templates/base.html:279
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Settings"
|
|
||||||
msgstr "설정"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:293
|
|
||||||
msgid "Sitemap Crawler"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:318
|
|
||||||
msgid "Sitemap"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:354
|
|
||||||
msgid "Real-time updates offline"
|
msgid "Real-time updates offline"
|
||||||
msgstr "실시간 업데이트 오프라인"
|
msgstr "실시간 업데이트 오프라인"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:364
|
#: changedetectionio/templates/base.html:259
|
||||||
msgid "Select Language"
|
msgid "Select Language"
|
||||||
msgstr "언어 선택"
|
msgstr "언어 선택"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:375
|
#: changedetectionio/templates/base.html:270
|
||||||
msgid ""
|
msgid ""
|
||||||
"Language support is in beta, please help us improve by opening a PR on "
|
"Language support is in beta, please help us improve by opening a PR on "
|
||||||
"GitHub with any updates."
|
"GitHub with any updates."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:387
|
#: changedetectionio/templates/login.html:10
|
||||||
#: changedetectionio/templates/base.html:400
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Search"
|
|
||||||
msgstr "수색"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:392
|
|
||||||
msgid "URL or Title"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:392
|
|
||||||
#, fuzzy
|
|
||||||
msgid "in"
|
|
||||||
msgstr "추가 정보"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:393
|
|
||||||
msgid "Enter search term..."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/login.html:11
|
|
||||||
msgid "Password"
|
msgid "Password"
|
||||||
msgstr "비밀번호"
|
msgstr "비밀번호"
|
||||||
|
|
||||||
#: changedetectionio/templates/login.html:17
|
#: changedetectionio/templates/login.html:16
|
||||||
msgid "Login"
|
msgid "Login"
|
||||||
msgstr "로그인"
|
msgstr "로그인"
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -7,7 +7,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PROJECT VERSION\n"
|
"Project-Id-Version: PROJECT VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||||
"POT-Creation-Date: 2026-01-03 14:31+0100\n"
|
"POT-Creation-Date: 2026-01-02 16:07+0100\n"
|
||||||
"PO-Revision-Date: 2026-01-02 11:54+0100\n"
|
"PO-Revision-Date: 2026-01-02 11:54+0100\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language: zh\n"
|
"Language: zh\n"
|
||||||
@@ -19,21 +19,21 @@ msgstr ""
|
|||||||
"Generated-By: Babel 2.17.0\n"
|
"Generated-By: Babel 2.17.0\n"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:241
|
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:241
|
||||||
#: changedetectionio/flask_app.py:214 changedetectionio/flask_app.py:226
|
#: changedetectionio/flask_app.py:213 changedetectionio/flask_app.py:225
|
||||||
#: changedetectionio/flask_app.py:247
|
#: changedetectionio/flask_app.py:246
|
||||||
#: changedetectionio/realtime/socket_server.py:171
|
#: changedetectionio/realtime/socket_server.py:171
|
||||||
msgid "Not yet"
|
msgid "Not yet"
|
||||||
msgstr "还没有"
|
msgstr "还没有"
|
||||||
|
|
||||||
#: changedetectionio/flask_app.py:534
|
#: changedetectionio/flask_app.py:468
|
||||||
msgid "Already logged in"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/flask_app.py:536
|
|
||||||
msgid "You must be logged in, please log in."
|
msgid "You must be logged in, please log in."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: changedetectionio/flask_app.py:551
|
#: changedetectionio/flask_app.py:495
|
||||||
|
msgid "Already logged in"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: changedetectionio/flask_app.py:522
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
msgid "Incorrect password"
|
msgid "Incorrect password"
|
||||||
msgstr "密码"
|
msgstr "密码"
|
||||||
@@ -176,8 +176,9 @@ msgid "Invalid value."
|
|||||||
msgstr "无效值。"
|
msgstr "无效值。"
|
||||||
|
|
||||||
#: changedetectionio/forms.py:732
|
#: changedetectionio/forms.py:732
|
||||||
|
#, fuzzy
|
||||||
msgid "Watch"
|
msgid "Watch"
|
||||||
msgstr "监控"
|
msgstr "# 手表"
|
||||||
|
|
||||||
#: changedetectionio/forms.py:733 changedetectionio/forms.py:766
|
#: changedetectionio/forms.py:733 changedetectionio/forms.py:766
|
||||||
msgid "Processor"
|
msgid "Processor"
|
||||||
@@ -186,7 +187,7 @@ msgstr "处理器"
|
|||||||
#: changedetectionio/forms.py:734
|
#: changedetectionio/forms.py:734
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
msgid "Edit > Watch"
|
msgid "Edit > Watch"
|
||||||
msgstr "编辑 > 监控"
|
msgstr "先编辑后观看"
|
||||||
|
|
||||||
#: changedetectionio/forms.py:747 changedetectionio/forms.py:994
|
#: changedetectionio/forms.py:747 changedetectionio/forms.py:994
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
@@ -362,7 +363,7 @@ msgstr "节省"
|
|||||||
|
|
||||||
#: changedetectionio/forms.py:829
|
#: changedetectionio/forms.py:829
|
||||||
msgid "Proxy"
|
msgid "Proxy"
|
||||||
msgstr "代理"
|
msgstr "代理人"
|
||||||
|
|
||||||
#: changedetectionio/forms.py:831
|
#: changedetectionio/forms.py:831
|
||||||
msgid "Send a notification when the filter can no longer be found on the page"
|
msgid "Send a notification when the filter can no longer be found on the page"
|
||||||
@@ -373,7 +374,7 @@ msgstr "当页面上找不到过滤器时发送通知"
|
|||||||
#: changedetectionio/blueprint/ui/templates/edit.html:59
|
#: changedetectionio/blueprint/ui/templates/edit.html:59
|
||||||
#: changedetectionio/forms.py:832
|
#: changedetectionio/forms.py:832
|
||||||
msgid "Notifications"
|
msgid "Notifications"
|
||||||
msgstr "通知"
|
msgstr "暂无信息"
|
||||||
|
|
||||||
#: changedetectionio/forms.py:832
|
#: changedetectionio/forms.py:832
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
@@ -426,8 +427,9 @@ msgid "Invalid template syntax in \"%(header)s\" header: %(error)s"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: changedetectionio/forms.py:920 changedetectionio/forms.py:932
|
#: changedetectionio/forms.py:920 changedetectionio/forms.py:932
|
||||||
|
#, fuzzy
|
||||||
msgid "Name"
|
msgid "Name"
|
||||||
msgstr "名称"
|
msgstr "取消静音"
|
||||||
|
|
||||||
#: changedetectionio/forms.py:921
|
#: changedetectionio/forms.py:921
|
||||||
msgid "Proxy URL"
|
msgid "Proxy URL"
|
||||||
@@ -450,8 +452,9 @@ msgid "Plaintext requests"
|
|||||||
msgstr "明文请求"
|
msgstr "明文请求"
|
||||||
|
|
||||||
#: changedetectionio/forms.py:946
|
#: changedetectionio/forms.py:946
|
||||||
|
#, fuzzy
|
||||||
msgid "Chrome requests"
|
msgid "Chrome requests"
|
||||||
msgstr "Chrome请求"
|
msgstr "要求"
|
||||||
|
|
||||||
#: changedetectionio/forms.py:952
|
#: changedetectionio/forms.py:952
|
||||||
msgid "Default proxy"
|
msgid "Default proxy"
|
||||||
@@ -508,8 +511,9 @@ msgid "API access token security check enabled"
|
|||||||
msgstr "已启用 API 访问令牌安全检查"
|
msgstr "已启用 API 访问令牌安全检查"
|
||||||
|
|
||||||
#: changedetectionio/forms.py:989
|
#: changedetectionio/forms.py:989
|
||||||
|
#, fuzzy
|
||||||
msgid "Notification base URL override"
|
msgid "Notification base URL override"
|
||||||
msgstr "通知基础URL"
|
msgstr "通知警报计数"
|
||||||
|
|
||||||
#: changedetectionio/forms.py:993
|
#: changedetectionio/forms.py:993
|
||||||
msgid "Treat empty pages as a change?"
|
msgid "Treat empty pages as a change?"
|
||||||
@@ -618,8 +622,6 @@ msgid "Backups were deleted."
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: changedetectionio/blueprint/backups/templates/overview.html:6
|
#: changedetectionio/blueprint/backups/templates/overview.html:6
|
||||||
#: changedetectionio/templates/base.html:282
|
|
||||||
#: changedetectionio/templates/base.html:290
|
|
||||||
msgid "Backups"
|
msgid "Backups"
|
||||||
msgstr "备份"
|
msgstr "备份"
|
||||||
|
|
||||||
@@ -643,11 +645,11 @@ msgstr "未找到备份。"
|
|||||||
|
|
||||||
#: changedetectionio/blueprint/backups/templates/overview.html:28
|
#: changedetectionio/blueprint/backups/templates/overview.html:28
|
||||||
msgid "Create backup"
|
msgid "Create backup"
|
||||||
msgstr "创建备份"
|
msgstr "备份"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/backups/templates/overview.html:30
|
#: changedetectionio/blueprint/backups/templates/overview.html:30
|
||||||
msgid "Remove backups"
|
msgid "Remove backups"
|
||||||
msgstr "删除备份"
|
msgstr "备份"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/imports/importer.py:45
|
#: changedetectionio/blueprint/imports/importer.py:45
|
||||||
msgid ""
|
msgid ""
|
||||||
@@ -855,7 +857,7 @@ msgstr "一般的"
|
|||||||
|
|
||||||
#: changedetectionio/blueprint/settings/templates/settings.html:23
|
#: changedetectionio/blueprint/settings/templates/settings.html:23
|
||||||
msgid "Fetching"
|
msgid "Fetching"
|
||||||
msgstr "获取"
|
msgstr "搜寻中"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/settings/templates/settings.html:24
|
#: changedetectionio/blueprint/settings/templates/settings.html:24
|
||||||
msgid "Global Filters"
|
msgid "Global Filters"
|
||||||
@@ -883,7 +885,7 @@ msgstr "验证码和代理"
|
|||||||
|
|
||||||
#: changedetectionio/blueprint/settings/templates/settings.html:35
|
#: changedetectionio/blueprint/settings/templates/settings.html:35
|
||||||
msgid "Info"
|
msgid "Info"
|
||||||
msgstr "信息"
|
msgstr "更多信息"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/settings/templates/settings.html:46
|
#: changedetectionio/blueprint/settings/templates/settings.html:46
|
||||||
msgid "Default recheck time for all watches, current system minimum is"
|
msgid "Default recheck time for all watches, current system minimum is"
|
||||||
@@ -911,7 +913,7 @@ msgstr "没有激活的插件"
|
|||||||
|
|
||||||
#: changedetectionio/blueprint/settings/templates/settings.html:405
|
#: changedetectionio/blueprint/settings/templates/settings.html:405
|
||||||
msgid "Back"
|
msgid "Back"
|
||||||
msgstr "返回"
|
msgstr "备份"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/settings/templates/settings.html:406
|
#: changedetectionio/blueprint/settings/templates/settings.html:406
|
||||||
msgid "Clear Snapshot History"
|
msgid "Clear Snapshot History"
|
||||||
@@ -1034,7 +1036,7 @@ msgstr ""
|
|||||||
|
|
||||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html:31
|
#: changedetectionio/blueprint/tags/templates/groups-overview.html:31
|
||||||
msgid "# Watches"
|
msgid "# Watches"
|
||||||
msgstr "# 监控项"
|
msgstr "# 手表"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html:32
|
#: changedetectionio/blueprint/tags/templates/groups-overview.html:32
|
||||||
msgid "Tag / Label name"
|
msgid "Tag / Label name"
|
||||||
@@ -1286,7 +1288,7 @@ msgstr "先链接。"
|
|||||||
|
|
||||||
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:17
|
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:17
|
||||||
msgid "Confirmation text"
|
msgid "Confirmation text"
|
||||||
msgstr "确认文本"
|
msgstr "暂无信息"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:27
|
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:27
|
||||||
msgid "Type in the word"
|
msgid "Type in the word"
|
||||||
@@ -1305,8 +1307,7 @@ msgid "Clear History!"
|
|||||||
msgstr "清晰的历史记录"
|
msgstr "清晰的历史记录"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:39
|
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:39
|
||||||
#: changedetectionio/templates/base.html:379
|
#: changedetectionio/templates/base.html:274
|
||||||
#: changedetectionio/templates/base.html:399
|
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "取消"
|
msgstr "取消"
|
||||||
|
|
||||||
@@ -1489,7 +1490,7 @@ msgstr "状况"
|
|||||||
|
|
||||||
#: changedetectionio/blueprint/ui/templates/edit.html:60
|
#: changedetectionio/blueprint/ui/templates/edit.html:60
|
||||||
msgid "Stats"
|
msgid "Stats"
|
||||||
msgstr "统计"
|
msgstr "设置"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/ui/templates/edit.html:73
|
#: changedetectionio/blueprint/ui/templates/edit.html:73
|
||||||
#: changedetectionio/blueprint/ui/templates/edit.html:313
|
#: changedetectionio/blueprint/ui/templates/edit.html:313
|
||||||
@@ -1924,11 +1925,11 @@ msgstr "添加新的网页更改检测监视"
|
|||||||
|
|
||||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:34
|
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:34
|
||||||
msgid "Watch this URL!"
|
msgid "Watch this URL!"
|
||||||
msgstr "监控此URL!"
|
msgstr "关注这个网址!"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:35
|
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:35
|
||||||
msgid "Edit first then Watch"
|
msgid "Edit first then Watch"
|
||||||
msgstr "编辑后监控"
|
msgstr "先编辑后观看"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:45
|
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:45
|
||||||
msgid "Create a shareable link"
|
msgid "Create a shareable link"
|
||||||
@@ -2065,7 +2066,7 @@ msgid "No information"
|
|||||||
msgstr "暂无信息"
|
msgstr "暂无信息"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:234
|
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:234
|
||||||
#: changedetectionio/templates/base.html:353
|
#: changedetectionio/templates/base.html:248
|
||||||
msgid "Checking now"
|
msgid "Checking now"
|
||||||
msgstr "立即检查"
|
msgstr "立即检查"
|
||||||
|
|
||||||
@@ -2251,264 +2252,69 @@ msgstr "网页文本/HTML、JSON 和 PDF 更改"
|
|||||||
msgid "Detects all text changes where possible"
|
msgid "Detects all text changes where possible"
|
||||||
msgstr "尽可能检测所有文本更改"
|
msgstr "尽可能检测所有文本更改"
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:25
|
#: changedetectionio/templates/base.html:77
|
||||||
msgid "Entry"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:153
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Actions"
|
|
||||||
msgstr "状况"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:172
|
|
||||||
msgid "Add a row/rule after"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:173
|
|
||||||
msgid "Remove this row/rule"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:174
|
|
||||||
msgid "Verify this rule against current snapshot"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:184
|
|
||||||
msgid ""
|
|
||||||
"Error - This watch needs Chrome (with playwright/sockpuppetbrowser), but "
|
|
||||||
"Chrome based fetching is not enabled."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:184
|
|
||||||
msgid "Alternatively try our"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:184
|
|
||||||
msgid ""
|
|
||||||
"very affordable subscription based service which has all this setup for "
|
|
||||||
"you"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:185
|
|
||||||
#, fuzzy
|
|
||||||
msgid "You may need to"
|
|
||||||
msgstr "你需要"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:185
|
|
||||||
msgid "Enable playwright environment variable"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:185
|
|
||||||
msgid "and uncomment the"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:185
|
|
||||||
#, fuzzy
|
|
||||||
msgid "in the"
|
|
||||||
msgstr "这"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:185
|
|
||||||
#, fuzzy
|
|
||||||
msgid "file"
|
|
||||||
msgstr "标题"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:240
|
|
||||||
msgid "Set a hourly/week day schedule"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:247
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Schedule time limits"
|
|
||||||
msgstr "复检时间(分钟)"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:248
|
|
||||||
msgid "Business hours"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:249
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Weekends"
|
|
||||||
msgstr "周数"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:250
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Reset"
|
|
||||||
msgstr "要求"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:259
|
|
||||||
msgid ""
|
|
||||||
"Warning, one or more of your 'days' has a duration that would extend into"
|
|
||||||
" the next day."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:260
|
|
||||||
msgid "This could have unintended consequences."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:270
|
|
||||||
#, fuzzy
|
|
||||||
msgid "More help and examples about using the scheduler"
|
|
||||||
msgstr "更多帮助和示例请参见此处"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:275
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Want to use a time schedule?"
|
|
||||||
msgstr "使用时间调度器"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:275
|
|
||||||
msgid "First confirm/save your Time Zone Settings"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:284
|
|
||||||
msgid ""
|
|
||||||
"Triggers a change if this text appears, AND something changed in the "
|
|
||||||
"document."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:284
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Triggered text"
|
|
||||||
msgstr "错误文本"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:285
|
|
||||||
msgid "Ignored for calculating changes, but still shown."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:285
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Ignored text"
|
|
||||||
msgstr "错误文本"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:286
|
|
||||||
#, fuzzy
|
|
||||||
msgid "No change-detection will occur because this text exists."
|
|
||||||
msgstr "文本匹配时阻止更改检测"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:286
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Blocked text"
|
|
||||||
msgstr "错误文本"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:78
|
|
||||||
#: changedetectionio/templates/base.html:168
|
|
||||||
msgid "GROUPS"
|
msgid "GROUPS"
|
||||||
msgstr "团体"
|
msgstr "团体"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:81
|
#: changedetectionio/templates/base.html:80
|
||||||
#: changedetectionio/templates/base.html:169
|
|
||||||
msgid "SETTINGS"
|
msgid "SETTINGS"
|
||||||
msgstr "设置"
|
msgstr "设置"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:84
|
#: changedetectionio/templates/base.html:83
|
||||||
#: changedetectionio/templates/base.html:170
|
|
||||||
msgid "IMPORT"
|
msgid "IMPORT"
|
||||||
msgstr "导入"
|
msgstr "进口"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:87
|
#: changedetectionio/templates/base.html:86
|
||||||
#: changedetectionio/templates/base.html:171
|
|
||||||
msgid "BACKUPS"
|
msgid "BACKUPS"
|
||||||
msgstr "备份"
|
msgstr "备份"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:91
|
#: changedetectionio/templates/base.html:90
|
||||||
#: changedetectionio/templates/base.html:173
|
|
||||||
msgid "EDIT"
|
msgid "EDIT"
|
||||||
msgstr "编辑"
|
msgstr "编辑"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:101
|
#: changedetectionio/templates/base.html:100
|
||||||
#: changedetectionio/templates/base.html:177
|
|
||||||
msgid "LOG OUT"
|
msgid "LOG OUT"
|
||||||
msgstr "退出"
|
msgstr "退出"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:108
|
#: changedetectionio/templates/base.html:109
|
||||||
msgid "Search, or Use Alt+S Key"
|
msgid "Search, or Use Alt+S Key"
|
||||||
msgstr "搜索或使用 Alt+S 键"
|
msgstr "搜索或使用 Alt+S 键"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:114
|
#: changedetectionio/templates/base.html:116
|
||||||
msgid "Toggle Light/Dark Mode"
|
msgid "Toggle Light/Dark Mode"
|
||||||
msgstr "切换亮/暗模式"
|
msgstr "切换亮/暗模式"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:115
|
#: changedetectionio/templates/base.html:117
|
||||||
msgid "Toggle light/dark mode"
|
msgid "Toggle light/dark mode"
|
||||||
msgstr "切换亮/暗模式"
|
msgstr "切换亮/暗模式"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:125
|
#: changedetectionio/templates/base.html:127
|
||||||
msgid "Change Language"
|
msgid "Change Language"
|
||||||
msgstr "更改语言"
|
msgstr "更改语言"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:126
|
#: changedetectionio/templates/base.html:128
|
||||||
msgid "Change language"
|
msgid "Change language"
|
||||||
msgstr "更改语言"
|
msgstr "更改语言"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:253
|
#: changedetectionio/templates/base.html:249
|
||||||
#, fuzzy
|
|
||||||
msgid "Watch List"
|
|
||||||
msgstr "监控列表"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:258
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Watches"
|
|
||||||
msgstr "监控项"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:261
|
|
||||||
msgid "Queue Status"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:270
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Queue"
|
|
||||||
msgstr "排队"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:274
|
|
||||||
#: changedetectionio/templates/base.html:279
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Settings"
|
|
||||||
msgstr "设置"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:293
|
|
||||||
msgid "Sitemap Crawler"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:318
|
|
||||||
msgid "Sitemap"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:354
|
|
||||||
msgid "Real-time updates offline"
|
msgid "Real-time updates offline"
|
||||||
msgstr "离线实时更新"
|
msgstr "离线实时更新"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:364
|
#: changedetectionio/templates/base.html:259
|
||||||
msgid "Select Language"
|
msgid "Select Language"
|
||||||
msgstr "选择语言"
|
msgstr "选择语言"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:375
|
#: changedetectionio/templates/base.html:270
|
||||||
msgid ""
|
msgid ""
|
||||||
"Language support is in beta, please help us improve by opening a PR on "
|
"Language support is in beta, please help us improve by opening a PR on "
|
||||||
"GitHub with any updates."
|
"GitHub with any updates."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:387
|
#: changedetectionio/templates/login.html:10
|
||||||
#: changedetectionio/templates/base.html:400
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Search"
|
|
||||||
msgstr "搜寻中"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:392
|
|
||||||
msgid "URL or Title"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:392
|
|
||||||
#, fuzzy
|
|
||||||
msgid "in"
|
|
||||||
msgstr "更多信息"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:393
|
|
||||||
msgid "Enter search term..."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/login.html:11
|
|
||||||
msgid "Password"
|
msgid "Password"
|
||||||
msgstr "密码"
|
msgstr "密码"
|
||||||
|
|
||||||
#: changedetectionio/templates/login.html:17
|
#: changedetectionio/templates/login.html:16
|
||||||
msgid "Login"
|
msgid "Login"
|
||||||
msgstr "登录"
|
msgstr "登录"
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -7,7 +7,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PROJECT VERSION\n"
|
"Project-Id-Version: PROJECT VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||||
"POT-Creation-Date: 2026-01-03 14:31+0100\n"
|
"POT-Creation-Date: 2026-01-02 16:07+0100\n"
|
||||||
"PO-Revision-Date: 2026-01-02 12:37+0100\n"
|
"PO-Revision-Date: 2026-01-02 12:37+0100\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language: zh_Hant_TW\n"
|
"Language: zh_Hant_TW\n"
|
||||||
@@ -19,21 +19,21 @@ msgstr ""
|
|||||||
"Generated-By: Babel 2.17.0\n"
|
"Generated-By: Babel 2.17.0\n"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:241
|
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:241
|
||||||
#: changedetectionio/flask_app.py:214 changedetectionio/flask_app.py:226
|
#: changedetectionio/flask_app.py:213 changedetectionio/flask_app.py:225
|
||||||
#: changedetectionio/flask_app.py:247
|
#: changedetectionio/flask_app.py:246
|
||||||
#: changedetectionio/realtime/socket_server.py:171
|
#: changedetectionio/realtime/socket_server.py:171
|
||||||
msgid "Not yet"
|
msgid "Not yet"
|
||||||
msgstr "還沒有"
|
msgstr "還沒有"
|
||||||
|
|
||||||
#: changedetectionio/flask_app.py:534
|
#: changedetectionio/flask_app.py:468
|
||||||
msgid "Already logged in"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/flask_app.py:536
|
|
||||||
msgid "You must be logged in, please log in."
|
msgid "You must be logged in, please log in."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: changedetectionio/flask_app.py:551
|
#: changedetectionio/flask_app.py:495
|
||||||
|
msgid "Already logged in"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: changedetectionio/flask_app.py:522
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
msgid "Incorrect password"
|
msgid "Incorrect password"
|
||||||
msgstr "密碼"
|
msgstr "密碼"
|
||||||
@@ -176,16 +176,18 @@ msgid "Invalid value."
|
|||||||
msgstr "無效值。"
|
msgstr "無效值。"
|
||||||
|
|
||||||
#: changedetectionio/forms.py:732
|
#: changedetectionio/forms.py:732
|
||||||
|
#, fuzzy
|
||||||
msgid "Watch"
|
msgid "Watch"
|
||||||
msgstr "監控"
|
msgstr "# 手錶"
|
||||||
|
|
||||||
#: changedetectionio/forms.py:733 changedetectionio/forms.py:766
|
#: changedetectionio/forms.py:733 changedetectionio/forms.py:766
|
||||||
msgid "Processor"
|
msgid "Processor"
|
||||||
msgstr "處理器"
|
msgstr "處理器"
|
||||||
|
|
||||||
#: changedetectionio/forms.py:734
|
#: changedetectionio/forms.py:734
|
||||||
|
#, fuzzy
|
||||||
msgid "Edit > Watch"
|
msgid "Edit > Watch"
|
||||||
msgstr "編輯 > 監控"
|
msgstr "先編輯後觀看"
|
||||||
|
|
||||||
#: changedetectionio/forms.py:747 changedetectionio/forms.py:994
|
#: changedetectionio/forms.py:747 changedetectionio/forms.py:994
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
@@ -361,7 +363,7 @@ msgstr "節省"
|
|||||||
|
|
||||||
#: changedetectionio/forms.py:829
|
#: changedetectionio/forms.py:829
|
||||||
msgid "Proxy"
|
msgid "Proxy"
|
||||||
msgstr "代理"
|
msgstr "代理人"
|
||||||
|
|
||||||
#: changedetectionio/forms.py:831
|
#: changedetectionio/forms.py:831
|
||||||
msgid "Send a notification when the filter can no longer be found on the page"
|
msgid "Send a notification when the filter can no longer be found on the page"
|
||||||
@@ -425,8 +427,9 @@ msgid "Invalid template syntax in \"%(header)s\" header: %(error)s"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: changedetectionio/forms.py:920 changedetectionio/forms.py:932
|
#: changedetectionio/forms.py:920 changedetectionio/forms.py:932
|
||||||
|
#, fuzzy
|
||||||
msgid "Name"
|
msgid "Name"
|
||||||
msgstr "名稱"
|
msgstr "取消靜音"
|
||||||
|
|
||||||
#: changedetectionio/forms.py:921
|
#: changedetectionio/forms.py:921
|
||||||
msgid "Proxy URL"
|
msgid "Proxy URL"
|
||||||
@@ -449,8 +452,9 @@ msgid "Plaintext requests"
|
|||||||
msgstr "明文請求"
|
msgstr "明文請求"
|
||||||
|
|
||||||
#: changedetectionio/forms.py:946
|
#: changedetectionio/forms.py:946
|
||||||
|
#, fuzzy
|
||||||
msgid "Chrome requests"
|
msgid "Chrome requests"
|
||||||
msgstr "Chrome請求"
|
msgstr "要求"
|
||||||
|
|
||||||
#: changedetectionio/forms.py:952
|
#: changedetectionio/forms.py:952
|
||||||
msgid "Default proxy"
|
msgid "Default proxy"
|
||||||
@@ -507,8 +511,9 @@ msgid "API access token security check enabled"
|
|||||||
msgstr "已啟用 API 訪問令牌安全檢查"
|
msgstr "已啟用 API 訪問令牌安全檢查"
|
||||||
|
|
||||||
#: changedetectionio/forms.py:989
|
#: changedetectionio/forms.py:989
|
||||||
|
#, fuzzy
|
||||||
msgid "Notification base URL override"
|
msgid "Notification base URL override"
|
||||||
msgstr "通知基礎URL"
|
msgstr "通知警報計數"
|
||||||
|
|
||||||
#: changedetectionio/forms.py:993
|
#: changedetectionio/forms.py:993
|
||||||
msgid "Treat empty pages as a change?"
|
msgid "Treat empty pages as a change?"
|
||||||
@@ -617,8 +622,6 @@ msgid "Backups were deleted."
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: changedetectionio/blueprint/backups/templates/overview.html:6
|
#: changedetectionio/blueprint/backups/templates/overview.html:6
|
||||||
#: changedetectionio/templates/base.html:282
|
|
||||||
#: changedetectionio/templates/base.html:290
|
|
||||||
msgid "Backups"
|
msgid "Backups"
|
||||||
msgstr "備份"
|
msgstr "備份"
|
||||||
|
|
||||||
@@ -1033,7 +1036,7 @@ msgstr ""
|
|||||||
|
|
||||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html:31
|
#: changedetectionio/blueprint/tags/templates/groups-overview.html:31
|
||||||
msgid "# Watches"
|
msgid "# Watches"
|
||||||
msgstr "# 監控項"
|
msgstr "# 手錶"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html:32
|
#: changedetectionio/blueprint/tags/templates/groups-overview.html:32
|
||||||
msgid "Tag / Label name"
|
msgid "Tag / Label name"
|
||||||
@@ -1304,8 +1307,7 @@ msgid "Clear History!"
|
|||||||
msgstr "清除歷史!"
|
msgstr "清除歷史!"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:39
|
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:39
|
||||||
#: changedetectionio/templates/base.html:379
|
#: changedetectionio/templates/base.html:274
|
||||||
#: changedetectionio/templates/base.html:399
|
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "取消"
|
msgstr "取消"
|
||||||
|
|
||||||
@@ -1923,11 +1925,11 @@ msgstr "添加新的網頁更改檢測監視"
|
|||||||
|
|
||||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:34
|
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:34
|
||||||
msgid "Watch this URL!"
|
msgid "Watch this URL!"
|
||||||
msgstr "監控此URL!"
|
msgstr "關注這個網址!"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:35
|
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:35
|
||||||
msgid "Edit first then Watch"
|
msgid "Edit first then Watch"
|
||||||
msgstr "編輯後監控"
|
msgstr "先編輯後觀看"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:45
|
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:45
|
||||||
msgid "Create a shareable link"
|
msgid "Create a shareable link"
|
||||||
@@ -2064,7 +2066,7 @@ msgid "No information"
|
|||||||
msgstr "暫無信息"
|
msgstr "暫無信息"
|
||||||
|
|
||||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:234
|
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:234
|
||||||
#: changedetectionio/templates/base.html:353
|
#: changedetectionio/templates/base.html:248
|
||||||
msgid "Checking now"
|
msgid "Checking now"
|
||||||
msgstr "立即檢查"
|
msgstr "立即檢查"
|
||||||
|
|
||||||
@@ -2250,264 +2252,69 @@ msgstr "網頁文本/HTML、JSON 和 PDF 更改"
|
|||||||
msgid "Detects all text changes where possible"
|
msgid "Detects all text changes where possible"
|
||||||
msgstr "盡可能檢測所有文本更改"
|
msgstr "盡可能檢測所有文本更改"
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:25
|
#: changedetectionio/templates/base.html:77
|
||||||
msgid "Entry"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:153
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Actions"
|
|
||||||
msgstr "狀況"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:172
|
|
||||||
msgid "Add a row/rule after"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:173
|
|
||||||
msgid "Remove this row/rule"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:174
|
|
||||||
msgid "Verify this rule against current snapshot"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:184
|
|
||||||
msgid ""
|
|
||||||
"Error - This watch needs Chrome (with playwright/sockpuppetbrowser), but "
|
|
||||||
"Chrome based fetching is not enabled."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:184
|
|
||||||
msgid "Alternatively try our"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:184
|
|
||||||
msgid ""
|
|
||||||
"very affordable subscription based service which has all this setup for "
|
|
||||||
"you"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:185
|
|
||||||
#, fuzzy
|
|
||||||
msgid "You may need to"
|
|
||||||
msgstr "你需要"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:185
|
|
||||||
msgid "Enable playwright environment variable"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:185
|
|
||||||
msgid "and uncomment the"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:185
|
|
||||||
#, fuzzy
|
|
||||||
msgid "in the"
|
|
||||||
msgstr "這"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:185
|
|
||||||
#, fuzzy
|
|
||||||
msgid "file"
|
|
||||||
msgstr "標題"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:240
|
|
||||||
msgid "Set a hourly/week day schedule"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:247
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Schedule time limits"
|
|
||||||
msgstr "複檢時間(分鐘)"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:248
|
|
||||||
msgid "Business hours"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:249
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Weekends"
|
|
||||||
msgstr "週數"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:250
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Reset"
|
|
||||||
msgstr "要求"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:259
|
|
||||||
msgid ""
|
|
||||||
"Warning, one or more of your 'days' has a duration that would extend into"
|
|
||||||
" the next day."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:260
|
|
||||||
msgid "This could have unintended consequences."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:270
|
|
||||||
#, fuzzy
|
|
||||||
msgid "More help and examples about using the scheduler"
|
|
||||||
msgstr "更多幫助和示例請參見此處"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:275
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Want to use a time schedule?"
|
|
||||||
msgstr "使用時間調度器"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:275
|
|
||||||
msgid "First confirm/save your Time Zone Settings"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:284
|
|
||||||
msgid ""
|
|
||||||
"Triggers a change if this text appears, AND something changed in the "
|
|
||||||
"document."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:284
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Triggered text"
|
|
||||||
msgstr "錯誤文本"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:285
|
|
||||||
msgid "Ignored for calculating changes, but still shown."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:285
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Ignored text"
|
|
||||||
msgstr "錯誤文本"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:286
|
|
||||||
#, fuzzy
|
|
||||||
msgid "No change-detection will occur because this text exists."
|
|
||||||
msgstr "文本匹配時阻止更改檢測"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/_helpers.html:286
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Blocked text"
|
|
||||||
msgstr "錯誤文本"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:78
|
|
||||||
#: changedetectionio/templates/base.html:168
|
|
||||||
msgid "GROUPS"
|
msgid "GROUPS"
|
||||||
msgstr "團體"
|
msgstr "團體"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:81
|
#: changedetectionio/templates/base.html:80
|
||||||
#: changedetectionio/templates/base.html:169
|
|
||||||
msgid "SETTINGS"
|
msgid "SETTINGS"
|
||||||
msgstr "設定"
|
msgstr "設定"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:84
|
#: changedetectionio/templates/base.html:83
|
||||||
#: changedetectionio/templates/base.html:170
|
|
||||||
msgid "IMPORT"
|
msgid "IMPORT"
|
||||||
msgstr "導入"
|
msgstr "進口"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:87
|
#: changedetectionio/templates/base.html:86
|
||||||
#: changedetectionio/templates/base.html:171
|
|
||||||
msgid "BACKUPS"
|
msgid "BACKUPS"
|
||||||
msgstr "備份"
|
msgstr "備份"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:91
|
#: changedetectionio/templates/base.html:90
|
||||||
#: changedetectionio/templates/base.html:173
|
|
||||||
msgid "EDIT"
|
msgid "EDIT"
|
||||||
msgstr "編輯"
|
msgstr "編輯"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:101
|
#: changedetectionio/templates/base.html:100
|
||||||
#: changedetectionio/templates/base.html:177
|
|
||||||
msgid "LOG OUT"
|
msgid "LOG OUT"
|
||||||
msgstr "退出"
|
msgstr "退出"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:108
|
#: changedetectionio/templates/base.html:109
|
||||||
msgid "Search, or Use Alt+S Key"
|
msgid "Search, or Use Alt+S Key"
|
||||||
msgstr "搜索或使用 Alt+S 鍵"
|
msgstr "搜索或使用 Alt+S 鍵"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:114
|
#: changedetectionio/templates/base.html:116
|
||||||
msgid "Toggle Light/Dark Mode"
|
msgid "Toggle Light/Dark Mode"
|
||||||
msgstr "切換亮/暗模式"
|
msgstr "切換亮/暗模式"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:115
|
#: changedetectionio/templates/base.html:117
|
||||||
msgid "Toggle light/dark mode"
|
msgid "Toggle light/dark mode"
|
||||||
msgstr "切換亮/暗模式"
|
msgstr "切換亮/暗模式"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:125
|
#: changedetectionio/templates/base.html:127
|
||||||
msgid "Change Language"
|
msgid "Change Language"
|
||||||
msgstr "更改語言"
|
msgstr "更改語言"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:126
|
#: changedetectionio/templates/base.html:128
|
||||||
msgid "Change language"
|
msgid "Change language"
|
||||||
msgstr "更改語言"
|
msgstr "更改語言"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:253
|
#: changedetectionio/templates/base.html:249
|
||||||
#, fuzzy
|
|
||||||
msgid "Watch List"
|
|
||||||
msgstr "監控列表"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:258
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Watches"
|
|
||||||
msgstr "監控項"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:261
|
|
||||||
msgid "Queue Status"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:270
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Queue"
|
|
||||||
msgstr "排隊"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:274
|
|
||||||
#: changedetectionio/templates/base.html:279
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Settings"
|
|
||||||
msgstr "設定"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:293
|
|
||||||
msgid "Sitemap Crawler"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:318
|
|
||||||
msgid "Sitemap"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:354
|
|
||||||
msgid "Real-time updates offline"
|
msgid "Real-time updates offline"
|
||||||
msgstr "離線實時更新"
|
msgstr "離線實時更新"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:364
|
#: changedetectionio/templates/base.html:259
|
||||||
msgid "Select Language"
|
msgid "Select Language"
|
||||||
msgstr "選擇語言"
|
msgstr "選擇語言"
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:375
|
#: changedetectionio/templates/base.html:270
|
||||||
msgid ""
|
msgid ""
|
||||||
"Language support is in beta, please help us improve by opening a PR on "
|
"Language support is in beta, please help us improve by opening a PR on "
|
||||||
"GitHub with any updates."
|
"GitHub with any updates."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:387
|
#: changedetectionio/templates/login.html:10
|
||||||
#: changedetectionio/templates/base.html:400
|
|
||||||
#, fuzzy
|
|
||||||
msgid "Search"
|
|
||||||
msgstr "搜尋中"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:392
|
|
||||||
msgid "URL or Title"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:392
|
|
||||||
#, fuzzy
|
|
||||||
msgid "in"
|
|
||||||
msgstr "資訊"
|
|
||||||
|
|
||||||
#: changedetectionio/templates/base.html:393
|
|
||||||
msgid "Enter search term..."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: changedetectionio/templates/login.html:11
|
|
||||||
msgid "Password"
|
msgid "Password"
|
||||||
msgstr "密碼"
|
msgstr "密碼"
|
||||||
|
|
||||||
#: changedetectionio/templates/login.html:17
|
#: changedetectionio/templates/login.html:16
|
||||||
msgid "Login"
|
msgid "Login"
|
||||||
msgstr "登入"
|
msgstr "登入"
|
||||||
|
|
||||||
|
|||||||
+185
-169
@@ -2,18 +2,19 @@
|
|||||||
Worker management module for changedetection.io
|
Worker management module for changedetection.io
|
||||||
|
|
||||||
Handles asynchronous workers for dynamic worker scaling.
|
Handles asynchronous workers for dynamic worker scaling.
|
||||||
Each worker runs in its own thread with its own event loop for isolation.
|
Sync worker support has been removed in favor of async-only architecture.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
# Global worker state - each worker has its own thread and event loop
|
# Global worker state
|
||||||
worker_threads = [] # List of WorkerThread objects
|
running_async_tasks = []
|
||||||
|
async_loop = None
|
||||||
|
async_loop_thread = None
|
||||||
|
|
||||||
# Track currently processing UUIDs for async workers - maps {uuid: worker_id}
|
# Track currently processing UUIDs for async workers - maps {uuid: worker_id}
|
||||||
currently_processing_uuids = {}
|
currently_processing_uuids = {}
|
||||||
@@ -21,118 +22,89 @@ currently_processing_uuids = {}
|
|||||||
# Configuration - async workers only
|
# Configuration - async workers only
|
||||||
USE_ASYNC_WORKERS = True
|
USE_ASYNC_WORKERS = True
|
||||||
|
|
||||||
# Custom ThreadPoolExecutor for queue operations with named threads
|
|
||||||
# Scale executor threads with FETCH_WORKERS to avoid bottleneck at high concurrency
|
|
||||||
_max_executor_workers = max(50, int(os.getenv("FETCH_WORKERS", "10")))
|
|
||||||
queue_executor = ThreadPoolExecutor(
|
|
||||||
max_workers=_max_executor_workers,
|
|
||||||
thread_name_prefix="QueueGetter-"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
def start_async_event_loop():
|
||||||
class WorkerThread:
|
"""Start a dedicated event loop for async workers in a separate thread"""
|
||||||
"""Container for a worker thread with its own event loop"""
|
global async_loop
|
||||||
def __init__(self, worker_id, update_q, notification_q, app, datastore):
|
logger.info("Starting async event loop for workers")
|
||||||
self.worker_id = worker_id
|
|
||||||
self.update_q = update_q
|
try:
|
||||||
self.notification_q = notification_q
|
# Create a new event loop for this thread
|
||||||
self.app = app
|
async_loop = asyncio.new_event_loop()
|
||||||
self.datastore = datastore
|
# Set it as the event loop for this thread
|
||||||
self.thread = None
|
asyncio.set_event_loop(async_loop)
|
||||||
self.loop = None
|
|
||||||
self.running = False
|
logger.debug(f"Event loop created and set: {async_loop}")
|
||||||
|
|
||||||
def run(self):
|
# Run the event loop forever
|
||||||
"""Run the worker in its own event loop"""
|
async_loop.run_forever()
|
||||||
try:
|
except Exception as e:
|
||||||
# Create a new event loop for this thread
|
logger.error(f"Async event loop error: {e}")
|
||||||
self.loop = asyncio.new_event_loop()
|
finally:
|
||||||
asyncio.set_event_loop(self.loop)
|
# Clean up
|
||||||
self.running = True
|
if async_loop and not async_loop.is_closed():
|
||||||
|
async_loop.close()
|
||||||
# Run the worker coroutine
|
async_loop = None
|
||||||
self.loop.run_until_complete(
|
logger.info("Async event loop stopped")
|
||||||
start_single_async_worker(
|
|
||||||
self.worker_id,
|
|
||||||
self.update_q,
|
|
||||||
self.notification_q,
|
|
||||||
self.app,
|
|
||||||
self.datastore,
|
|
||||||
queue_executor
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
# Normal shutdown - worker was cancelled
|
|
||||||
import os
|
|
||||||
in_pytest = "pytest" in os.sys.modules or "PYTEST_CURRENT_TEST" in os.environ
|
|
||||||
if not in_pytest:
|
|
||||||
logger.info(f"Worker {self.worker_id} shutting down gracefully")
|
|
||||||
except RuntimeError as e:
|
|
||||||
# Ignore expected shutdown errors
|
|
||||||
if "Event loop stopped" not in str(e) and "Event loop is closed" not in str(e):
|
|
||||||
logger.error(f"Worker {self.worker_id} runtime error: {e}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Worker {self.worker_id} thread error: {e}")
|
|
||||||
finally:
|
|
||||||
# Clean up
|
|
||||||
if self.loop and not self.loop.is_closed():
|
|
||||||
self.loop.close()
|
|
||||||
self.running = False
|
|
||||||
self.loop = None
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
"""Start the worker thread"""
|
|
||||||
self.thread = threading.Thread(
|
|
||||||
target=self.run,
|
|
||||||
daemon=True,
|
|
||||||
name=f"PageFetchAsyncUpdateWorker-{self.worker_id}"
|
|
||||||
)
|
|
||||||
self.thread.start()
|
|
||||||
|
|
||||||
def stop(self):
|
|
||||||
"""Stop the worker thread"""
|
|
||||||
if self.loop and self.running:
|
|
||||||
try:
|
|
||||||
# Signal the loop to stop
|
|
||||||
self.loop.call_soon_threadsafe(self.loop.stop)
|
|
||||||
except RuntimeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if self.thread and self.thread.is_alive():
|
|
||||||
self.thread.join(timeout=2.0)
|
|
||||||
|
|
||||||
|
|
||||||
def start_async_workers(n_workers, update_q, notification_q, app, datastore):
|
def start_async_workers(n_workers, update_q, notification_q, app, datastore):
|
||||||
"""Start async workers, each with its own thread and event loop for isolation"""
|
"""Start the async worker management system"""
|
||||||
global worker_threads, currently_processing_uuids
|
global async_loop_thread, async_loop, running_async_tasks, currently_processing_uuids
|
||||||
|
|
||||||
# Clear any stale state
|
# Clear any stale UUID tracking state
|
||||||
currently_processing_uuids.clear()
|
currently_processing_uuids.clear()
|
||||||
|
|
||||||
# Start each worker in its own thread with its own event loop
|
# Start the event loop in a separate thread
|
||||||
logger.info(f"Starting {n_workers} async workers (isolated threads)")
|
async_loop_thread = threading.Thread(target=start_async_event_loop, daemon=True)
|
||||||
|
async_loop_thread.start()
|
||||||
|
|
||||||
|
# Wait for the loop to be available (with timeout for safety)
|
||||||
|
max_wait_time = 5.0
|
||||||
|
wait_start = time.time()
|
||||||
|
while async_loop is None and (time.time() - wait_start) < max_wait_time:
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
if async_loop is None:
|
||||||
|
logger.error("Failed to start async event loop within timeout")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Additional brief wait to ensure loop is running
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
# Start async workers
|
||||||
|
logger.info(f"Starting {n_workers} async workers")
|
||||||
for i in range(n_workers):
|
for i in range(n_workers):
|
||||||
try:
|
try:
|
||||||
worker = WorkerThread(i, update_q, notification_q, app, datastore)
|
# Use a factory function to create named worker coroutines
|
||||||
worker.start()
|
def create_named_worker(worker_id):
|
||||||
worker_threads.append(worker)
|
async def named_worker():
|
||||||
# No sleep needed - threads start independently and asynchronously
|
task = asyncio.current_task()
|
||||||
except Exception as e:
|
if task:
|
||||||
|
task.set_name(f"async-worker-{worker_id}")
|
||||||
|
return await start_single_async_worker(worker_id, update_q, notification_q, app, datastore)
|
||||||
|
return named_worker()
|
||||||
|
|
||||||
|
task_future = asyncio.run_coroutine_threadsafe(create_named_worker(i), async_loop)
|
||||||
|
running_async_tasks.append(task_future)
|
||||||
|
except RuntimeError as e:
|
||||||
logger.error(f"Failed to start async worker {i}: {e}")
|
logger.error(f"Failed to start async worker {i}: {e}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
||||||
async def start_single_async_worker(worker_id, update_q, notification_q, app, datastore, executor=None):
|
async def start_single_async_worker(worker_id, update_q, notification_q, app, datastore):
|
||||||
"""Start a single async worker with auto-restart capability"""
|
"""Start a single async worker with auto-restart capability"""
|
||||||
from changedetectionio.async_update_worker import async_update_worker
|
from changedetectionio.async_update_worker import async_update_worker
|
||||||
|
|
||||||
# Check if we're in pytest environment - if so, be more gentle with logging
|
# Check if we're in pytest environment - if so, be more gentle with logging
|
||||||
import os
|
import os
|
||||||
in_pytest = "pytest" in os.sys.modules or "PYTEST_CURRENT_TEST" in os.environ
|
in_pytest = "pytest" in os.sys.modules or "PYTEST_CURRENT_TEST" in os.environ
|
||||||
|
|
||||||
while not app.config.exit.is_set():
|
while not app.config.exit.is_set():
|
||||||
try:
|
try:
|
||||||
await async_update_worker(worker_id, update_q, notification_q, app, datastore, executor)
|
if not in_pytest:
|
||||||
|
logger.info(f"Starting async worker {worker_id}")
|
||||||
|
await async_update_worker(worker_id, update_q, notification_q, app, datastore)
|
||||||
# If we reach here, worker exited cleanly
|
# If we reach here, worker exited cleanly
|
||||||
if not in_pytest:
|
if not in_pytest:
|
||||||
logger.info(f"Async worker {worker_id} exited cleanly")
|
logger.info(f"Async worker {worker_id} exited cleanly")
|
||||||
@@ -159,38 +131,39 @@ def start_workers(n_workers, update_q, notification_q, app, datastore):
|
|||||||
|
|
||||||
def add_worker(update_q, notification_q, app, datastore):
|
def add_worker(update_q, notification_q, app, datastore):
|
||||||
"""Add a new async worker (for dynamic scaling)"""
|
"""Add a new async worker (for dynamic scaling)"""
|
||||||
global worker_threads
|
global running_async_tasks
|
||||||
|
|
||||||
worker_id = len(worker_threads)
|
if not async_loop:
|
||||||
logger.info(f"Adding async worker {worker_id}")
|
logger.error("Async loop not running, cannot add worker")
|
||||||
|
|
||||||
try:
|
|
||||||
worker = WorkerThread(worker_id, update_q, notification_q, app, datastore)
|
|
||||||
worker.start()
|
|
||||||
worker_threads.append(worker)
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to add worker {worker_id}: {e}")
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
worker_id = len(running_async_tasks)
|
||||||
|
logger.info(f"Adding async worker {worker_id}")
|
||||||
|
|
||||||
|
task_future = asyncio.run_coroutine_threadsafe(
|
||||||
|
start_single_async_worker(worker_id, update_q, notification_q, app, datastore), async_loop
|
||||||
|
)
|
||||||
|
running_async_tasks.append(task_future)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def remove_worker():
|
def remove_worker():
|
||||||
"""Remove an async worker (for dynamic scaling)"""
|
"""Remove an async worker (for dynamic scaling)"""
|
||||||
global worker_threads
|
global running_async_tasks
|
||||||
|
|
||||||
if not worker_threads:
|
if not running_async_tasks:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Stop the last worker
|
# Cancel the last worker
|
||||||
worker = worker_threads.pop()
|
task_future = running_async_tasks.pop()
|
||||||
worker.stop()
|
task_future.cancel()
|
||||||
logger.info(f"Removed async worker, {len(worker_threads)} workers remaining")
|
logger.info(f"Removed async worker, {len(running_async_tasks)} workers remaining")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def get_worker_count():
|
def get_worker_count():
|
||||||
"""Get current number of async workers"""
|
"""Get current number of async workers"""
|
||||||
return len(worker_threads)
|
return len(running_async_tasks)
|
||||||
|
|
||||||
|
|
||||||
def get_running_uuids():
|
def get_running_uuids():
|
||||||
@@ -276,21 +249,38 @@ def queue_item_async_safe(update_q, item, silent=False):
|
|||||||
|
|
||||||
def shutdown_workers():
|
def shutdown_workers():
|
||||||
"""Shutdown all async workers fast and aggressively"""
|
"""Shutdown all async workers fast and aggressively"""
|
||||||
global worker_threads
|
global async_loop, async_loop_thread, running_async_tasks
|
||||||
|
|
||||||
# Check if we're in pytest environment - if so, be more gentle with logging
|
# Check if we're in pytest environment - if so, be more gentle with logging
|
||||||
import os
|
import os
|
||||||
in_pytest = "pytest" in os.sys.modules or "PYTEST_CURRENT_TEST" in os.environ
|
in_pytest = "pytest" in os.sys.modules or "PYTEST_CURRENT_TEST" in os.environ
|
||||||
|
|
||||||
if not in_pytest:
|
if not in_pytest:
|
||||||
logger.info("Fast shutdown of async workers initiated...")
|
logger.info("Fast shutdown of async workers initiated...")
|
||||||
|
|
||||||
# Stop all worker threads
|
# Cancel all async tasks immediately
|
||||||
for worker in worker_threads:
|
for task_future in running_async_tasks:
|
||||||
worker.stop()
|
if not task_future.done():
|
||||||
|
task_future.cancel()
|
||||||
worker_threads.clear()
|
|
||||||
|
# Stop the async event loop immediately
|
||||||
|
if async_loop and not async_loop.is_closed():
|
||||||
|
try:
|
||||||
|
async_loop.call_soon_threadsafe(async_loop.stop)
|
||||||
|
except RuntimeError:
|
||||||
|
# Loop might already be stopped
|
||||||
|
pass
|
||||||
|
|
||||||
|
running_async_tasks.clear()
|
||||||
|
async_loop = None
|
||||||
|
|
||||||
|
# Give async thread minimal time to finish, then continue
|
||||||
|
if async_loop_thread and async_loop_thread.is_alive():
|
||||||
|
async_loop_thread.join(timeout=1.0) # Only 1 second timeout
|
||||||
|
if async_loop_thread.is_alive() and not in_pytest:
|
||||||
|
logger.info("Async thread still running after timeout - continuing with shutdown")
|
||||||
|
async_loop_thread = None
|
||||||
|
|
||||||
if not in_pytest:
|
if not in_pytest:
|
||||||
logger.info("Async workers fast shutdown complete")
|
logger.info("Async workers fast shutdown complete")
|
||||||
|
|
||||||
@@ -300,57 +290,69 @@ def shutdown_workers():
|
|||||||
def adjust_async_worker_count(new_count, update_q=None, notification_q=None, app=None, datastore=None):
|
def adjust_async_worker_count(new_count, update_q=None, notification_q=None, app=None, datastore=None):
|
||||||
"""
|
"""
|
||||||
Dynamically adjust the number of async workers.
|
Dynamically adjust the number of async workers.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
new_count: Target number of workers
|
new_count: Target number of workers
|
||||||
update_q, notification_q, app, datastore: Required for adding new workers
|
update_q, notification_q, app, datastore: Required for adding new workers
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Status of the adjustment operation
|
dict: Status of the adjustment operation
|
||||||
"""
|
"""
|
||||||
global worker_threads
|
global running_async_tasks
|
||||||
|
|
||||||
current_count = get_worker_count()
|
current_count = get_worker_count()
|
||||||
|
|
||||||
if new_count == current_count:
|
if new_count == current_count:
|
||||||
return {
|
return {
|
||||||
'status': 'no_change',
|
'status': 'no_change',
|
||||||
'message': f'Worker count already at {current_count}',
|
'message': f'Worker count already at {current_count}',
|
||||||
'current_count': current_count
|
'current_count': current_count
|
||||||
}
|
}
|
||||||
|
|
||||||
if new_count > current_count:
|
if new_count > current_count:
|
||||||
# Add workers
|
# Add workers
|
||||||
workers_to_add = new_count - current_count
|
workers_to_add = new_count - current_count
|
||||||
logger.info(f"Adding {workers_to_add} async workers (from {current_count} to {new_count})")
|
logger.info(f"Adding {workers_to_add} async workers (from {current_count} to {new_count})")
|
||||||
|
|
||||||
if not all([update_q, notification_q, app, datastore]):
|
if not all([update_q, notification_q, app, datastore]):
|
||||||
return {
|
return {
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Missing required parameters to add workers',
|
'message': 'Missing required parameters to add workers',
|
||||||
'current_count': current_count
|
'current_count': current_count
|
||||||
}
|
}
|
||||||
|
|
||||||
for i in range(workers_to_add):
|
for i in range(workers_to_add):
|
||||||
add_worker(update_q, notification_q, app, datastore)
|
worker_id = len(running_async_tasks)
|
||||||
|
task_future = asyncio.run_coroutine_threadsafe(
|
||||||
|
start_single_async_worker(worker_id, update_q, notification_q, app, datastore),
|
||||||
|
async_loop
|
||||||
|
)
|
||||||
|
running_async_tasks.append(task_future)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'message': f'Added {workers_to_add} workers',
|
'message': f'Added {workers_to_add} workers',
|
||||||
'previous_count': current_count,
|
'previous_count': current_count,
|
||||||
'current_count': len(worker_threads)
|
'current_count': new_count
|
||||||
}
|
}
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Remove workers
|
# Remove workers
|
||||||
workers_to_remove = current_count - new_count
|
workers_to_remove = current_count - new_count
|
||||||
logger.info(f"Removing {workers_to_remove} async workers (from {current_count} to {new_count})")
|
logger.info(f"Removing {workers_to_remove} async workers (from {current_count} to {new_count})")
|
||||||
|
|
||||||
removed_count = 0
|
removed_count = 0
|
||||||
for _ in range(workers_to_remove):
|
for _ in range(workers_to_remove):
|
||||||
if remove_worker():
|
if running_async_tasks:
|
||||||
|
task_future = running_async_tasks.pop()
|
||||||
|
task_future.cancel()
|
||||||
|
# Wait for the task to actually stop
|
||||||
|
try:
|
||||||
|
task_future.result(timeout=5) # 5 second timeout
|
||||||
|
except Exception:
|
||||||
|
pass # Task was cancelled, which is expected
|
||||||
removed_count += 1
|
removed_count += 1
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'message': f'Removed {removed_count} workers',
|
'message': f'Removed {removed_count} workers',
|
||||||
@@ -365,58 +367,72 @@ def get_worker_status():
|
|||||||
'worker_type': 'async',
|
'worker_type': 'async',
|
||||||
'worker_count': get_worker_count(),
|
'worker_count': get_worker_count(),
|
||||||
'running_uuids': get_running_uuids(),
|
'running_uuids': get_running_uuids(),
|
||||||
'active_threads': sum(1 for w in worker_threads if w.thread and w.thread.is_alive()),
|
'async_loop_running': async_loop is not None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def check_worker_health(expected_count, update_q=None, notification_q=None, app=None, datastore=None):
|
def check_worker_health(expected_count, update_q=None, notification_q=None, app=None, datastore=None):
|
||||||
"""
|
"""
|
||||||
Check if the expected number of async workers are running and restart any missing ones.
|
Check if the expected number of async workers are running and restart any missing ones.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
expected_count: Expected number of workers
|
expected_count: Expected number of workers
|
||||||
update_q, notification_q, app, datastore: Required for restarting workers
|
update_q, notification_q, app, datastore: Required for restarting workers
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Health check results
|
dict: Health check results
|
||||||
"""
|
"""
|
||||||
global worker_threads
|
global running_async_tasks
|
||||||
|
|
||||||
current_count = get_worker_count()
|
current_count = get_worker_count()
|
||||||
|
|
||||||
# Check which workers are actually alive
|
if current_count == expected_count:
|
||||||
alive_count = sum(1 for w in worker_threads if w.thread and w.thread.is_alive())
|
|
||||||
|
|
||||||
if alive_count == expected_count:
|
|
||||||
return {
|
return {
|
||||||
'status': 'healthy',
|
'status': 'healthy',
|
||||||
'expected_count': expected_count,
|
'expected_count': expected_count,
|
||||||
'actual_count': alive_count,
|
'actual_count': current_count,
|
||||||
'message': f'All {expected_count} async workers running'
|
'message': f'All {expected_count} async workers running'
|
||||||
}
|
}
|
||||||
|
|
||||||
# Find dead workers
|
# Check for crashed async workers
|
||||||
dead_workers = []
|
dead_workers = []
|
||||||
for i, worker in enumerate(worker_threads[:]):
|
alive_count = 0
|
||||||
if not worker.thread or not worker.thread.is_alive():
|
|
||||||
dead_workers.append(i)
|
for i, task_future in enumerate(running_async_tasks[:]):
|
||||||
logger.warning(f"Async worker {worker.worker_id} thread is dead")
|
if task_future.done():
|
||||||
|
try:
|
||||||
|
result = task_future.result()
|
||||||
|
dead_workers.append(i)
|
||||||
|
logger.warning(f"Async worker {i} completed unexpectedly")
|
||||||
|
except Exception as e:
|
||||||
|
dead_workers.append(i)
|
||||||
|
logger.error(f"Async worker {i} crashed: {e}")
|
||||||
|
else:
|
||||||
|
alive_count += 1
|
||||||
|
|
||||||
# Remove dead workers from tracking
|
# Remove dead workers from tracking
|
||||||
for i in reversed(dead_workers):
|
for i in reversed(dead_workers):
|
||||||
if i < len(worker_threads):
|
if i < len(running_async_tasks):
|
||||||
worker_threads.pop(i)
|
running_async_tasks.pop(i)
|
||||||
|
|
||||||
missing_workers = expected_count - alive_count
|
missing_workers = expected_count - alive_count
|
||||||
restarted_count = 0
|
restarted_count = 0
|
||||||
|
|
||||||
if missing_workers > 0 and all([update_q, notification_q, app, datastore]):
|
if missing_workers > 0 and all([update_q, notification_q, app, datastore]):
|
||||||
logger.info(f"Restarting {missing_workers} crashed async workers")
|
logger.info(f"Restarting {missing_workers} crashed async workers")
|
||||||
|
|
||||||
for i in range(missing_workers):
|
for i in range(missing_workers):
|
||||||
if add_worker(update_q, notification_q, app, datastore):
|
worker_id = alive_count + i
|
||||||
|
try:
|
||||||
|
task_future = asyncio.run_coroutine_threadsafe(
|
||||||
|
start_single_async_worker(worker_id, update_q, notification_q, app, datastore),
|
||||||
|
async_loop
|
||||||
|
)
|
||||||
|
running_async_tasks.append(task_future)
|
||||||
restarted_count += 1
|
restarted_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to restart worker {worker_id}: {e}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'status': 'repaired' if restarted_count > 0 else 'degraded',
|
'status': 'repaired' if restarted_count > 0 else 'degraded',
|
||||||
'expected_count': expected_count,
|
'expected_count': expected_count,
|
||||||
|
|||||||
+4
-4
@@ -12,8 +12,8 @@ janus # Thread-safe async/sync queue bridge
|
|||||||
flask_wtf~=1.2
|
flask_wtf~=1.2
|
||||||
flask~=3.1
|
flask~=3.1
|
||||||
flask-socketio~=5.6.0
|
flask-socketio~=5.6.0
|
||||||
python-socketio~=5.16.0
|
python-socketio~=5.14.3
|
||||||
python-engineio~=4.13.0
|
python-engineio~=4.12.3
|
||||||
inscriptis~=2.2
|
inscriptis~=2.2
|
||||||
pytz
|
pytz
|
||||||
timeago~=1.0
|
timeago~=1.0
|
||||||
@@ -60,7 +60,7 @@ cryptography==46.0.3
|
|||||||
paho-mqtt!=2.0.*
|
paho-mqtt!=2.0.*
|
||||||
|
|
||||||
# Used for CSS filtering, JSON extraction from HTML
|
# Used for CSS filtering, JSON extraction from HTML
|
||||||
beautifulsoup4>=4.0.0,<=4.14.3
|
beautifulsoup4>=4.0.0,<=4.14.2
|
||||||
|
|
||||||
# XPath filtering, lxml is required by bs4 anyway, but put it here to be safe.
|
# XPath filtering, lxml is required by bs4 anyway, but put it here to be safe.
|
||||||
# #2328 - 5.2.0 and 5.2.1 had extra CPU flag CFLAGS set which was not compatible on older hardware
|
# #2328 - 5.2.0 and 5.2.1 had extra CPU flag CFLAGS set which was not compatible on older hardware
|
||||||
@@ -148,7 +148,7 @@ tzdata
|
|||||||
pluggy ~= 1.6
|
pluggy ~= 1.6
|
||||||
|
|
||||||
# Needed for testing, cross-platform for process and system monitoring
|
# Needed for testing, cross-platform for process and system monitoring
|
||||||
psutil==7.2.1
|
psutil==7.1.0
|
||||||
|
|
||||||
ruff >= 0.11.2
|
ruff >= 0.11.2
|
||||||
pre_commit >= 4.2.0
|
pre_commit >= 4.2.0
|
||||||
|
|||||||
Reference in New Issue
Block a user