Skip to content

Commit 27928f3

Browse files
committed
Tweak for partial search
1 parent d58a71c commit 27928f3

File tree

4 files changed

+188
-0
lines changed

4 files changed

+188
-0
lines changed

changedetectionio/api/Search.py

+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
from flask_restful import Resource, abort
2+
from flask import request
3+
from . import auth
4+
5+
class Search(Resource):
6+
def __init__(self, **kwargs):
7+
# datastore is a black box dependency
8+
self.datastore = kwargs['datastore']
9+
10+
@auth.check_token
11+
def get(self):
12+
"""
13+
@api {get} /api/v1/search Search for watches
14+
@apiDescription Search watches by URL or title text
15+
@apiExample {curl} Example usage:
16+
curl "http://localhost:5000/api/v1/search?q=example.com" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
17+
@apiName Search
18+
@apiGroup Watch Management
19+
@apiQuery {String} q Search query to match against watch URLs and titles
20+
@apiQuery {String} [tag] Optional name of tag to limit results
21+
@apiSuccess (200) {Object} JSON Object containing matched watches
22+
"""
23+
query = request.args.get('q', '').strip()
24+
tag_limit = request.args.get('tag', '').strip()
25+
from changedetectionio.strtobool import strtobool
26+
partial = bool(strtobool(request.args.get('partial', '0'))) if 'partial' in request.args else False
27+
28+
# Require a search query
29+
if not query:
30+
abort(400, message="Search query 'q' parameter is required")
31+
32+
# Use the search function from the datastore
33+
matching_uuids = self.datastore.search_watches_for_url(query=query, tag_limit=tag_limit, partial=partial)
34+
35+
# Build the response with watch details
36+
results = {}
37+
for uuid in matching_uuids:
38+
watch = self.datastore.data['watching'].get(uuid)
39+
results[uuid] = {
40+
'last_changed': watch.last_changed,
41+
'last_checked': watch['last_checked'],
42+
'last_error': watch['last_error'],
43+
'title': watch['title'],
44+
'url': watch['url'],
45+
'viewed': watch.viewed
46+
}
47+
48+
return results, 200

changedetectionio/flask_app.py

+4
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from changedetectionio import __version__
3535
from changedetectionio import queuedWatchMetaData
3636
from changedetectionio.api import Watch, WatchHistory, WatchSingleHistory, CreateWatch, Import, SystemInfo, Tag, Tags
37+
from changedetectionio.api.Search import Search
3738
from .time_handler import is_within_schedule
3839

3940
datastore = None
@@ -275,6 +276,9 @@ def check_authentication():
275276

276277
watch_api.add_resource(Tag, '/api/v1/tag', '/api/v1/tag/<string:uuid>',
277278
resource_class_kwargs={'datastore': datastore})
279+
280+
watch_api.add_resource(Search, '/api/v1/search',
281+
resource_class_kwargs={'datastore': datastore})
278282

279283

280284

changedetectionio/store.py

+35
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,41 @@ def any_watches_have_processor_by_name(self, processor_name):
631631
if watch.get('processor') == processor_name:
632632
return True
633633
return False
634+
635+
def search_watches_for_url(self, query, tag_limit=None, partial=False):
636+
"""Search watches by URL, title, or error messages
637+
638+
Args:
639+
query (str): Search term to match against watch URLs, titles, and error messages
640+
tag_limit (str, optional): Optional tag name to limit search results
641+
partial: (bool, optional): sub-string matching
642+
643+
Returns:
644+
list: List of UUIDs of watches that match the search criteria
645+
"""
646+
matching_uuids = []
647+
query = query.lower().strip()
648+
tag = self.tag_exists_by_name(tag_limit) if tag_limit else False
649+
650+
for uuid, watch in self.data['watching'].items():
651+
# Filter by tag if requested
652+
if tag_limit:
653+
if not tag.get('uuid') in watch.get('tags', []):
654+
continue
655+
656+
# Search in URL, title, or error messages
657+
if partial:
658+
if ((watch.get('title') and query in watch.get('title').lower()) or
659+
query in watch.get('url', '').lower() or
660+
(watch.get('last_error') and query in watch.get('last_error').lower())):
661+
matching_uuids.append(uuid)
662+
else:
663+
if ((watch.get('title') and query == watch.get('title').lower()) or
664+
query == watch.get('url', '').lower() or
665+
(watch.get('last_error') and query == watch.get('last_error').lower())):
666+
matching_uuids.append(uuid)
667+
668+
return matching_uuids
634669

