Line data Source code
1 : #include "SearchNewsOperation.h"
2 : #include "../models/NewsList.h"
3 : #include "../utilities/FangLogging.h"
4 :
5 : #include <QRegularExpression>
6 :
7 8 : SearchNewsOperation::SearchNewsOperation(OperationManager *parent, LisvelFeedItem *feedItem,
8 : LoadMode mode, const QString& searchQuery,
9 8 : Scope scope, qint64 scopeId, int loadLimit) :
10 : LisvelLoadNewsOperation(parent, feedItem, mode, loadLimit, false), // prependOnInit = false
11 8 : searchQuery(searchQuery),
12 8 : scope(scope),
13 8 : scopeId(scopeId)
14 : {
15 8 : }
16 :
17 22 : QString SearchNewsOperation::sanitizeSearchQuery(const QString& query) const
18 : {
19 : // FTS5 query syntax special characters:
20 : // - Double quotes start phrase queries
21 : // - Asterisk is a prefix operator
22 : // - Parentheses group expressions
23 : // - Plus/minus are boolean operators
24 : // - Caret for column filters
25 : // - Colon for column specification
26 : // - AND, OR, NOT are boolean operators (case-insensitive)
27 : //
28 : // For user-entered queries, we wrap each word in double quotes to treat them as literals.
29 :
30 22 : QString sanitized = query.trimmed();
31 22 : if (sanitized.isEmpty()) {
32 1 : return QString();
33 : }
34 :
35 : // Split on whitespace and quote each term.
36 21 : QStringList terms = sanitized.split(QRegularExpression("\\s+"), Qt::SkipEmptyParts);
37 21 : QStringList quotedTerms;
38 :
39 42 : for (const QString& term : terms) {
40 : // Escape double quotes.
41 21 : QString escaped = term;
42 21 : escaped.replace("\"", "\"\""); // FTS5 escapes " by doubling it.
43 :
44 : // Wrap in double quotes to treat as literal
45 21 : quotedTerms.append("\"" + escaped + "\"");
46 21 : }
47 :
48 : // Join with spaces to AND the terms together.
49 21 : return quotedTerms.join(" ");
50 22 : }
51 :
52 7 : qint64 SearchNewsOperation::getFirstNewsID()
53 : {
54 7 : QString sanitized = sanitizeSearchQuery(searchQuery);
55 7 : if (sanitized.isEmpty()) {
56 0 : return -1;
57 : }
58 :
59 : const QString queryString =
60 : "SELECT N.id FROM NewsItemTable N "
61 : "JOIN NewsItemFTS F ON N.id = F.rowid "
62 : "WHERE NewsItemFTS MATCH :search_query "
63 : "ORDER BY bm25(NewsItemFTS), N.timestamp DESC, N.id DESC "
64 7 : "LIMIT 1";
65 :
66 7 : QSqlQuery query(db());
67 7 : query.prepare(queryString);
68 7 : query.bindValue(":search_query", sanitized);
69 :
70 7 : if (!query.exec() || !query.next()) {
71 1 : return -1;
72 : }
73 :
74 6 : return query.value("id").toLongLong();
75 7 : }
76 :
77 7 : QString SearchNewsOperation::appendNewQueryString()
78 : {
79 : // Note: bm25() returns negative values where more negative = more relevant,
80 : // so ORDER BY bm25() puts most relevant first.
81 :
82 : QString baseQuery =
83 : "SELECT "
84 : " N.id, N.feed_id, N.guid, N.author, N.timestamp, N.url, N.pinned, "
85 : " highlight(NewsItemFTS, 0, '<mark>', '</mark>') AS title, "
86 : " N.summary, "
87 : " N.content, "
88 : " N.media_image_url "
89 : "FROM NewsItemTable N "
90 7 : "JOIN NewsItemFTS F ON N.id = F.rowid ";
91 :
92 : // Add scope-specific JOINs and WHERE clauses
93 7 : QString scopeFilter;
94 7 : switch (scope) {
95 1 : case Scope::Feed:
96 : // Search within a specific feed
97 1 : scopeFilter = "AND N.feed_id = :scope_id ";
98 1 : break;
99 :
100 0 : case Scope::Folder:
101 : // Search within all feeds in a folder - need to join FeedItemTable
102 0 : baseQuery += "JOIN FeedItemTable FI ON N.feed_id = FI.id ";
103 0 : scopeFilter = "AND FI.parent_folder = :scope_id ";
104 0 : break;
105 :
106 6 : case Scope::Global:
107 : default:
108 : // No additional filter for global search
109 6 : break;
110 : }
111 :
112 14 : return baseQuery +
113 : "WHERE NewsItemFTS MATCH :search_query "
114 14 : + scopeFilter +
115 28 : "AND N.id NOT IN (" + getLoadedIDString() + ") "
116 : "ORDER BY bm25(NewsItemFTS), N.timestamp DESC, N.id DESC "
117 14 : "LIMIT :load_limit";
118 7 : }
119 :
120 0 : QString SearchNewsOperation::prependNewQueryString()
121 : {
122 : // Prepend not supported for search results. This LISVEL window should handle this for us.
123 0 : return QString();
124 : }
125 :
126 7 : void SearchNewsOperation::bindQueryParameters(QSqlQuery& query)
127 : {
128 7 : QString sanitized = sanitizeSearchQuery(searchQuery);
129 7 : query.bindValue(":search_query", sanitized);
130 :
131 7 : if (scope == Scope::Feed || scope == Scope::Folder) {
132 1 : query.bindValue(":scope_id", scopeId);
133 : }
134 7 : }
135 :
136 7 : void SearchNewsOperation::queryToNewsListWithHighlights(QSqlQuery& query, QList<NewsItem*>* list)
137 : {
138 15 : while (query.next()) {
139 8 : QString title = query.value("title").toString();
140 8 : QString summary = query.value("summary").toString();
141 8 : QString content = query.value("content").toString();
142 :
143 : // Debug our highlighting method.
144 16 : qCDebug(logOperation) << "SearchNewsOperation: Highlighted title:" << title.left(100);
145 16 : qCDebug(logOperation) << "SearchNewsOperation: Title contains <mark>:" << title.contains("<mark>");
146 :
147 : NewsItem* newsItem = new NewsItem(
148 : feedItem,
149 8 : query.value("id").toLongLong(),
150 16 : query.value("feed_id").toLongLong(),
151 : title, // Already highlighted
152 16 : query.value("author").toString(), // Author not highlighted (column 3 in FTS)
153 : summary, // Already highlighted
154 : content, // Already highlighted
155 16 : QDateTime::fromMSecsSinceEpoch(query.value("timestamp").toLongLong()),
156 16 : query.value("url").toString(),
157 8 : query.value("pinned").toInt() != 0,
158 16 : query.value("media_image_url").toString()
159 56 : );
160 :
161 8 : list->append(newsItem);
162 8 : }
163 7 : }
164 :
165 8 : void SearchNewsOperation::execute()
166 : {
167 : // Validate search query.
168 8 : QString sanitized = sanitizeSearchQuery(searchQuery);
169 8 : if (sanitized.isEmpty()) {
170 2 : qCDebug(logOperation) << "SearchNewsOperation: Empty or invalid search query";
171 1 : return;
172 : }
173 :
174 : // Ensure lists are empty.
175 7 : if (!listAppend.isEmpty() || !listPrepend.isEmpty()) {
176 0 : qCWarning(logOperation) << "SearchNewsOperation: Lists not empty at start!";
177 0 : listAppend.clear();
178 0 : listPrepend.clear();
179 : }
180 :
181 : // For search, we only support Initial and Append modes.
182 : // Prepend doesn't make sense for relevance-ordered results - just return empty.
183 7 : LoadMode currentMode = getMode();
184 7 : if (currentMode == Prepend) {
185 0 : qCDebug(logOperation) << "SearchNewsOperation: Prepend mode not supported, returning empty";
186 0 : return;
187 : }
188 :
189 : // For Initial load, ensure the list is empty
190 7 : if (currentMode == Initial) {
191 7 : if (feedItem->getNewsList() != nullptr && !feedItem->getNewsList()->isEmpty()) {
192 0 : qCWarning(logOperation) << "SearchNewsOperation: Initial load but list not empty, clearing";
193 0 : feedItem->getNewsList()->clear();
194 : }
195 : }
196 :
197 : // Execute the search query
198 7 : QSqlQuery query(db());
199 7 : query.prepare(appendNewQueryString());
200 7 : query.bindValue(":load_limit", getLoadLimit());
201 7 : bindQueryParameters(query);
202 :
203 7 : if (!query.exec()) {
204 0 : qCWarning(logOperation) << "SearchNewsOperation: Query failed:" << query.lastError();
205 0 : reportError("Search query failed: " + query.lastError().text());
206 0 : return;
207 : }
208 :
209 : // Extract results with highlighted fields
210 7 : queryToNewsListWithHighlights(query, &listAppend);
211 :
212 : // Add items to the feed's news list
213 7 : if (!listAppend.isEmpty()) {
214 14 : for (NewsItem* newsItem : std::as_const(listAppend)) {
215 8 : if (!feedItem->getNewsList()->containsID(newsItem->getDbID())) {
216 8 : feedItem->getNewsList()->append(newsItem);
217 : }
218 : }
219 : }
220 :
221 : // Set the first news ID for bookmark display logic.
222 : // TODO: How does pinned news handle bookmarks? Maybe we could go that route.
223 7 : feedItem->setFirstNewsID(getFirstNewsID());
224 8 : }
|