Skip to content

Commit fafea1b

Browse files
authored
Updates/migration - Re-run tag update, re-save to cleanup changedetection.json, code refactor (#3898)
1 parent 93630e1 commit fafea1b

6 files changed

Lines changed: 72 additions & 42 deletions

File tree

changedetectionio/api/Tags.py

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -97,17 +97,6 @@ def delete(self, uuid):
9797
# Delete the tag, and any tag reference
9898
del self.datastore.data['settings']['application']['tags'][uuid]
9999

100-
# Delete tag.json file if it exists
101-
import os
102-
tag_dir = os.path.join(self.datastore.datastore_path, uuid)
103-
tag_json = os.path.join(tag_dir, "tag.json")
104-
if os.path.exists(tag_json):
105-
try:
106-
os.unlink(tag_json)
107-
logger.info(f"Deleted tag.json for tag {uuid}")
108-
except Exception as e:
109-
logger.error(f"Failed to delete tag.json for tag {uuid}: {e}")
110-
111100
# Remove tag from all watches
112101
for watch_uuid, watch in self.datastore.data['watching'].items():
113102
if watch.get('tags') and uuid in watch['tags']:

changedetectionio/blueprint/tags/__init__.py

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -70,17 +70,6 @@ def delete(uuid):
7070
if datastore.data['settings']['application']['tags'].get(uuid):
7171
del datastore.data['settings']['application']['tags'][uuid]
7272

73-
# Delete tag.json file if it exists
74-
import os
75-
tag_dir = os.path.join(datastore.datastore_path, uuid)
76-
tag_json = os.path.join(tag_dir, "tag.json")
77-
if os.path.exists(tag_json):
78-
try:
79-
os.unlink(tag_json)
80-
logger.info(f"Deleted tag.json for tag {uuid}")
81-
except Exception as e:
82-
logger.error(f"Failed to delete tag.json for tag {uuid}: {e}")
83-
8473
# Remove tag from all watches in background thread to avoid blocking
8574
def remove_tag_background(tag_uuid):
8675
"""Background thread to remove tag from watches - discarded after completion."""
@@ -127,19 +116,11 @@ def unlink_tag_background(tag_uuid):
127116
@tags_blueprint.route("/delete_all", methods=['GET'])
128117
@login_optionally_required
129118
def delete_all():
130-
# Delete all tag.json files
131-
import os
119+
132120
for tag_uuid in list(datastore.data['settings']['application']['tags'].keys()):
133-
tag_dir = os.path.join(datastore.datastore_path, tag_uuid)
134-
tag_json = os.path.join(tag_dir, "tag.json")
135-
if os.path.exists(tag_json):
136-
try:
137-
os.unlink(tag_json)
138-
except Exception as e:
139-
logger.error(f"Failed to delete tag.json for tag {tag_uuid}: {e}")
121+
# TagsDict 'del' handler will remove the dir
122+
del datastore.data['settings']['application']['tags'][tag_uuid]
140123

141-
# Clear all tags from settings immediately
142-
datastore.data['settings']['application']['tags'] = {}
143124

144125
# Clear tags from all watches in background thread to avoid blocking
145126
def clear_all_tags_background():
@@ -255,7 +236,4 @@ def form_tag_edit_submit(uuid):
255236
return redirect(url_for('tags.tags_overview_page'))
256237

257238

258-
@tags_blueprint.route("/delete/<string:uuid>", methods=['GET'])
259-
def form_tag_delete(uuid):
260-
return redirect(url_for('tags.tags_overview_page'))
261239
return tags_blueprint

changedetectionio/model/App.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from copy import deepcopy
33

44
from changedetectionio.blueprint.rss import RSS_FORMAT_TYPES, RSS_CONTENT_FORMAT_DEFAULT
5+
from changedetectionio.model.Tags import TagsDict
56

67
from changedetectionio.notification import (
78
default_notification_body,
@@ -68,7 +69,7 @@ class model(dict):
6869
'schema_version' : 0,
6970
'shared_diff_access': False,
7071
'strip_ignored_lines': False,
71-
'tags': {}, #@todo use Tag.model initialisers
72+
'tags': None, # Initialized in __init__ with real datastore_path
7273
'webdriver_delay': None , # Extra delay in seconds before extracting text
7374
'ui': {
7475
'use_page_title_in_list': True,
@@ -80,10 +81,16 @@ class model(dict):
8081
}
8182
}
8283

83-
def __init__(self, *arg, **kw):
84+
def __init__(self, *arg, datastore_path=None, **kw):
8485
super(model, self).__init__(*arg, **kw)
86+
# Capture any tags data passed in before base_config overwrites the structure
87+
existing_tags = self.get('settings', {}).get('application', {}).get('tags') or {}
8588
# CRITICAL: deepcopy to avoid sharing mutable objects between instances
8689
self.update(deepcopy(self.base_config))
90+
# TagsDict requires the real datastore_path at runtime (cannot be set at class-definition time)
91+
if datastore_path is None:
92+
raise ValueError("App.model() requires 'datastore_path' keyword argument")
93+
self['settings']['application']['tags'] = TagsDict(existing_tags, datastore_path=datastore_path)
8794

8895

8996
def parse_headers_from_text_file(filepath):

changedetectionio/model/Tags.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import os
2+
import shutil
3+
from pathlib import Path
4+
from loguru import logger
5+
6+
_SENTINEL = object()
7+
8+
9+
class TagsDict(dict):
10+
"""Dict subclass that removes the corresponding tag.json file when a tag is deleted."""
11+
12+
def __init__(self, *args, datastore_path: str | os.PathLike, **kwargs) -> None:
13+
self._datastore_path = Path(datastore_path)
14+
super().__init__(*args, **kwargs)
15+
16+
def __delitem__(self, key: str) -> None:
17+
super().__delitem__(key)
18+
tag_dir = self._datastore_path / key
19+
tag_json_file = tag_dir / "tag.json"
20+
if not os.path.exists(tag_json_file):
21+
logger.critical(f"Aborting deletion of directory '{tag_dir}' because '{tag_json_file}' does not exist.")
22+
return
23+
try:
24+
shutil.rmtree(tag_dir)
25+
logger.info(f"Deleted tag directory for tag {key!r}")
26+
except FileNotFoundError:
27+
pass
28+
except OSError as e:
29+
logger.error(f"Failed to delete tag directory for tag {key!r}: {e}")
30+
31+
def pop(self, key: str, default=_SENTINEL):
32+
"""Remove and return tag, deleting its tag.json file. Raises KeyError if missing and no default given."""
33+
if key in self:
34+
value = self[key]
35+
del self[key]
36+
return value
37+
if default is _SENTINEL:
38+
raise KeyError(key)
39+
return default

changedetectionio/store/__init__.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
from loguru import logger
2323
from blinker import signal
2424

25+
from ..model.Tags import TagsDict
26+
2527
# Try to import orjson for faster JSON serialization
2628
try:
2729
import orjson
@@ -121,6 +123,11 @@ def _apply_settings(self, settings_data):
121123
if 'application' in settings_data['settings']:
122124
self.__data['settings']['application'].update(settings_data['settings']['application'])
123125

126+
# Use our Tags dict with cleanup helpers etc
127+
# @todo Same for Watches
128+
existing_tags = settings_data.get('settings', {}).get('application', {}).get('tags') or {}
129+
self.__data['settings']['application']['tags'] = TagsDict(existing_tags, datastore_path=self.datastore_path)
130+
124131
# More or less for the old format which had this data in the one url-watches.json
125132
# cant hurt to leave it here,
126133
if 'watching' in settings_data:
@@ -196,7 +203,7 @@ def reload_state(self, datastore_path, include_default_watches, version_tag):
196203
self.datastore_path = datastore_path
197204

198205
# Initialize data structure
199-
self.__data = App.model()
206+
self.__data = App.model(datastore_path=datastore_path)
200207
self.json_store_path = os.path.join(self.datastore_path, "changedetection.json")
201208

202209
# Base definition for all watchers (deepcopy part of #569)
@@ -355,6 +362,9 @@ def _build_settings_data(self):
355362
# Deep copy settings to avoid modifying the original
356363
settings_copy = copy.deepcopy(self.__data['settings'])
357364

365+
# Is saved as {uuid}/tag.json
366+
settings_copy['application']['tags'] = {}
367+
358368
return {
359369
'note': 'Settings file - watches are in {uuid}/watch.json, tags are in {uuid}/tag.json',
360370
'app_guid': self.__data.get('app_guid'),

changedetectionio/store/updates.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -669,7 +669,9 @@ def migrate_legacy_db_format(self):
669669
def update_26(self):
670670
self.migrate_legacy_db_format()
671671

672-
def update_28(self):
672+
# Re-run tag to JSON migration
673+
def update_29(self):
674+
673675
"""
674676
Migrate tags to individual tag.json files.
675677
@@ -682,8 +684,6 @@ def update_28(self):
682684
- Enables independent tag versioning/backup
683685
- Maintains backwards compatibility (tags stay in settings too)
684686
"""
685-
# Force save as tag.json (not watch.json) even if object is corrupted
686-
687687
logger.critical("=" * 80)
688688
logger.critical("Running migration: Individual tag persistence (update_28)")
689689
logger.critical("Creating individual tag.json files")
@@ -702,6 +702,9 @@ def update_28(self):
702702
failed_count = 0
703703

704704
for uuid, tag_data in tags.items():
705+
if os.path.isfile(os.path.join(self.datastore_path, uuid, "tag.json")):
706+
logger.debug(f"Tag {uuid} tag.json exists, skipping")
707+
continue
705708
try:
706709
tag_data.commit()
707710
saved_count += 1
@@ -723,3 +726,7 @@ def update_28(self):
723726
logger.info("Future tag edits will update both locations (dual storage)")
724727
logger.critical("=" * 80)
725728

729+
# write it to disk, it will be saved without ['tags'] in the JSON db because we find it from disk glob
730+
# (left this out by accident in previous update, added tags={} in the changedetection.json save_to_disk)
731+
self._save_settings()
732+

0 commit comments

Comments
 (0)