635670
def get_unique_notification_tokens_available(self):
636671
# Ask each type of watch if they have any extra notification token to add to the validation
+101
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
from copy import copy
2+
3+
from flask import url_for
4+
import json
5+
import time
6+
from .util import live_server_setup, wait_for_all_checks
7+
8+
9+
def test_api_search(client, live_server):
10+
live_server_setup(live_server)
11+
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
12+
13+
watch_data = {}
14+
# Add some test watches
15+
urls = [
16+
'https://example.com/page1',
17+
'https://example.org/testing',
18+
'https://test-site.com/example'
19+
]
20+
21+
# Import the test URLs
22+
res = client.post(
23+
url_for("imports.import_page"),
24+
data={"urls": "\r\n".join(urls)},
25+
follow_redirects=True
26+
)
27+
assert b"3 Imported" in res.data
28+
wait_for_all_checks(client)
29+
30+
# Get a listing, it will be the first one
31+
watches_response = client.get(
32+
url_for("createwatch"),
33+
headers={'x-api-key': api_key}
34+
)
35+
36+
37+
# Add a title to one watch for title search testing
38+
for uuid, watch in watches_response.json.items():
39+
40+
watch_data = client.get(url_for("watch", uuid=uuid),
41+
follow_redirects=True,
42+
headers={'x-api-key': api_key}
43+
)
44+
45+
if urls[0] == watch_data.json['url']:
46+
# HTTP PUT ( UPDATE an existing watch )
47+
client.put(
48+
url_for("watch", uuid=uuid),
49+
headers={'x-api-key': api_key, 'content-type': 'application/json'},
50+
data=json.dumps({'title': 'Example Title Test'}),
51+
)
52+
53+
# Test search by URL
54+
res = client.get(url_for("search")+"?q=https://example.com/page1", headers={'x-api-key': api_key, 'content-type': 'application/json'})
55+
assert len(res.json) == 1
56+
assert list(res.json.values())[0]['url'] == urls[0]
57+
58+
# Test search by URL - partial should NOT match without ?partial=true flag
59+
res = client.get(url_for("search")+"?q=https://example", headers={'x-api-key': api_key, 'content-type': 'application/json'})
60+
assert len(res.json) == 0
61+
62+
63+
# Test search by title
64+
res = client.get(url_for("search")+"?q=Example Title Test", headers={'x-api-key': api_key, 'content-type': 'application/json'})
65+
assert len(res.json) == 1
66+
assert list(res.json.values())[0]['url'] == urls[0]
67+
assert list(res.json.values())[0]['title'] == 'Example Title Test'
68+
69+
# Test search that should return multiple results (partial = true)
70+
res = client.get(url_for("search")+"?q=https://example&partial=true", headers={'x-api-key': api_key, 'content-type': 'application/json'})
71+
assert len(res.json) == 2
72+
73+
# Test empty search
74+
res = client.get(url_for("search")+"?q=", headers={'x-api-key': api_key, 'content-type': 'application/json'})
75+
assert res.status_code == 400
76+
77+
# Add a tag to test search with tag filter
78+
tag_name = 'test-tag'
79+
res = client.post(
80+
url_for("tag"),
81+
data=json.dumps({"title": tag_name}),
82+
headers={'content-type': 'application/json', 'x-api-key': api_key}
83+
)
84+
assert res.status_code == 201
85+
tag_uuid = res.json['uuid']
86+
87+
# Add the tag to one watch
88+
for uuid, watch in watches_response.json.items():
89+
if urls[2] == watch['url']:
90+
client.put(
91+
url_for("watch", uuid=uuid),
92+
headers={'x-api-key': api_key, 'content-type': 'application/json'},
93+
data=json.dumps({'tags': [tag_uuid]}),
94+
)
95+
96+
97+
# Test search with tag filter and q
98+
res = client.get(url_for("search") + f"?q={urls[2]}&tag={tag_name}", headers={'x-api-key': api_key, 'content-type': 'application/json'})
99+
assert len(res.json) == 1
100+
assert list(res.json.values())[0]['url'] == urls[2]
101+

0 commit comments

Comments
 (0)