kopia lustrzana https://github.com/dgtlmoon/changedetection.io
rodzic
3f9fab3944
commit
af24079053
|
@ -6,7 +6,6 @@
|
||||||
# @todo enable https://urllib3.readthedocs.io/en/latest/user-guide.html#ssl as option?
|
# @todo enable https://urllib3.readthedocs.io/en/latest/user-guide.html#ssl as option?
|
||||||
# @todo option for interval day/6 hour/etc
|
# @todo option for interval day/6 hour/etc
|
||||||
# @todo on change detected, config for calling some API
|
# @todo on change detected, config for calling some API
|
||||||
# @todo make tables responsive!
|
|
||||||
# @todo fetch title into json
|
# @todo fetch title into json
|
||||||
# https://distill.io/features
|
# https://distill.io/features
|
||||||
# proxy per check
|
# proxy per check
|
||||||
|
@ -53,6 +52,8 @@ app.config['NEW_VERSION_AVAILABLE'] = False
|
||||||
|
|
||||||
app.config['LOGIN_DISABLED'] = False
|
app.config['LOGIN_DISABLED'] = False
|
||||||
|
|
||||||
|
#app.config["EXPLAIN_TEMPLATE_LOADING"] = True
|
||||||
|
|
||||||
# Disables caching of the templates
|
# Disables caching of the templates
|
||||||
app.config['TEMPLATES_AUTO_RELOAD'] = True
|
app.config['TEMPLATES_AUTO_RELOAD'] = True
|
||||||
|
|
||||||
|
@ -74,6 +75,17 @@ def init_app_secret(datastore_path):
|
||||||
|
|
||||||
return secret
|
return secret
|
||||||
|
|
||||||
|
# Remember python is by reference
|
||||||
|
# populate_form in wtfors didnt work for me. (try using a setattr() obj type on datastore.watch?)
|
||||||
|
def populate_form_from_watch(form, watch):
|
||||||
|
for i in form.__dict__.keys():
|
||||||
|
if i[0] != '_':
|
||||||
|
p = getattr(form, i)
|
||||||
|
if hasattr(p, 'data') and i in watch:
|
||||||
|
if not p.data:
|
||||||
|
setattr(p, "data", watch[i])
|
||||||
|
|
||||||
|
|
||||||
# We use the whole watch object from the store/JSON so we can see if there's some related status in terms of a thread
|
# We use the whole watch object from the store/JSON so we can see if there's some related status in terms of a thread
|
||||||
# running or something similar.
|
# running or something similar.
|
||||||
@app.template_filter('format_last_checked_time')
|
@app.template_filter('format_last_checked_time')
|
||||||
|
@ -345,82 +357,47 @@ def changedetection_app(config=None, datastore_o=None):
|
||||||
|
|
||||||
return datastore.data['watching'][uuid]['previous_md5']
|
return datastore.data['watching'][uuid]['previous_md5']
|
||||||
|
|
||||||
|
|
||||||
@app.route("/edit/<string:uuid>", methods=['GET', 'POST'])
|
@app.route("/edit/<string:uuid>", methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def edit_page(uuid):
|
def edit_page(uuid):
|
||||||
import validators
|
from backend import forms
|
||||||
|
form = forms.watchForm(request.form)
|
||||||
|
|
||||||
# More for testing, possible to return the first/only
|
# More for testing, possible to return the first/only
|
||||||
if uuid == 'first':
|
if uuid == 'first':
|
||||||
uuid = list(datastore.data['watching'].keys()).pop()
|
uuid = list(datastore.data['watching'].keys()).pop()
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'GET':
|
||||||
|
populate_form_from_watch(form, datastore.data['watching'][uuid])
|
||||||
|
|
||||||
url = request.form.get('url').strip()
|
if request.method == 'POST' and form.validate():
|
||||||
tag = request.form.get('tag').strip()
|
update_obj = {'url': form.url.data.strip(),
|
||||||
|
'tag': form.tag.data.strip(),
|
||||||
minutes_recheck = request.form.get('minutes')
|
'headers': form.headers.data
|
||||||
if minutes_recheck:
|
|
||||||
minutes = int(minutes_recheck.strip())
|
|
||||||
if minutes >= 1:
|
|
||||||
datastore.data['watching'][uuid]['minutes_between_check'] = minutes
|
|
||||||
else:
|
|
||||||
flash("Must be atleast 1 minute between checks.", 'error')
|
|
||||||
return redirect(url_for('edit_page', uuid=uuid))
|
|
||||||
|
|
||||||
# Extra headers
|
|
||||||
form_headers = request.form.get('headers').strip().split("\n")
|
|
||||||
extra_headers = {}
|
|
||||||
if form_headers:
|
|
||||||
for header in form_headers:
|
|
||||||
if len(header):
|
|
||||||
parts = header.split(':', 1)
|
|
||||||
if len(parts) == 2:
|
|
||||||
extra_headers.update({parts[0].strip(): parts[1].strip()})
|
|
||||||
|
|
||||||
update_obj = {'url': url,
|
|
||||||
'tag': tag,
|
|
||||||
'headers': extra_headers
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Notification URLs
|
# Notification URLs
|
||||||
form_notification_text = request.form.get('notification_urls')
|
datastore.data['watching'][uuid]['notification_urls'] = form.notification_urls.data
|
||||||
notification_urls = []
|
|
||||||
if form_notification_text:
|
|
||||||
for text in form_notification_text.strip().split("\n"):
|
|
||||||
text = text.strip()
|
|
||||||
if len(text):
|
|
||||||
notification_urls.append(text)
|
|
||||||
|
|
||||||
datastore.data['watching'][uuid]['notification_urls'] = notification_urls
|
|
||||||
|
|
||||||
# Ignore text
|
# Ignore text
|
||||||
form_ignore_text = request.form.get('ignore-text')
|
form_ignore_text = form.ignore_text.data
|
||||||
ignore_text = []
|
datastore.data['watching'][uuid]['ignore_text'] = form_ignore_text
|
||||||
|
|
||||||
|
# Reset the previous_md5 so we process a new snapshot including stripping ignore text.
|
||||||
if form_ignore_text:
|
if form_ignore_text:
|
||||||
for text in form_ignore_text.strip().split("\n"):
|
|
||||||
text = text.strip()
|
|
||||||
if len(text):
|
|
||||||
ignore_text.append(text)
|
|
||||||
|
|
||||||
datastore.data['watching'][uuid]['ignore_text'] = ignore_text
|
|
||||||
|
|
||||||
# Reset the previous_md5 so we process a new snapshot including stripping ignore text.
|
|
||||||
if len(datastore.data['watching'][uuid]['history']):
|
if len(datastore.data['watching'][uuid]['history']):
|
||||||
update_obj['previous_md5'] = get_current_checksum_include_ignore_text(uuid=uuid)
|
update_obj['previous_md5'] = get_current_checksum_include_ignore_text(uuid=uuid)
|
||||||
|
|
||||||
|
|
||||||
# CSS Filter
|
datastore.data['watching'][uuid]['css_filter'] = form.css_filter.data.strip()
|
||||||
css_filter = request.form.get('css_filter')
|
|
||||||
if css_filter:
|
|
||||||
datastore.data['watching'][uuid]['css_filter'] = css_filter.strip()
|
|
||||||
|
|
||||||
# Reset the previous_md5 so we process a new snapshot including stripping ignore text.
|
# Reset the previous_md5 so we process a new snapshot including stripping ignore text.
|
||||||
|
if form.css_filter.data.strip() != datastore.data['watching'][uuid]['css_filter']:
|
||||||
if len(datastore.data['watching'][uuid]['history']):
|
if len(datastore.data['watching'][uuid]['history']):
|
||||||
update_obj['previous_md5'] = get_current_checksum_include_ignore_text(uuid=uuid)
|
update_obj['previous_md5'] = get_current_checksum_include_ignore_text(uuid=uuid)
|
||||||
|
|
||||||
|
|
||||||
validators.url(url) # @todo switch to prop/attr/observer
|
|
||||||
datastore.data['watching'][uuid].update(update_obj)
|
datastore.data['watching'][uuid].update(update_obj)
|
||||||
datastore.needs_write = True
|
datastore.needs_write = True
|
||||||
flash("Updated watch.")
|
flash("Updated watch.")
|
||||||
|
@ -428,10 +405,9 @@ def changedetection_app(config=None, datastore_o=None):
|
||||||
# Queue the watch for immediate recheck
|
# Queue the watch for immediate recheck
|
||||||
update_q.put(uuid)
|
update_q.put(uuid)
|
||||||
|
|
||||||
trigger_n = request.form.get('trigger-test-notification')
|
if form.trigger_check.data:
|
||||||
if trigger_n:
|
n_object = {'watch_url': form.url.data.strip(),
|
||||||
n_object = {'watch_url': url,
|
'notification_urls': form.notification_urls.data}
|
||||||
'notification_urls': notification_urls}
|
|
||||||
notification_q.put(n_object)
|
notification_q.put(n_object)
|
||||||
|
|
||||||
flash('Notifications queued.')
|
flash('Notifications queued.')
|
||||||
|
@ -439,7 +415,7 @@ def changedetection_app(config=None, datastore_o=None):
|
||||||
return redirect(url_for('index'))
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
output = render_template("edit.html", uuid=uuid, watch=datastore.data['watching'][uuid])
|
output = render_template("edit.html", uuid=uuid, watch=datastore.data['watching'][uuid], form=form)
|
||||||
|
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
@ -447,92 +423,62 @@ def changedetection_app(config=None, datastore_o=None):
|
||||||
@login_required
|
@login_required
|
||||||
def settings_page():
|
def settings_page():
|
||||||
|
|
||||||
|
from backend import forms
|
||||||
|
form = forms.globalSettingsForm(request.form)
|
||||||
|
|
||||||
if request.method == 'GET':
|
if request.method == 'GET':
|
||||||
if request.values.get('notification-test'):
|
form.minutes_between_check.data = int(datastore.data['settings']['requests']['minutes_between_check'] / 60)
|
||||||
url_count = len(datastore.data['settings']['application']['notification_urls'])
|
form.notification_urls.data = datastore.data['settings']['application']['notification_urls']
|
||||||
if url_count:
|
|
||||||
import apprise
|
|
||||||
apobj = apprise.Apprise()
|
|
||||||
apobj.debug = True
|
|
||||||
|
|
||||||
# Add each notification
|
# Password unset is a GET
|
||||||
for n in datastore.data['settings']['application']['notification_urls']:
|
if request.values.get('removepassword') == 'true':
|
||||||
apobj.add(n)
|
|
||||||
outcome = apobj.notify(
|
|
||||||
body='Hello from the worlds best and simplest web page change detection and monitoring service!',
|
|
||||||
title='Changedetection.io Notification Test',
|
|
||||||
)
|
|
||||||
|
|
||||||
if outcome:
|
|
||||||
flash("{} Notification URLs reached.".format(url_count), "notice")
|
|
||||||
else:
|
|
||||||
flash("One or more Notification URLs failed", 'error')
|
|
||||||
|
|
||||||
return redirect(url_for('settings_page'))
|
|
||||||
|
|
||||||
if request.values.get('removepassword'):
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
datastore.data['settings']['application']['password'] = False
|
datastore.data['settings']['application']['password'] = False
|
||||||
flash("Password protection removed.", 'notice')
|
flash("Password protection removed.", 'notice')
|
||||||
flask_login.logout_user()
|
flask_login.logout_user()
|
||||||
|
|
||||||
return redirect(url_for('settings_page'))
|
if request.method == 'POST' and form.validate():
|
||||||
|
|
||||||
if request.method == 'POST':
|
datastore.data['settings']['application']['notification_urls'] = form.notification_urls.data
|
||||||
|
datastore.data['settings']['requests']['minutes_between_check'] = form.minutes_between_check.data * 60
|
||||||
|
|
||||||
password = request.values.get('password')
|
if len(form.notification_urls.data):
|
||||||
if password:
|
import apprise
|
||||||
import hashlib
|
apobj = apprise.Apprise()
|
||||||
import base64
|
apobj.debug = True
|
||||||
import secrets
|
|
||||||
|
|
||||||
# Make a new salt on every new password and store it with the password
|
# Add each notification
|
||||||
salt = secrets.token_bytes(32)
|
for n in datastore.data['settings']['application']['notification_urls']:
|
||||||
|
apobj.add(n)
|
||||||
|
outcome = apobj.notify(
|
||||||
|
body='Hello from the worlds best and simplest web page change detection and monitoring service!',
|
||||||
|
title='Changedetection.io Notification Test',
|
||||||
|
)
|
||||||
|
|
||||||
key = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, 100000)
|
if outcome:
|
||||||
store = base64.b64encode(salt + key).decode('ascii')
|
flash("{} Notification URLs reached.".format(len(form.notification_urls.data)), "notice")
|
||||||
datastore.data['settings']['application']['password'] = store
|
else:
|
||||||
|
flash("One or more Notification URLs failed", 'error')
|
||||||
|
|
||||||
|
|
||||||
|
datastore.data['settings']['application']['notification_urls'] = form.notification_urls.data
|
||||||
|
datastore.needs_write = True
|
||||||
|
|
||||||
|
if form.trigger_check.data:
|
||||||
|
n_object = {'watch_url': "Test from changedetection.io!",
|
||||||
|
'notification_urls': form.notification_urls.data}
|
||||||
|
notification_q.put(n_object)
|
||||||
|
flash('Notifications queued.')
|
||||||
|
|
||||||
|
if form.password.encrypted_password:
|
||||||
|
datastore.data['settings']['application']['password'] = form.password.encrypted_password
|
||||||
flash("Password protection enabled.", 'notice')
|
flash("Password protection enabled.", 'notice')
|
||||||
flask_login.logout_user()
|
flask_login.logout_user()
|
||||||
return redirect(url_for('index'))
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
try:
|
|
||||||
minutes = int(request.values.get('minutes').strip())
|
|
||||||
except ValueError:
|
|
||||||
flash("Invalid value given, use an integer.", "error")
|
|
||||||
|
|
||||||
else:
|
|
||||||
if minutes >= 1:
|
|
||||||
datastore.data['settings']['requests']['minutes_between_check'] = minutes
|
|
||||||
datastore.needs_write = True
|
|
||||||
else:
|
|
||||||
flash("Must be atleast 1 minute.", 'error')
|
|
||||||
|
|
||||||
# 'validators' package doesnt work because its often a non-stanadard protocol. :(
|
|
||||||
datastore.data['settings']['application']['notification_urls'] = []
|
|
||||||
trigger_n = request.form.get('trigger-test-notification')
|
|
||||||
|
|
||||||
for n in request.values.get('notification_urls').strip().split("\n"):
|
|
||||||
url = n.strip()
|
|
||||||
datastore.data['settings']['application']['notification_urls'].append(url)
|
|
||||||
datastore.needs_write = True
|
|
||||||
|
|
||||||
if trigger_n:
|
|
||||||
n_object = {'watch_url': "Test from changedetection.io!",
|
|
||||||
'notification_urls': datastore.data['settings']['application']['notification_urls']}
|
|
||||||
notification_q.put(n_object)
|
|
||||||
flash('Notifications queued.')
|
|
||||||
|
|
||||||
flash("Settings updated.")
|
flash("Settings updated.")
|
||||||
|
|
||||||
|
output = render_template("settings.html", form=form)
|
||||||
output = render_template("settings.html",
|
|
||||||
minutes=datastore.data['settings']['requests']['minutes_between_check'],
|
|
||||||
notification_urls="\r\n".join(
|
|
||||||
datastore.data['settings']['application']['notification_urls']))
|
|
||||||
return output
|
return output
|
||||||
|
|
||||||
@app.route("/import", methods=['GET', "POST"])
|
@app.route("/import", methods=['GET', "POST"])
|
||||||
|
|
|
@ -0,0 +1,109 @@
|
||||||
|
from wtforms import Form, BooleanField, StringField, PasswordField, validators, IntegerField, fields, TextAreaField, \
|
||||||
|
Field
|
||||||
|
from wtforms import widgets
|
||||||
|
from wtforms.fields import html5
|
||||||
|
|
||||||
|
|
||||||
|
class StringListField(StringField):
|
||||||
|
widget = widgets.TextArea()
|
||||||
|
|
||||||
|
def _value(self):
|
||||||
|
if self.data:
|
||||||
|
return "\r\n".join(self.data)
|
||||||
|
else:
|
||||||
|
return u''
|
||||||
|
|
||||||
|
# incoming
|
||||||
|
def process_formdata(self, valuelist):
|
||||||
|
if valuelist:
|
||||||
|
# Remove empty strings
|
||||||
|
cleaned = list(filter(None, valuelist[0].split("\n")))
|
||||||
|
self.data = [x.strip() for x in cleaned]
|
||||||
|
p = 1
|
||||||
|
else:
|
||||||
|
self.data = []
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class SaltyPasswordField(StringField):
|
||||||
|
widget = widgets.PasswordInput()
|
||||||
|
encrypted_password = ""
|
||||||
|
|
||||||
|
def build_password(self, password):
|
||||||
|
import hashlib
|
||||||
|
import base64
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
# Make a new salt on every new password and store it with the password
|
||||||
|
salt = secrets.token_bytes(32)
|
||||||
|
|
||||||
|
key = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, 100000)
|
||||||
|
store = base64.b64encode(salt + key).decode('ascii')
|
||||||
|
|
||||||
|
return store
|
||||||
|
|
||||||
|
# incoming
|
||||||
|
def process_formdata(self, valuelist):
|
||||||
|
if valuelist:
|
||||||
|
# Remove empty strings
|
||||||
|
self.encrypted_password = self.build_password(valuelist[0])
|
||||||
|
self.data=[]
|
||||||
|
else:
|
||||||
|
self.data = []
|
||||||
|
|
||||||
|
|
||||||
|
# Separated by key:value
|
||||||
|
class StringDictKeyValue(StringField):
|
||||||
|
widget = widgets.TextArea()
|
||||||
|
|
||||||
|
def _value(self):
|
||||||
|
if self.data:
|
||||||
|
output = u''
|
||||||
|
for k in self.data.keys():
|
||||||
|
output += "{}: {}\r\n".format(k, self.data[k])
|
||||||
|
|
||||||
|
return output
|
||||||
|
else:
|
||||||
|
return u''
|
||||||
|
|
||||||
|
# incoming
|
||||||
|
def process_formdata(self, valuelist):
|
||||||
|
if valuelist:
|
||||||
|
self.data = {}
|
||||||
|
# Remove empty strings
|
||||||
|
cleaned = list(filter(None, valuelist[0].split("\n")))
|
||||||
|
for s in cleaned:
|
||||||
|
parts = s.strip().split(':')
|
||||||
|
if len(parts) == 2:
|
||||||
|
self.data.update({parts[0].strip(): parts[1].strip()})
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.data = {}
|
||||||
|
|
||||||
|
|
||||||
|
class watchForm(Form):
|
||||||
|
# https://wtforms.readthedocs.io/en/2.3.x/fields/#module-wtforms.fields.html5
|
||||||
|
# `require_tld` = False is needed even for the test harness "http://localhost:5005.." to run
|
||||||
|
|
||||||
|
url = html5.URLField('URL', [validators.URL(require_tld=False)])
|
||||||
|
tag = StringField('Tag', [validators.Optional(), validators.Length(max=35)])
|
||||||
|
minutes_between_check = html5.IntegerField('Maximum time in minutes until recheck',
|
||||||
|
[validators.Optional(), validators.NumberRange(min=1)])
|
||||||
|
css_filter = StringField('CSS Filter')
|
||||||
|
|
||||||
|
ignore_text = StringListField('Ignore Text')
|
||||||
|
notification_urls = StringListField('Notification URL List')
|
||||||
|
headers = StringDictKeyValue('Request Headers')
|
||||||
|
trigger_check = BooleanField('Send test notification on save')
|
||||||
|
|
||||||
|
|
||||||
|
class globalSettingsForm(Form):
|
||||||
|
|
||||||
|
password = SaltyPasswordField()
|
||||||
|
remove_password = BooleanField('Remove password')
|
||||||
|
|
||||||
|
minutes_between_check = html5.IntegerField('Maximum time in minutes until recheck',
|
||||||
|
[validators.NumberRange(min=1)])
|
||||||
|
|
||||||
|
notification_urls = StringListField('Notification URL List')
|
||||||
|
trigger_check = BooleanField('Send test notification on save')
|
|
@ -5,7 +5,7 @@
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
"scss": "node-sass --watch *.scss -o ."
|
"scss": "node-sass --watch styles.scss diff.scss -o ."
|
||||||
},
|
},
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
|
|
|
@ -225,15 +225,39 @@ footer {
|
||||||
.paused-state.state-False:hover img {
|
.paused-state.state-False:hover img {
|
||||||
opacity: 0.8; }
|
opacity: 0.8; }
|
||||||
|
|
||||||
.pure-form label {
|
.monospaced-textarea textarea {
|
||||||
font-weight: bold; }
|
|
||||||
|
|
||||||
.pure-form input[type=url] {
|
|
||||||
width: 100%; }
|
|
||||||
|
|
||||||
.pure-form textarea {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-size: 14px; }
|
font-family: monospace;
|
||||||
|
white-space: pre;
|
||||||
|
overflow-wrap: normal;
|
||||||
|
overflow-x: scroll; }
|
||||||
|
|
||||||
|
.pure-form {
|
||||||
|
/* The input fields with errors */
|
||||||
|
/* The list of errors */ }
|
||||||
|
.pure-form .pure-control-group, .pure-form .pure-group, .pure-form .pure-controls {
|
||||||
|
padding-bottom: 1em; }
|
||||||
|
.pure-form .pure-control-group dd, .pure-form .pure-group dd, .pure-form .pure-controls dd {
|
||||||
|
margin: 0px; }
|
||||||
|
.pure-form .error input {
|
||||||
|
background-color: #ffebeb; }
|
||||||
|
.pure-form ul.errors {
|
||||||
|
padding: .5em .6em;
|
||||||
|
border: 1px solid #dd0000;
|
||||||
|
border-radius: 4px;
|
||||||
|
vertical-align: middle;
|
||||||
|
-webkit-box-sizing: border-box;
|
||||||
|
box-sizing: border-box; }
|
||||||
|
.pure-form ul.errors li {
|
||||||
|
margin-left: 1em;
|
||||||
|
color: #dd0000; }
|
||||||
|
.pure-form label {
|
||||||
|
font-weight: bold; }
|
||||||
|
.pure-form input[type=url] {
|
||||||
|
width: 100%; }
|
||||||
|
.pure-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 14px; }
|
||||||
|
|
||||||
@media only screen and (max-width: 760px), (min-device-width: 768px) and (max-device-width: 1024px) {
|
@media only screen and (max-width: 760px), (min-device-width: 768px) and (max-device-width: 1024px) {
|
||||||
.box {
|
.box {
|
||||||
|
|
|
@ -297,8 +297,43 @@ footer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.monospaced-textarea {
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
font-family: monospace;
|
||||||
|
white-space: pre;
|
||||||
|
overflow-wrap: normal;
|
||||||
|
overflow-x: scroll;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.pure-form {
|
.pure-form {
|
||||||
|
.pure-control-group, .pure-group, .pure-controls {
|
||||||
|
padding-bottom: 1em;
|
||||||
|
dd {
|
||||||
|
margin: 0px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* The input fields with errors */
|
||||||
|
.error {
|
||||||
|
input {
|
||||||
|
background-color: #ffebeb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* The list of errors */
|
||||||
|
ul.errors {
|
||||||
|
padding: .5em .6em;
|
||||||
|
border: 1px solid #dd0000;
|
||||||
|
border-radius: 4px;
|
||||||
|
vertical-align: middle;
|
||||||
|
-webkit-box-sizing: border-box;
|
||||||
|
box-sizing: border-box;
|
||||||
|
li {
|
||||||
|
margin-left: 1em;
|
||||||
|
color: #dd0000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
{% macro render_field(field) %}
|
||||||
|
<dt {% if field.errors %} class="error" {% endif %}>{{ field.label }}
|
||||||
|
<dd {% if field.errors %} class="error" {% endif %}>{{ field(**kwargs)|safe }}
|
||||||
|
{% if field.errors %}
|
||||||
|
<ul class=errors>
|
||||||
|
{% for error in field.errors %}
|
||||||
|
<li>{{ error }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
{% endmacro %}
|
|
@ -1,102 +1,58 @@
|
||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="edit-form">
|
{% from '_helpers.jinja' import render_field %}
|
||||||
|
<div class="edit-form monospaced-textarea">
|
||||||
|
|
||||||
<form class="pure-form pure-form-stacked" action="/edit/{{uuid}}" method="POST">
|
<form class="pure-form pure-form-stacked" action="/edit/{{uuid}}" method="POST">
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
<label for="url">URL</label>
|
{{ render_field(form.url, placeholder="https://...", size=30, required=true) }}
|
||||||
<input type="url" id="url" required="" placeholder="https://..." name="url" value="{{ watch.url}}"
|
|
||||||
size="50"/>
|
|
||||||
<span class="pure-form-message-inline">This is a required field.</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
<label for="tag">Tag</label>
|
{{ render_field(form.tag, size=10) }}
|
||||||
<input type="text" placeholder="tag" size="10" id="tag" name="tag" value="{{ watch.tag}}"/>
|
|
||||||
<span class="pure-form-message-inline">Grouping tags, can be a comma separated list.</span>
|
|
||||||
</div>
|
</div>
|
||||||
</br>
|
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
<label for="minutes">Maximum time in minutes until recheck.</label>
|
{{ render_field(form.minutes_between_check, size=5) }}
|
||||||
<input type="text" id="minutes" name="minutes" value="{{watch.minutes_between_check}}"
|
|
||||||
size="5"/>
|
|
||||||
<span class="pure-form-message-inline">Minimum 1 minute between recheck</span>
|
|
||||||
</div>
|
</div>
|
||||||
<br/>
|
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
<label for="minutes">CSS Filter</label>
|
{{ render_field(form.css_filter, size=25, placeholder=".class-name or #some-id, or other CSS selector rule.") }}
|
||||||
<input type="text" id="css_filter" name="css_filter" value="{{watch.css_filter}}"
|
|
||||||
size="25" placeholder=".class-name or #some-id, or other CSS selector rule."/>
|
|
||||||
<span class="pure-form-message-inline">Limit text to this CSS rule, only text matching this CSS rule is included.<br/>
|
<span class="pure-form-message-inline">Limit text to this CSS rule, only text matching this CSS rule is included.<br/>
|
||||||
Please be sure that you thoroughly understand how to write CSS selector rules before filing an issue on GitHub!<br/>
|
Please be sure that you thoroughly understand how to write CSS selector rules before filing an issue on GitHub!<br/>
|
||||||
Go <a href="https://github.com/dgtlmoon/changedetection.io/wiki/CSS-Selector-help">here for more CSS selector help</a>
|
Go <a href="https://github.com/dgtlmoon/changedetection.io/wiki/CSS-Selector-help">here for more CSS selector help</a>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<br/>
|
|
||||||
<!-- @todo: move to tabs --->
|
<!-- @todo: move to tabs --->
|
||||||
<fieldset class="pure-group">
|
<fieldset class="pure-group">
|
||||||
<label for="ignore-text">Ignore text</label>
|
{{ render_field(form.ignore_text, rows=5) }}
|
||||||
|
|
||||||
<textarea id="ignore-text" name="ignore-text" class="pure-input-1-2" placeholder=""
|
|
||||||
style="width: 100%;
|
|
||||||
font-family:monospace;
|
|
||||||
white-space: pre;
|
|
||||||
overflow-wrap: normal;
|
|
||||||
overflow-x: scroll;" rows="5">{% for value in watch.ignore_text %}{{ value }}
|
|
||||||
{% endfor %}</textarea>
|
|
||||||
<span class="pure-form-message-inline">Each line will be processed separately as an ignore rule.</span>
|
<span class="pure-form-message-inline">Each line will be processed separately as an ignore rule.</span>
|
||||||
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<!-- @todo: move to tabs --->
|
|
||||||
<fieldset class="pure-group">
|
<fieldset class="pure-group">
|
||||||
<label for="headers">Extra request headers</label>
|
{{ render_field(form.headers, rows=5, placeholder="Example
|
||||||
|
|
||||||
<textarea id="headers" name="headers" class="pure-input-1-2" placeholder="Example
|
|
||||||
Cookie: foobar
|
Cookie: foobar
|
||||||
User-Agent: wonderbra 1.0"
|
User-Agent: wonderbra 1.0") }}
|
||||||
style="width: 100%;
|
|
||||||
font-family:monospace;
|
|
||||||
white-space: pre;
|
|
||||||
overflow-wrap: normal;
|
|
||||||
overflow-x: scroll;" rows="5">{% for key, value in watch.headers.items() %}{{ key }}: {{ value }}
|
|
||||||
{% endfor %}</textarea>
|
|
||||||
<br/>
|
|
||||||
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<div class="pure-control-group">
|
|
||||||
<label for="tag">Notification URLs</label>
|
|
||||||
<textarea id="notification_urls" name="notification_urls" class="pure-input-1-2" placeholder=""
|
|
||||||
style="width: 100%;
|
|
||||||
font-family:monospace;
|
|
||||||
white-space: pre;
|
|
||||||
overflow-wrap: normal;
|
|
||||||
overflow-x: scroll;" rows="5">{% for value in watch.notification_urls %}{{ value }}
|
|
||||||
{% endfor %}</textarea>
|
|
||||||
<span class="pure-form-message-inline">Use <a target=_new href="https://github.com/caronc/apprise">AppRise URLs</a> for notification to just about any service!</a> </span>
|
|
||||||
<br/>
|
|
||||||
<div class="pure-controls">
|
|
||||||
<span class="pure-form-message-inline"><label for="trigger-test-notification" class="pure-checkbox">
|
|
||||||
<input type="checkbox" id="trigger-test-notification" name="trigger-test-notification"> Send test notification on save.</label></span>
|
|
||||||
|
|
||||||
</div>
|
<div class="pure-control-group">
|
||||||
|
{{ render_field(form.notification_urls, rows=5, placeholder="Gitter - gitter://token/room
|
||||||
|
Office365 - o365://TenantID:AccountEmail/ClientID/ClientSecret/TargetEmail
|
||||||
|
AWS SNS - sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo
|
||||||
|
SMTPS - mailtos://user:pass@mail.domain.com?to=receivingAddress@example.com
|
||||||
|
") }}
|
||||||
|
<span class="pure-form-message-inline">Use <a target=_new href="https://github.com/caronc/apprise">AppRise URLs</a> for notification to just about any service!</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pure-controls">
|
||||||
|
{{ render_field(form.trigger_check, rows=5) }}
|
||||||
</div>
|
</div>
|
||||||
<br/>
|
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
<button type="submit" class="pure-button pure-button-primary">Save</button>
|
<button type="submit" class="pure-button pure-button-primary">Save</button>
|
||||||
</div>
|
</div>
|
||||||
<br/>
|
<br/>
|
||||||
|
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
<a href="/" class="pure-button button-small button-cancel">Cancel</a>
|
<a href="/" class="pure-button button-small button-cancel">Cancel</a>
|
||||||
<a href="/api/delete?uuid={{uuid}}"
|
<a href="/api/delete?uuid={{uuid}}"
|
||||||
class="pure-button button-small button-error ">Delete</a>
|
class="pure-button button-small button-error ">Delete</a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|
|
@ -1,42 +1,28 @@
|
||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% from '_helpers.jinja' import render_field %}
|
||||||
|
|
||||||
<div class="edit-form">
|
<div class="edit-form">
|
||||||
|
|
||||||
|
|
||||||
<form class="pure-form pure-form-stacked settings" action="/settings" method="POST">
|
<form class="pure-form pure-form-stacked settings" action="/settings" method="POST">
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
<label for="minutes">Maximum time in minutes until recheck.</label>
|
{{ render_field(form.minutes_between_check, size=5) }}
|
||||||
<input type="text" id="minutes" required="" name="minutes" value="{{minutes}}"
|
|
||||||
size="5"/>
|
|
||||||
<span class="pure-form-message-inline">This is a required field.</span><br/>
|
|
||||||
<span class="pure-form-message-inline">Minimum 1 minute between recheck</span>
|
|
||||||
</div>
|
</div>
|
||||||
<br/>
|
|
||||||
<hr>
|
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
<label for="minutes">Password protection</label>
|
|
||||||
<input type="password" id="password" name="password" size="15"/>
|
|
||||||
{% if current_user.is_authenticated %}
|
{% if current_user.is_authenticated %}
|
||||||
<a href="/settings?removepassword=true" class="pure-button pure-button-primary">Remove password</a>
|
<a href="/settings?removepassword=true" class="pure-button pure-button-primary">Remove password</a>
|
||||||
|
{% else %}
|
||||||
|
{{ render_field(form.password, size=10) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<br/>
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
<label for="minutes">Global notification settings</label><br/>
|
{{ render_field(form.notification_urls, rows=5, placeholder="Gitter - gitter://token/room
|
||||||
Notification URLs <a href="https://github.com/caronc/apprise"> see Apprise examples</a>.
|
|
||||||
<textarea style="overflow-wrap: normal; overflow-x: scroll;" id="notification_urls" name="notification_urls" cols="80"
|
|
||||||
rows="6" wrap=off placeholder="Example:
|
|
||||||
|
|
||||||
Gitter - gitter://token/room
|
|
||||||
Office365 - o365://TenantID:AccountEmail/ClientID/ClientSecret/TargetEmail
|
Office365 - o365://TenantID:AccountEmail/ClientID/ClientSecret/TargetEmail
|
||||||
AWS SNS - sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo
|
AWS SNS - sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo
|
||||||
SMTPS - mailtos://user:pass@mail.domain.com?to=receivingAddress@example.com
|
SMTPS - mailtos://user:pass@mail.domain.com?to=receivingAddress@example.com
|
||||||
">{{notification_urls}}</textarea>
|
") }}
|
||||||
|
<span class="pure-form-message-inline">Use <a target=_new href="https://github.com/caronc/apprise">AppRise URLs</a> for notification to just about any service!</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="pure-controls">
|
<div class="pure-controls">
|
||||||
<span class="pure-form-message-inline"><label for="trigger-test-notification" class="pure-checkbox">
|
<span class="pure-form-message-inline"><label for="trigger-test-notification" class="pure-checkbox">
|
||||||
|
|
|
@ -82,7 +82,7 @@ def set_modified_ignore_response():
|
||||||
def test_check_ignore_text_functionality(client, live_server):
|
def test_check_ignore_text_functionality(client, live_server):
|
||||||
sleep_time_for_fetch_thread = 3
|
sleep_time_for_fetch_thread = 3
|
||||||
|
|
||||||
ignore_text = "XXXXX\nYYYYY\nZZZZZ"
|
ignore_text = "XXXXX\r\nYYYYY\r\nZZZZZ"
|
||||||
set_original_ignore_response()
|
set_original_ignore_response()
|
||||||
|
|
||||||
# Give the endpoint time to spin up
|
# Give the endpoint time to spin up
|
||||||
|
@ -107,7 +107,7 @@ def test_check_ignore_text_functionality(client, live_server):
|
||||||
# Add our URL to the import page
|
# Add our URL to the import page
|
||||||
res = client.post(
|
res = client.post(
|
||||||
url_for("edit_page", uuid="first"),
|
url_for("edit_page", uuid="first"),
|
||||||
data={"ignore-text": ignore_text, "url": test_url, "tag": "", "headers": ""},
|
data={"ignore_text": ignore_text, "url": test_url},
|
||||||
follow_redirects=True
|
follow_redirects=True
|
||||||
)
|
)
|
||||||
assert b"Updated watch." in res.data
|
assert b"Updated watch." in res.data
|
||||||
|
|
|
@ -6,8 +6,9 @@ services:
|
||||||
hostname: changedetection.io
|
hostname: changedetection.io
|
||||||
volumes:
|
volumes:
|
||||||
- changedetection-data:/datastore
|
- changedetection-data:/datastore
|
||||||
|
|
||||||
# environment:
|
# environment:
|
||||||
|
# - PUID=1000
|
||||||
|
# - PGID=1000
|
||||||
# Proxy support example.
|
# Proxy support example.
|
||||||
# - HTTP_PROXY="socks5h://10.10.1.10:1080"
|
# - HTTP_PROXY="socks5h://10.10.1.10:1080"
|
||||||
# - HTTPS_PROXY="socks5h://10.10.1.10:1080"
|
# - HTTPS_PROXY="socks5h://10.10.1.10:1080"
|
||||||
|
|
|
@ -11,6 +11,9 @@ feedgen ~= 0.9
|
||||||
flask-login ~= 0.5
|
flask-login ~= 0.5
|
||||||
pytz
|
pytz
|
||||||
urllib3
|
urllib3
|
||||||
|
wtforms ~= 2.3.3
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Notification library
|
# Notification library
|
||||||
apprise ~= 0.9
|
apprise ~= 0.9
|
||||||
|
|
Ładowanie…
Reference in New Issue