Files

189 lines
9.2 KiB
Python

import os
import time
from flask import Blueprint, request, make_response, render_template, redirect, url_for, flash, session
from flask_paginate import Pagination, get_page_parameter
from flask_babel import gettext as _
from changedetectionio import forms
from changedetectionio import processors
from changedetectionio import worker_pool
from changedetectionio.store import ChangeDetectionStore
from changedetectionio.auth_decorator import login_optionally_required
# Shared filtering — the single source of truth, also used by the ui blueprint's
# bulk actions so a filtered view and the actions taken on it always agree.
from changedetectionio.blueprint.watchlist import filters as wl_filters
from changedetectionio.blueprint.watchlist.row_context import watch_row_context
def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData):
watchlist_blueprint = Blueprint('watchlist', __name__, template_folder="templates")
@watchlist_blueprint.route("/", methods=['GET'])
@login_optionally_required
def index():
active_tag_req = request.args.get('tag', '').lower().strip()
active_tag_uuid = active_tag = None
# Be sure limit_tag is a uuid
if active_tag_req:
for uuid, tag in datastore.data['settings']['application'].get('tags', {}).items():
if active_tag_req == tag.get('title', '').lower().strip() or active_tag_req == uuid:
active_tag = tag
active_tag_uuid = uuid
break
# Redirect for the old rss path which used the /?rss=true
if request.args.get('rss'):
return redirect(url_for('rss.feed', tag=active_tag_uuid))
op = request.args.get('op')
if op:
uuid = request.args.get('uuid')
if op == 'pause':
datastore.data['watching'][uuid].toggle_pause()
elif op == 'mute':
datastore.data['watching'][uuid].toggle_mute()
datastore.data['watching'][uuid].commit()
return redirect(url_for('watchlist.index', tag = active_tag_uuid))
# Sort by last_changed and add the uuid which is usually the key..
sorted_watches = []
active_processor = request.args.get('processor', '').strip()
# Toolbar facet counts are tallied over the tag/processor/search context but
# independently of the active status toggle, so the .seg numbers stay stable
# as you switch between All / Unread / Deals / With errors.
errored_count = 0
deals_count = 0
unread_count = 0
processor_counts = {}
list_filters = wl_filters.list_filters_from_args(datastore, request.args)
for uuid, watch in datastore.data['watching'].items():
# The processor facet is counted over the tag/search base (ignoring the
# selected processor) so every detected processor shows up and you can
# switch between them.
if not wl_filters.watch_matches_tag(datastore, watch, list_filters):
continue
if not wl_filters.watch_passes_search(watch, list_filters):
continue
proc = watch.get('processor')
if proc:
processor_counts[proc] = processor_counts.get(proc, 0) + 1
# Narrow to the selected processor for the remaining facets + the list.
if list_filters['processor'] and proc != list_filters['processor']:
continue
if watch.get('last_error'):
errored_count += 1
if wl_filters.watch_is_deal(watch):
deals_count += 1
# Unread = changed and not yet viewed — same test the 'unread' status
# filter uses, so this count matches what clicking Unread shows.
if not (watch.viewed or watch.last_changed == 0):
unread_count += 1
if wl_filters.watch_passes_status(watch, list_filters):
sorted_watches.append(watch)
form = forms.quickWatchForm(request.form)
page = request.args.get(get_page_parameter(), type=int, default=1)
total_count = len(sorted_watches)
pagination = Pagination(page=page,
total=total_count,
per_page=datastore.data['settings']['application'].get('pager_size', 50),
css_framework="semantic",
display_msg=_('displaying <b>{start} - {end}</b> {record_name} in total <b>{total}</b>'),
record_name=_('records'))
sorted_tags = sorted(datastore.data['settings']['application'].get('tags').items(), key=lambda x: x[1]['title'])
from changedetectionio import content_fetchers
available_fetchers = content_fetchers.available_fetchers()
from changedetectionio.llm.evaluator import get_llm_config as _get_llm_config
from changedetectionio.llm.ui_strings import LLM_INTENT_WATCH_PLACEHOLDER
llm_configured = bool(_get_llm_config(datastore))
# Everything the row markup itself needs comes from here, shared with the Socket.IO
# row push so a live-updated row can't drift from the server-rendered one.
row_ctx = watch_row_context(datastore,
active_tag_uuid=active_tag_uuid,
queued_uuids=update_q.get_queued_uuids())
output = render_template(
"watch-overview.html",
**row_ctx,
active_tag=active_tag,
active_processor=active_processor,
checking_now_size=len(worker_pool.get_running_uuids()),
app_rss_token=datastore.data['settings']['application'].get('rss_access_token'),
errored_count=errored_count,
deals_count=deals_count,
unread_count=unread_count,
processor_counts=processor_counts,
# body classes for app-wide state; realtime.js keeps these in sync live
# (has-any-unviewed reveals the "Mark all viewed" button - see _watch_table.scss)
extra_classes=' '.join(filter(None, ['has-queue' if not update_q.empty() else '',
'llm-configured' if llm_configured else '',
'has-any-unviewed' if datastore.unread_changes_count else ''])),
form=form,
generate_tag_colors=processors.generate_processor_badge_colors,
wcag_text_color=processors.wcag_text_color,
guid=datastore.data['app_guid'],
available_fetchers=available_fetchers,
#header=_("todo - tag name etc"),
hosted_sticky=os.getenv("SALTED_PASS", False) == False,
now_time_server=round(time.time()),
pagination=pagination,
processor_badge_css=processors.get_processor_badge_css(),
processor_badge_texts=processors.get_processor_badge_texts(),
queue_size=update_q.qsize(),
# Active view filters (tag/processor/q/unread/...) so links (e.g. column sorting)
# can re-apply them and not drop the operator's current filtered view.
active_filters=wl_filters.filter_query_args(request.args),
search_q=request.args.get('q', '').strip(),
sort_attribute=request.args.get('sort') if request.args.get('sort') else request.cookies.get('sort'),
sort_order=request.args.get('order') if request.args.get('order') else request.cookies.get('order'),
tags=sorted_tags,
unread_changes_count=datastore.unread_changes_count,
watches=sorted_watches,
llm_configured=llm_configured,
llm_intent_watch_placeholder=LLM_INTENT_WATCH_PLACEHOLDER,
)
# Return freed template-building memory to the OS immediately.
# render_template allocates ~20MB of intermediate strings that are freed on return,
# but glibc keeps those pages mapped in its arenas as RSS. malloc_trim() forces
# glibc to release them, preventing RSS growth from concurrent Chrome connections.
try:
import ctypes
ctypes.CDLL('libc.so.6').malloc_trim(0)
except Exception:
pass
if session.get('share-link'):
del (session['share-link'])
resp = make_response(output)
# The template can run on cookie or url query info
if request.args.get('sort'):
resp.set_cookie('sort', request.args.get('sort'))
if request.args.get('order'):
resp.set_cookie('order', request.args.get('order'))
return resp
@watchlist_blueprint.route("/uuids", methods=['GET'])
@login_optionally_required
def uuids():
"""All watch UUIDs matching the current filter (tag/search/status/processor).
Backs the client "select all matching" feature: the watchlist page only
renders one page of rows, so to select across pages the browser fetches the
full matching id list from here and holds it in its selection store.
"""
from flask import jsonify
return jsonify({'uuids': wl_filters.matching_watch_uuids(datastore, request.args)})
return watchlist_blueprint