LCOV - code coverage report
Current view: top level - src/operations - SearchNewsOperation.cpp (source / functions) Coverage Total Hit
Test: coverage.info.cleaned Lines: 84.1 % 107 90
Test Date: 2026-03-23 10:19:47 Functions: 87.5 % 8 7

            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 : }
        

Generated by: LCOV version 2.0-1