diff --git a/changedetectionio/__init__.py b/changedetectionio/__init__.py index aa9c810b..4baa923e 100644 --- a/changedetectionio/__init__.py +++ b/changedetectionio/__init__.py @@ -519,7 +519,7 @@ def changedetection_app(config=None, datastore_o=None): def edit_page(uuid): from changedetectionio import forms - + using_default_check_time = True # More for testing, possible to return the first/only if not datastore.data['watching'].keys(): flash("No watches to edit", "error") @@ -532,27 +532,39 @@ def changedetection_app(config=None, datastore_o=None): flash("No watch with the UUID %s found." % (uuid), "error") return redirect(url_for('index')) + # be sure we update with a copy instead of accidently editing the live object by reference + default = deepcopy(datastore.data['watching'][uuid]) + + # Show system wide default if nothing configured + if datastore.data['watching'][uuid]['fetch_backend'] is None: + default['fetch_backend'] = datastore.data['settings']['application']['fetch_backend'] + + # Show system wide default if nothing configured + if all(value == 0 or value == None for value in datastore.data['watching'][uuid]['time_between_check'].values()): + default['time_between_check'] = deepcopy(datastore.data['settings']['requests']['time_between_check']) + form = forms.watchForm(formdata=request.form if request.method == 'POST' else None, - data=datastore.data['watching'][uuid] + data=default ) - if request.method == 'GET': - # Set some defaults that refer to the main config when None, we do the same in POST, - # probably there should be a nice little handler for this. - if datastore.data['watching'][uuid]['fetch_backend'] is None: - form.fetch_backend.data = datastore.data['settings']['application']['fetch_backend'] - if datastore.data['watching'][uuid]['minutes_between_check'] is None: - form.minutes_between_check.data = datastore.data['settings']['requests']['minutes_between_check'] if request.method == 'POST' and form.validate(): + extra_update_obj = {} # Re #110, if they submit the same as the default value, set it to None, so we continue to follow the default - if form.minutes_between_check.data == datastore.data['settings']['requests']['minutes_between_check']: - form.minutes_between_check.data = None + # Assume we use the default value, unless something relevant is different, then use the form value + # values could be None, 0 etc. + # Set to None unless the next for: says that something is different + extra_update_obj['time_between_check'] = dict.fromkeys(form.time_between_check.data) + for k, v in form.time_between_check.data.items(): + if v and v != datastore.data['settings']['requests']['time_between_check'][k]: + extra_update_obj['time_between_check'] = form.time_between_check.data + using_default_check_time = False + break + + # Use the default if its the same as system wide if form.fetch_backend.data == datastore.data['settings']['application']['fetch_backend']: - form.fetch_backend.data = None - - extra_update_obj = {} + extra_update_obj['fetch_backend'] = None # Notification URLs datastore.data['watching'][uuid]['notification_urls'] = form.notification_urls.data @@ -578,7 +590,7 @@ def changedetection_app(config=None, datastore_o=None): # Re #286 - We wait for syncing new data to disk in another thread every 60 seconds # But in the case something is added we should save straight away - datastore.sync_to_json() + datastore.needs_write_urgent = True # Queue the watch for immediate recheck update_q.put(uuid) @@ -597,12 +609,12 @@ def changedetection_app(config=None, datastore_o=None): if request.method == 'POST' and not form.validate(): flash("An error occurred, please see below.", "error") - has_empty_checktime = datastore.data['watching'][uuid].has_empty_checktime + output = render_template("edit.html", uuid=uuid, watch=datastore.data['watching'][uuid], form=form, - has_empty_checktime=has_empty_checktime, + has_empty_checktime=using_default_check_time, current_base_url=datastore.data['settings']['application']['base_url'], emailprefix=os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False) ) @@ -632,15 +644,15 @@ def changedetection_app(config=None, datastore_o=None): if form.validate(): datastore.data['settings']['application'].update(form.data['application']) datastore.data['settings']['requests'].update(form.data['requests']) - datastore.needs_write = True if not os.getenv("SALTED_PASS", False) and len(form.application.form.password.encrypted_password): datastore.data['settings']['application']['password'] = form.application.form.password.encrypted_password - datastore.needs_write = True + datastore.needs_write_urgent = True flash("Password protection enabled.", 'notice') flask_login.logout_user() return redirect(url_for('index')) + datastore.needs_write_urgent = True flash("Settings updated.") else: @@ -1177,7 +1189,7 @@ def ticker_thread_check_time_launch_checks(): now = time.time() recheck_time_minimum_seconds = int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 60)) - recheck_time_system_seconds = int(copied_datastore.data['settings']['requests']['minutes_between_check']) * 60 + recheck_time_system_seconds = datastore.threshold_seconds for uuid, watch in copied_datastore.data['watching'].items(): @@ -1187,8 +1199,9 @@ def ticker_thread_check_time_launch_checks(): # If they supplied an individual entry minutes to threshold. threshold = now - if watch.threshold_seconds: - threshold -= watch.threshold_seconds + watch_threshold_seconds = watch.threshold_seconds() + if watch_threshold_seconds: + threshold -= watch_threshold_seconds else: threshold -= recheck_time_system_seconds diff --git a/changedetectionio/fetch_site_status.py b/changedetectionio/fetch_site_status.py index 7fd86611..55bf49dc 100644 --- a/changedetectionio/fetch_site_status.py +++ b/changedetectionio/fetch_site_status.py @@ -164,8 +164,8 @@ class perform_site_check(): else: fetched_md5 = hashlib.md5(stripped_text_from_html).hexdigest() - # On the first run of a site, watch['previous_md5'] will be an empty string, set it the current one. - if not len(watch['previous_md5']): + # On the first run of a site, watch['previous_md5'] will be None, set it the current one. + if not watch.get('previous_md5'): watch['previous_md5'] = fetched_md5 update_obj["previous_md5"] = fetched_md5 diff --git a/changedetectionio/forms.py b/changedetectionio/forms.py index 45c1457a..72dae639 100644 --- a/changedetectionio/forms.py +++ b/changedetectionio/forms.py @@ -89,6 +89,13 @@ class SaltyPasswordField(StringField): else: self.data = False +class TimeBetweenCheckForm(Form): + weeks = IntegerField('Weeks', validators=[validators.Optional(), validators.NumberRange(min=0, message="Should contain zero or more seconds")]) + days = IntegerField('Days', validators=[validators.Optional(), validators.NumberRange(min=0, message="Should contain zero or more seconds")]) + hours = IntegerField('Hours', validators=[validators.Optional(), validators.NumberRange(min=0, message="Should contain zero or more seconds")]) + minutes = IntegerField('Minutes', validators=[validators.Optional(), validators.NumberRange(min=0, message="Should contain zero or more seconds")]) + seconds = IntegerField('Seconds', validators=[validators.Optional(), validators.NumberRange(min=0, message="Should contain zero or more seconds")]) + # @todo add total seconds minimum validatior = minimum_seconds_recheck_time # Separated by key:value class StringDictKeyValue(StringField): @@ -317,8 +324,7 @@ class watchForm(commonSettingsForm): url = fields.URLField('URL', validators=[validateURL()]) tag = StringField('Group tag', [validators.Optional(), validators.Length(max=35)], default='') - minutes_between_check = fields.IntegerField('Maximum time in minutes until recheck', - [validators.Optional(), validators.NumberRange(min=1)]) + time_between_check = FormField(TimeBetweenCheckForm) css_filter = StringField('CSS/JSON/XPATH Filter', [ValidateCSSJSONXPATHInput()], default='') @@ -351,8 +357,9 @@ class watchForm(commonSettingsForm): # datastore.data['settings']['requests'].. class globalSettingsRequestForm(Form): - minutes_between_check = fields.IntegerField('Maximum time in minutes until recheck', - [validators.NumberRange(min=1)]) + time_between_check = FormField(TimeBetweenCheckForm) + + # datastore.data['settings']['application'].. class globalSettingsApplicationForm(commonSettingsForm): diff --git a/changedetectionio/model/App.py b/changedetectionio/model/App.py index c54df507..ebd5731a 100644 --- a/changedetectionio/model/App.py +++ b/changedetectionio/model/App.py @@ -10,9 +10,7 @@ from changedetectionio.notification import ( ) class model(dict): - def __init__(self, *arg, **kw): - super(model, self).__init__(*arg, **kw) - self.update({ + base_config = { 'note': "Hello! If you change this file manually, please be sure to restart your changedetection.io instance!", 'watching': {}, 'settings': { @@ -24,8 +22,7 @@ class model(dict): }, 'requests': { 'timeout': 15, # Default 15 seconds - # Default 3 hours - 'minutes_between_check': 3 * 60, # Default 3 hours + 'time_between_check': {'weeks': None, 'days': None, 'hours': 3, 'minutes': None, 'seconds': None}, 'workers': 10 # Number of threads, lower is better for slow connections }, 'application': { @@ -46,4 +43,8 @@ class model(dict): 'schema_version' : 0 } } - }) + } + + def __init__(self, *arg, **kw): + super(model, self).__init__(*arg, **kw) + self.update(self.base_config) diff --git a/changedetectionio/model/Watch.py b/changedetectionio/model/Watch.py index b86b930e..8283002c 100644 --- a/changedetectionio/model/Watch.py +++ b/changedetectionio/model/Watch.py @@ -12,17 +12,16 @@ from changedetectionio.notification import ( class model(dict): - def __init__(self, *arg, **kw): - self.update({ + base_config = { 'url': None, 'tag': None, 'last_checked': 0, 'last_changed': 0, 'paused': False, 'last_viewed': 0, # history key value of the last viewed via the [diff] link - 'newest_history_key': "", + 'newest_history_key': 0, 'title': None, - 'previous_md5': "", + 'previous_md5': False, 'uuid': str(uuid_builder.uuid4()), 'headers': {}, # Extra headers to send 'body': None, @@ -42,21 +41,27 @@ class model(dict): # Re #110, so then if this is set to None, we know to use the default value instead # Requires setting to None on submit if it's the same as the default # Should be all None by default, so we use the system default in this case. - 'minutes_between_check': None - }) + 'time_between_check': {'weeks': None, 'days': None, 'hours': None, 'minutes': None, 'seconds': None} + } + + def __init__(self, *arg, **kw): + self.update(self.base_config) # goes at the end so we update the default object with the initialiser super(model, self).__init__(*arg, **kw) @property def has_empty_checktime(self): - if self.get('minutes_between_check', None): - return False - return True + # using all() + dictionary comprehension + # Check if all values are 0 in dictionary + res = all(x == None or x == False or x==0 for x in self.get('time_between_check', {}).values()) + return res - @property def threshold_seconds(self): - sec = self.get('minutes_between_check', None) - if sec: - sec = sec * 60 - return sec + seconds = 0 + mtable = {'seconds': 1, 'minutes': 60, 'hours': 3600, 'days': 86400, 'weeks': 86400 * 7} + for m, n in mtable.items(): + x = self.get('time_between_check', {}).get(m, None) + if x: + seconds += x * n + return seconds diff --git a/changedetectionio/static/styles/styles.css b/changedetectionio/static/styles/styles.css index 2ff07ee1..174c9aea 100644 --- a/changedetectionio/static/styles/styles.css +++ b/changedetectionio/static/styles/styles.css @@ -440,3 +440,8 @@ ul { padding-left: 1em; padding-top: 0px; margin-top: 4px; } + +.time-check-widget tr { + display: inline; } + .time-check-widget tr input[type="number"] { + width: 4em; } diff --git a/changedetectionio/static/styles/styles.scss b/changedetectionio/static/styles/styles.scss index 79e12909..3b305b45 100644 --- a/changedetectionio/static/styles/styles.scss +++ b/changedetectionio/static/styles/styles.scss @@ -624,4 +624,13 @@ ul { padding-left: 1em; padding-top: 0px; margin-top: 4px; +} + +.time-check-widget { + tr { + display: inline; + input[type="number"] { + width: 4em; + } + } } \ No newline at end of file diff --git a/changedetectionio/store.py b/changedetectionio/store.py index 9aeebeeb..853f63fe 100644 --- a/changedetectionio/store.py +++ b/changedetectionio/store.py @@ -7,6 +7,7 @@ import uuid as uuid_builder from copy import deepcopy from os import mkdir, path, unlink from threading import Lock +import re from changedetectionio.model import Watch, App @@ -16,6 +17,11 @@ from changedetectionio.model import Watch, App # https://stackoverflow.com/questions/6190468/how-to-trigger-function-on-value-change class ChangeDetectionStore: lock = Lock() + # For general updates/writes that can wait a few seconds + needs_write = False + + # For when we edit, we should write to disk + needs_write_urgent = False def __init__(self, datastore_path="/datastore", include_default_watches=True, version_tag="0.0.0"): # Should only be active for docker @@ -100,6 +106,9 @@ class ChangeDetectionStore: secret = secrets.token_hex(16) self.__data['settings']['application']['rss_access_token'] = secret + # Bump the update version by running updates + self.run_updates() + self.needs_write = True # Finally start the thread that will manage periodic data saves to JSON @@ -145,6 +154,17 @@ class ChangeDetectionStore: self.needs_write = True + @property + def threshold_seconds(self): + seconds = 0 + mtable = {'seconds': 1, 'minutes': 60, 'hours': 3600, 'days': 86400, 'weeks': 86400 * 7} + minimum_seconds_recheck_time = int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 5)) + for m, n in mtable.items(): + x = self.__data['settings']['requests']['time_between_check'].get(m) + if x: + seconds += x * n + return max(seconds, minimum_seconds_recheck_time) + @property def data(self): has_unviewed = False @@ -339,7 +359,7 @@ class ChangeDetectionStore: def sync_to_json(self): logging.info("Saving JSON..") - + print("Saving JSON..") try: data = deepcopy(self.__data) except RuntimeError as e: @@ -361,6 +381,7 @@ class ChangeDetectionStore: logging.error("Error writing JSON!! (Main JSON file save was skipped) : %s", str(e)) self.needs_write = False + self.needs_write_urgent = False # Thread runner, this helps with thread/write issues when there are many operations that want to update the JSON # by just running periodically in one thread, according to python, dict updates are threadsafe. @@ -371,14 +392,14 @@ class ChangeDetectionStore: print("Shutting down datastore thread") return - if self.needs_write: + if self.needs_write or self.needs_write_urgent: self.sync_to_json() # Once per minute is enough, more and it can cause high CPU usage # better here is to use something like self.app.config.exit.wait(1), but we cant get to 'app' from here - for i in range(30): - time.sleep(2) - if self.stop_thread: + for i in range(120): + time.sleep(0.5) + if self.stop_thread or self.needs_write_urgent: break # Go through the datastore path and remove any snapshots that are not mentioned in the index @@ -398,3 +419,49 @@ class ChangeDetectionStore: if not str(item) in index: print ("Removing",item) unlink(item) + + # Run all updates + # IMPORTANT - Each update could be run even when they have a new install and the schema is correct + # So therefor - each `update_n` should be very careful about checking if it needs to actually run + # Probably we should bump the current update schema version with each tag release version? + def run_updates(self): + import inspect + import shutil + + updates_available = [] + for i, o in inspect.getmembers(self, predicate=inspect.ismethod): + m = re.search(r'update_(\d+)$', i) + if m: + updates_available.append(int(m.group(1))) + updates_available.sort() + + for update_n in updates_available: + if update_n > self.__data['settings']['application']['schema_version']: + print ("Applying update_{}".format((update_n))) + # Wont exist on fresh installs + if os.path.exists(self.json_store_path): + shutil.copyfile(self.json_store_path, self.datastore_path+"/url-watches-before-{}.json".format(update_n)) + + try: + update_method = getattr(self, "update_{}".format(update_n))() + except Exception as e: + print("Error while trying update_{}".format((update_n))) + print(e) + # Don't run any more updates + return + else: + # Bump the version, important + self.__data['settings']['application']['schema_version'] = update_n + + # Convert minutes to seconds on settings and each watch + def update_1(self): + if self.data['settings']['requests'].get('minutes_between_check'): + self.data['settings']['requests']['time_between_check']['minutes'] = self.data['settings']['requests']['minutes_between_check'] + # Remove the default 'hours' that is set from the model + self.data['settings']['requests']['time_between_check']['hours'] = None + + for uuid, watch in self.data['watching'].items(): + if 'minutes_between_check' in watch: + # Only upgrade individual watch time if it was set + if watch.get('minutes_between_check', False): + self.data['watching'][uuid]['time_between_check']['minutes'] = watch['minutes_between_check'] diff --git a/changedetectionio/templates/edit.html b/changedetectionio/templates/edit.html index d06c2208..07af9c4d 100644 --- a/changedetectionio/templates/edit.html +++ b/changedetectionio/templates/edit.html @@ -41,7 +41,7 @@