From 850864dc58e0258cb364783052595e468b26539d Mon Sep 17 00:00:00 2001 From: yzinchuk Date: Fri, 3 Jul 2026 04:19:04 -0400 Subject: [PATCH] add thumbnail disk cache, sort order, and date range filter - Thumbnail disk cache: scaled PNGs persisted under ~/.cache/orc-gallery/thumbs/ keyed by MD5(path+filesize); avoids re-decode/re-scale on gallery reload - SortOrder enum (DateNewest/Oldest, NameAsc/Desc) threaded through DatabaseManager, ImageGallery, and MainWindow; SQL ORDER BY generated by sortClause() helper - Date range filter: QDateEdit pair in filter toolbar with special-value "Any" sentinel; dateWhereFragment() omits WHERE clause when dates are invalid (no-filter); QSignalBlocker used in handleClearDates for atomic reset without double-refresh - Cache keys updated to encode sort+date+text via makeSearchKey() - plan.md updated with new features, corrected API signatures, and GUI layout diagram Co-Authored-By: Claude Sonnet 4.6 --- plan.md | 31 +++- src/databasemanager.cpp | 343 +++++++++++++++++++--------------------- src/databasemanager.h | 65 ++++---- src/imagegallery.cpp | 88 ++++++++--- src/imagegallery.h | 17 +- src/mainwindow.cpp | 119 ++++++++++++-- src/mainwindow.h | 11 ++ 7 files changed, 406 insertions(+), 268 deletions(-) diff --git a/plan.md b/plan.md index 7760744..5c3a285 100644 --- a/plan.md +++ b/plan.md @@ -159,13 +159,17 @@ Central controller. Inherits `QMainWindow`. Responsibilities: - Menu bar: File → Open, Settings, Update Database, Quit -- Search bar (`QLineEdit`) with a 500 ms typing-inactivity timer (`m_typingTimer`) — search only fires after the user stops typing +- Search bar (`QLineEdit`) with a 150 ms typing-inactivity timer (`m_typingTimer`) — search only fires after the user stops typing +- Sort/filter toolbar row: sort combo (`QComboBox`) + From/To date pickers (`QDateEdit`) + Clear button - Animated "Searching…" status bar indicator (`m_searchingAnimationTimer`) - Instantiates and owns `DatabaseManager` and `ImageGallery` - Reads/writes settings via `QSettings` (INI at `~/.config/OrcGallery/settings.ini`) - On search complete: updates status bar with result count - On clear search: reloads all images (first page) +Sort options: Newest First (default), Oldest First, Name A-Z, Name Z-A. +Date filter: "Any" (special value = minimum date, treated as no-filter) to any specific date. `QSignalBlocker` used in `handleClearDates` to reset both date edits atomically without double-refresh. + Settings keys: - `databasePath` — path to `.db` file - `screenshotsDir` — directory scanned by OCR script @@ -198,17 +202,21 @@ Key public API: |---|---| | `initialize(path)` | Opens/validates an existing database | | `createDatabase(path)` | Creates schema from scratch | -| `getAllImages(offset, limit)` | Paginated full-path list, no search filter | -| `getImageCount()` | Total row count (cached) | -| `searchImages(text, offset, limit)` | FTS5 or LIKE search, runs in background thread via `QtConcurrent::run` | +| `getAllImages(offset, limit, sort, from, to)` | Paginated list with optional sort order and date range | +| `getImageCount()` | Total row count (unfiltered) | +| `searchImages(text, offset, limit, sort, from, to)` | FTS5 or LIKE search, runs async; `SortOrder` enum + `QDate` range | | `cancelSearch()` | Sets cancellation flag; background thread checks it | | `initializeFTS()` | Creates virtual table + triggers if FTS5 available | | `prepareFTSQuery(text)` | Converts user text to FTS5 query syntax | +`SortOrder` enum: `DateNewest` (default), `DateOldest`, `NameAsc`, `NameDesc`. + +Static helpers (internal): `sortClause(sort)` → SQL ORDER BY fragment; `dateWhereFragment(from, to)` → SQL WHERE fragment (empty string when dates are invalid/unset); `bindDateParams(query, from, to)` → binds `:from`/`:to`; `makeSearchKey(text, sort, from, to)` → composite string cache key. + Caching: -- `m_searchCache`: `QMap` keyed by query string, 5-minute TTL -- `m_allImagesCache`: paginated all-images results -- `m_imageCountCache`: row count integer +- `m_searchCache`: `QMap, SearchCacheItem>>` — outer key is `makeSearchKey(...)` encoding text+sort+date, inner key is `(offset, limit)`, 5-minute TTL +- `m_allImagesCache`: `QMap>` — key encodes sort+date+offset+limit +- `m_cachedImageCount`: row count integer - Cache eviction when `m_searchCache` exceeds 50 entries - All cache access under `m_cacheMutex` @@ -248,6 +256,9 @@ Behavior: ├───────────────────────────────────────────────────────────┤ │ [ Search OCR text... ] [×] │ ← Search bar ├───────────────────────────────────────────────────────────┤ +│ Sort: [Newest First v] From: [ Any ] To: [ Any ] │ ← Filter bar +│ [Clear Dates] │ +├───────────────────────────────────────────────────────────┤ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ │ │ │ │ │ │ │ │ ← Thumbnails @@ -352,6 +363,7 @@ All three keys have fallbacks defined in `MainWindow::loadSettings()` and `Setti | SQLite I/O | WAL journal + 20 MB page cache + memory-mapped I/O via PRAGMA on init | | Thumbnail load stall | `QImage` loaded/scaled on `QThreadPool`, marshalled to main thread via `invokeMethod` | | Overlay widget cost | Filename drawn in `ImageThumbnail::paintEvent` — no child QFrame/QLabel/QHBoxLayout | +| Disk thumbnail cache | Scaled PNGs cached under `~/.cache/orc-gallery/thumbs/` keyed by MD5(path+size); avoids repeated disk I/O and scaling on gallery reload | --- @@ -384,6 +396,9 @@ XDG paths used by the application: - `ImageGallery` does not use a `QAbstractItemModel`; it builds `ImageThumbnail` widgets directly and manages them in a flat list (`m_thumbnails`). - Database connections must not be shared across threads — each `QtConcurrent` task opens its own named connection and closes it on exit. - `cancelSearch()` is non-blocking: it sets `m_searchCancelled` and returns immediately. The background thread checks the flag at multiple points and exits early. The destructor sets the flag then calls `waitForFinished()` for clean shutdown. -- The watcher's `finished` signal is not used for emitting results (was causing double-emit). Results are emitted directly from `performSearchInBackground` once and only if `m_currentSearchText == searchText`. +- The watcher's `finished` signal is not used for emitting results (was causing double-emit). Results are emitted directly from `performSearchInBackground` once and only if `m_currentSearchKey == makeSearchKey(searchText, sort, from, to)`. - `ImageThumbnail::paintEvent` draws the filename overlay; no child widgets needed. The `m_displayName` field is pre-truncated in the constructor. - Async thumbnail loading: `loadThumbnailAsync` posts to `QThreadPool::globalInstance()`. `QImage` is loaded and `.scaled()` off the main thread; `QPixmap::fromImage()` is called inside a `Qt::QueuedConnection` invokeMethod lambda. A `QPointer` guard prevents use-after-free if the gallery clears before the load completes. +- Thumbnail disk cache stored at `QStandardPaths::CacheLocation + "/thumbs/"`. Cache key = `QCryptographicHash::Md5(path + "|" + fileSize)` as hex string + `.png`. Avoids reloading/rescaling images that haven't changed. +- Sort/date state is held in `ImageGallery` (`m_sortOrder`, `m_fromDate`, `m_toDate`). `MainWindow` pushes state to the gallery via `setSortOrder()`/`setDateFilter()` before triggering any refresh. +- Date filter uses `QDateEdit::setSpecialValueText(" Any ")` with `minimumDate()` as the sentinel; the gallery receives `QDate()` (invalid) when the date edit is at minimum, which `dateWhereFragment()` treats as "no filter". diff --git a/src/databasemanager.cpp b/src/databasemanager.cpp index 4ba8bf0..cc27e1b 100644 --- a/src/databasemanager.cpp +++ b/src/databasemanager.cpp @@ -6,12 +6,54 @@ #include #include +// --- helpers ---------------------------------------------------------------- + +static QString sortClause(DatabaseManager::SortOrder sort) +{ + switch (sort) { + case DatabaseManager::SortOrder::DateNewest: return "ORDER BY created_date DESC"; + case DatabaseManager::SortOrder::DateOldest: return "ORDER BY created_date ASC"; + case DatabaseManager::SortOrder::NameAsc: return "ORDER BY filename ASC COLLATE NOCASE"; + case DatabaseManager::SortOrder::NameDesc: return "ORDER BY filename DESC COLLATE NOCASE"; + } + return "ORDER BY created_date DESC"; +} + +// Returns the extra WHERE fragment and binds params to query. +// Caller must append this string after their existing WHERE clause. +static QString dateWhereFragment(const QDate &from, const QDate &to) +{ + QString fragment; + if (from.isValid()) + fragment += " AND created_date >= :from_date"; + if (to.isValid()) + fragment += " AND created_date <= :to_date"; + return fragment; +} + +static void bindDateParams(QSqlQuery &q, const QDate &from, const QDate &to) +{ + if (from.isValid()) + q.bindValue(":from_date", from.toString("yyyy-MM-dd") + "T00:00:00"); + if (to.isValid()) + q.bindValue(":to_date", to.toString("yyyy-MM-dd") + "T23:59:59.999999"); +} + +static QString makeSearchKey(const QString &text, DatabaseManager::SortOrder sort, + const QDate &from, const QDate &to) +{ + return text + "|" + QString::number(static_cast(sort)) + "|" + + from.toString(Qt::ISODate) + "|" + to.toString(Qt::ISODate); +} + +// ---------------------------------------------------------------------------- + DatabaseManager::DatabaseManager(QObject *parent) : QObject(parent) , m_initialized(false) , m_ftsEnabled(false) , m_searchCancelled(false) - , m_cachedImageCount(-1) // Initialize to invalid value + , m_cachedImageCount(-1) , m_currentOffset(0) , m_currentLimit(0) { @@ -31,10 +73,10 @@ DatabaseManager::DatabaseManager(QObject *parent) DatabaseManager::~DatabaseManager() { - // Signal cancellation and wait for the background thread to drain cleanly. { QMutexLocker locker(&m_searchMutex); m_searchCancelled = true; + m_currentSearchKey.clear(); } if (m_searchFuture.isRunning()) m_searchFuture.waitForFinished(); @@ -183,76 +225,70 @@ bool DatabaseManager::initialize(const QString &dbPath) return true; } -QList DatabaseManager::getAllImages(int offset, int limit) +QList DatabaseManager::getAllImages(int offset, int limit, + SortOrder sort, + const QDate &from, + const QDate &to) { QList images; - - if (!m_initialized) { - qDebug() << "Database not initialized."; - return images; - } - - // Check cache first - QPair cacheKey(offset, limit); - QMutexLocker cacheLocker(&m_cacheMutex); - if (m_allImagesCache.contains(cacheKey)) { - // Use cached results if available and not expired - if (m_lastCacheUpdate.secsTo(QDateTime::currentDateTime()) < CACHE_LIFETIME_SECS) { + + if (!m_initialized) return images; + + // Cache key encodes all query parameters + QString cacheKey = makeSearchKey("", sort, from, to) + + "|" + QString::number(offset) + + "|" + QString::number(limit); + { + QMutexLocker cacheLocker(&m_cacheMutex); + if (m_allImagesCache.contains(cacheKey) && + m_lastCacheUpdate.secsTo(QDateTime::currentDateTime()) < CACHE_LIFETIME_SECS) { return m_allImagesCache[cacheKey]; } } - cacheLocker.unlock(); - - // Verify database is still connected + if (!m_db.isOpen() && !m_db.open()) { - qDebug() << "Database connection lost and cannot be reopened:" << m_db.lastError().text(); m_initialized = false; return images; } - - // Start transaction to speed up query - m_db.transaction(); + m_db.transaction(); QSqlQuery query; + + QString dateExtra = dateWhereFragment(from, to); + QString whereClause = dateExtra.isEmpty() ? "" : ("WHERE 1=1" + dateExtra); + + QString sql = "SELECT id, full_path, ocr_text FROM ocr_results " + + whereClause + " " + sortClause(sort); + if (limit > 0) + sql += " LIMIT :limit OFFSET :offset"; + + query.prepare(sql); + bindDateParams(query, from, to); if (limit > 0) { - // Use pagination - query.prepare("SELECT id, full_path, ocr_text FROM ocr_results ORDER BY id LIMIT :limit OFFSET :offset"); query.bindValue(":limit", limit); query.bindValue(":offset", offset); - } else { - // Get all results - query.prepare("SELECT id, full_path, ocr_text FROM ocr_results ORDER BY id"); } - + if (!query.exec()) { - qDebug() << "Failed to fetch images:" << query.lastError().text(); + qDebug() << "getAllImages failed:" << query.lastError().text(); m_db.rollback(); return images; } - // Reserve space for results to avoid reallocations - images.reserve(query.size() > 0 ? query.size() : 100); - + images.reserve(100); while (query.next()) { ImageItem item; - item.id = query.value(0).toInt(); + item.id = query.value(0).toInt(); item.filePath = query.value(1).toString(); - item.ocrText = query.value(2).toString(); - - // Only add images that have a non-empty path - if (!item.filePath.isEmpty()) { + item.ocrText = query.value(2).toString(); + if (!item.filePath.isEmpty()) images.append(item); - } } - m_db.commit(); - - // Update cache - cacheLocker.relock(); + + QMutexLocker cacheLocker(&m_cacheMutex); m_allImagesCache[cacheKey] = images; m_lastCacheUpdate = QDateTime::currentDateTime(); - cacheLocker.unlock(); - return images; } @@ -325,151 +361,137 @@ QSqlDatabase DatabaseManager::getDatabaseConnection() return threadDb; } -void DatabaseManager::searchImages(const QString &searchText, int offset, int limit) +void DatabaseManager::searchImages(const QString &searchText, int offset, int limit, + SortOrder sort, const QDate &from, const QDate &to) { if (!m_initialized) { - qDebug() << "Database not initialized."; - emit searchResultsReady(QList(), searchText, offset, limit, 0); + emit searchResultsReady({}, searchText, offset, limit, 0, sort, from, to); return; } - // Verify database is still connected if (!m_db.isOpen() && !m_db.open()) { - qDebug() << "Database connection lost and cannot be reopened:" << m_db.lastError().text(); m_initialized = false; - emit searchResultsReady(QList(), searchText, offset, limit, 0); + emit searchResultsReady({}, searchText, offset, limit, 0, sort, from, to); return; } - // If search text is empty, return all images if (searchText.isEmpty()) { - // For empty search, return all images with pagination - QList allImages = getAllImages(offset, limit); + QList allImages = getAllImages(offset, limit, sort, from, to); int totalCount = getImageCount(); - - emit searchResultsReady(allImages, searchText, offset, limit, totalCount); + emit searchResultsReady(allImages, searchText, offset, limit, totalCount, sort, from, to); return; } - // Check if we have a cached result for this search query + // Check cache — key encodes text + sort + date + pagination + const QString outerKey = makeSearchKey(searchText, sort, from, to); + const QPair pageKey(offset, limit); { QMutexLocker locker(&m_cacheMutex); - QPair cacheKey(offset, limit); - if (m_searchCache.contains(searchText) && - m_searchCache[searchText].contains(cacheKey)) { - - SearchCacheItem cacheItem = m_searchCache[searchText][cacheKey]; - - // Check if cache is still valid - if (cacheItem.timestamp.secsTo(QDateTime::currentDateTime()) < CACHE_LIFETIME_SECS) { - emit searchResultsReady(cacheItem.results, searchText, offset, limit, cacheItem.totalCount); + if (m_searchCache.contains(outerKey) && m_searchCache[outerKey].contains(pageKey)) { + const SearchCacheItem &ci = m_searchCache[outerKey][pageKey]; + if (ci.timestamp.secsTo(QDateTime::currentDateTime()) < CACHE_LIFETIME_SECS) { + emit searchResultsReady(ci.results, searchText, offset, limit, + ci.totalCount, sort, from, to); return; } } } - // No delay needed since we're using typing inactivity timer in MainWindow - - // Cancel any ongoing search before starting a new one cancelSearch(); - - // Store the current search parameters safely + { QMutexLocker locker(&m_searchMutex); - m_currentSearchText = searchText; - m_currentOffset = offset; - m_currentLimit = limit; - m_searchCancelled = false; + m_currentSearchKey = outerKey; + m_currentOffset = offset; + m_currentLimit = limit; + m_searchCancelled = false; } - // The signal is now emitted before starting the thread to ensure UI responsiveness - - // Start the search operation in a background thread - m_searchFuture = QtConcurrent::run([this, searchText, offset, limit]() { - performSearchInBackground(searchText, offset, limit); + + m_searchFuture = QtConcurrent::run([this, searchText, offset, limit, sort, from, to]() { + performSearchInBackground(searchText, offset, limit, sort, from, to); }); - - // Show immediate feedback that search is starting + emit searchStarted(searchText); - m_searchWatcher.setFuture(m_searchFuture); } void DatabaseManager::cancelSearch() { - // Non-blocking: just set the flag. The background thread checks it at - // multiple points and exits early. The new search starts immediately. QMutexLocker locker(&m_searchMutex); m_searchCancelled = true; - m_currentSearchText.clear(); + m_currentSearchKey.clear(); } -void DatabaseManager::performSearchInBackground(const QString &searchText, int offset, int limit) +void DatabaseManager::performSearchInBackground(const QString &searchText, int offset, int limit, + SortOrder sort, const QDate &from, const QDate &to) { QList images; - - // Get a thread-specific database connection + const QString searchKey = makeSearchKey(searchText, sort, from, to); + QSqlDatabase threadDb = getDatabaseConnection(); - if (!threadDb.isOpen() && !threadDb.open()) { - qDebug() << "Thread database connection failed:" << threadDb.lastError().text(); + qDebug() << "Thread DB connection failed:" << threadDb.lastError().text(); return; } - - // Check if search was cancelled + { QMutexLocker locker(&m_searchMutex); - if (m_searchCancelled || m_currentSearchText != searchText) { - return; - } + if (m_searchCancelled || m_currentSearchKey != searchKey) return; } int totalCount = 0; // Check if search was cancelled before main query { QMutexLocker locker(&m_searchMutex); - if (m_searchCancelled || m_currentSearchText != searchText) { + if (m_searchCancelled || m_currentSearchKey != makeSearchKey(searchText, sort, from, to)) { return; } } - // Single query: COUNT(*) OVER() gives the total match count without a - // separate round-trip. SQLite evaluates window functions before LIMIT, so - // the count reflects the full result set even when we page with LIMIT/OFFSET. + // Single query: COUNT(*) OVER() gives total match count without a separate round-trip. threadDb.transaction(); QSqlQuery query(threadDb); + const QString dateExtra = dateWhereFragment(from, to); if (m_ftsEnabled) { - QString ftsQuery = prepareFTSQuery(searchText); - QString queryStr = + // For FTS5: date filter goes into a subquery so it combines with MATCH cleanly + QString ftsQ = prepareFTSQuery(searchText); + // When user picks a sort other than relevance, use that column; otherwise rank + QString orderBy = (sort == SortOrder::DateNewest || sort == SortOrder::DateOldest + || sort == SortOrder::NameAsc || sort == SortOrder::NameDesc) + ? sortClause(sort) + : "ORDER BY rank"; + + QString sql = "SELECT r.id, r.full_path, r.ocr_text, COUNT(*) OVER() AS total_count " "FROM ocr_results r " "JOIN ocr_fts f ON r.id = f.rowid " - "WHERE ocr_fts MATCH :query " - "ORDER BY rank"; - if (limit > 0) - queryStr += " LIMIT :limit OFFSET :offset"; + "WHERE ocr_fts MATCH :query" + dateExtra + " " + orderBy; + if (limit > 0) sql += " LIMIT :limit OFFSET :offset"; - query.prepare(queryStr); - query.bindValue(":query", ftsQuery); + query.prepare(sql); + query.bindValue(":query", ftsQ); + bindDateParams(query, from, to); if (limit > 0) { query.bindValue(":limit", limit); query.bindValue(":offset", offset); } } else { - QString whereClause = (searchText.length() <= 3) + QString textWhere = (searchText.length() <= 3) ? "ocr_text LIKE :search" : "ocr_text LIKE :search OR ocr_text LIKE :wordstart"; - QString queryStr = + QString sql = "SELECT id, full_path, ocr_text, COUNT(*) OVER() AS total_count " - "FROM ocr_results WHERE " + whereClause + " ORDER BY id"; - if (limit > 0) - queryStr += " LIMIT :limit OFFSET :offset"; + "FROM ocr_results WHERE (" + textWhere + ")" + dateExtra + + " " + sortClause(sort); + if (limit > 0) sql += " LIMIT :limit OFFSET :offset"; - query.prepare(queryStr); + query.prepare(sql); query.bindValue(":search", "%" + searchText + "%"); if (searchText.length() > 3) query.bindValue(":wordstart", "% " + searchText + "%"); + bindDateParams(query, from, to); if (limit > 0) { query.bindValue(":limit", limit); query.bindValue(":offset", offset); @@ -479,103 +501,71 @@ void DatabaseManager::performSearchInBackground(const QString &searchText, int o if (!query.exec()) { qDebug() << "Search query failed:" << query.lastError().text(); threadDb.rollback(); - - // FTS failure: fall back to LIKE for this request if (m_ftsEnabled) { - qDebug() << "Falling back to LIKE query"; + qDebug() << "Falling back to LIKE"; threadDb.transaction(); - query.prepare( + QString sql = "SELECT id, full_path, ocr_text, COUNT(*) OVER() AS total_count " - "FROM ocr_results WHERE ocr_text LIKE :search ORDER BY id"); + "FROM ocr_results WHERE ocr_text LIKE :search" + dateExtra + + " " + sortClause(sort); + if (limit > 0) sql += " LIMIT :limit OFFSET :offset"; + query.prepare(sql); query.bindValue(":search", "%" + searchText + "%"); - if (!query.exec()) { - qDebug() << "Fallback query failed:" << query.lastError().text(); - threadDb.rollback(); - return; - } + bindDateParams(query, from, to); + if (limit > 0) { query.bindValue(":limit", limit); query.bindValue(":offset", offset); } + if (!query.exec()) { threadDb.rollback(); return; } } else { return; } } - // Check cancellation again after query execution { QMutexLocker locker(&m_searchMutex); - if (m_searchCancelled || m_currentSearchText != searchText) { - threadDb.rollback(); - return; - } + if (m_searchCancelled || m_currentSearchKey != searchKey) { threadDb.rollback(); return; } } images.reserve(100); - while (query.next()) { if (query.at() % 20 == 0) { QMutexLocker locker(&m_searchMutex); - if (m_searchCancelled || m_currentSearchText != searchText) { - threadDb.rollback(); - return; - } + if (m_searchCancelled || m_currentSearchKey != searchKey) { threadDb.rollback(); return; } } - - // Read total count from the window function column (same on every row) - if (totalCount == 0) - totalCount = query.value(3).toInt(); - + if (totalCount == 0) totalCount = query.value(3).toInt(); ImageItem item; item.id = query.value(0).toInt(); item.filePath = query.value(1).toString(); item.ocrText = query.value(2).toString(); - - if (!item.filePath.isEmpty()) - images.append(item); + if (!item.filePath.isEmpty()) images.append(item); } - threadDb.commit(); // Check if search was cancelled before storing results { QMutexLocker locker(&m_searchMutex); - if (m_searchCancelled || m_currentSearchText != searchText) { + if (m_searchCancelled || m_currentSearchKey != makeSearchKey(searchText, sort, from, to)) { return; } } - - // Cache the result for future queries and emit signal with the results - QMutexLocker locker(&m_cacheMutex); - - // Create cache item with results and metadata - SearchCacheItem cacheItem; - cacheItem.results = images; - cacheItem.totalCount = totalCount; - cacheItem.timestamp = QDateTime::currentDateTime(); - - // If this is the first query for this search text, create a new map - if (!m_searchCache.contains(searchText)) { - m_searchCache.insert(searchText, QMap, SearchCacheItem>()); + + // Store in cache + { + QMutexLocker locker(&m_cacheMutex); + SearchCacheItem ci; + ci.results = images; + ci.totalCount = totalCount; + ci.timestamp = QDateTime::currentDateTime(); + if (!m_searchCache.contains(searchKey)) + m_searchCache.insert(searchKey, {}); + m_searchCache[searchKey].insert({offset, limit}, ci); + if (m_searchCache.size() > MAX_CACHE_SIZE) + m_searchCache.remove(m_searchCache.firstKey()); } - - // Store results for this specific offset/limit combination - QPair cacheKey(offset, limit); - m_searchCache[searchText].insert(cacheKey, cacheItem); - - // Limit cache size to avoid memory issues - if (m_searchCache.size() > MAX_CACHE_SIZE) { - // Remove the oldest entry - if (!m_searchCache.isEmpty()) { - QString oldestKey = m_searchCache.firstKey(); - m_searchCache.remove(oldestKey); - } - } - - // Release mutex before emitting signal - locker.unlock(); - - // Emit signal with results, including pagination info + + // Emit only if this search is still current QMutexLocker searchLocker(&m_searchMutex); - if (!m_searchCancelled && m_currentSearchText == searchText) { + if (!m_searchCancelled && m_currentSearchKey == searchKey) { searchLocker.unlock(); - emit searchResultsReady(images, searchText, offset, limit, totalCount); + emit searchResultsReady(images, searchText, offset, limit, totalCount, sort, from, to); } } @@ -586,7 +576,6 @@ void DatabaseManager::cleanupCache() // Get current time QDateTime now = QDateTime::currentDateTime(); - // Expire old search cache items QMutableMapIterator, SearchCacheItem>> i(m_searchCache); while (i.hasNext()) { i.next(); diff --git a/src/databasemanager.h b/src/databasemanager.h index aef3d38..f55e722 100644 --- a/src/databasemanager.h +++ b/src/databasemanager.h @@ -13,6 +13,7 @@ #include #include #include +#include #include #include @@ -28,13 +29,17 @@ class DatabaseManager : public QObject Q_OBJECT public: - /** - * @brief Struct to represent an image item with its path and OCR text - */ + enum class SortOrder { + DateNewest, // ORDER BY created_date DESC (default) + DateOldest, // ORDER BY created_date ASC + NameAsc, // ORDER BY filename ASC + NameDesc // ORDER BY filename DESC + }; + struct ImageItem { QString filePath; QString ocrText; - int id; // Add id to support pagination + int id; }; /** @@ -69,44 +74,28 @@ public: * @param limit Maximum number of items to return, 0 means no limit * @return List of ImageItem objects */ - QList getAllImages(int offset = 0, int limit = 0); + QList getAllImages(int offset = 0, int limit = 0, + SortOrder sort = SortOrder::DateNewest, + const QDate &from = QDate(), + const QDate &to = QDate()); - /** - * @brief Get total count of all images in the database - * @return Total number of images - */ int getImageCount(); public slots: - /** - * @brief Search for images matching the search text - * @param searchText Text to search for in OCR results - * @param offset Starting position (0-based) for pagination - * @param limit Maximum number of items to return, 0 means no limit - */ - void searchImages(const QString &searchText, int offset = 0, int limit = 0); + void searchImages(const QString &searchText, int offset = 0, int limit = 0, + SortOrder sort = SortOrder::DateNewest, + const QDate &from = QDate(), + const QDate &to = QDate()); - /** - * @brief Cancel any ongoing search operations - */ void cancelSearch(); signals: - /** - * @brief Signal emitted when search results are available - * @param results The list of image items matching the search - * @param searchText The original search text that produced these results - * @param offset The offset used for this result set - * @param limit The limit used for this result set - * @param totalCount The total number of results matching the search (regardless of pagination) - */ - void searchResultsReady(const QList &results, const QString &searchText, - int offset, int limit, int totalCount); + void searchResultsReady(const QList &results, + const QString &searchText, + int offset, int limit, int totalCount, + DatabaseManager::SortOrder sort, + const QDate &from, const QDate &to); - /** - * @brief Signal emitted when search operation starts - * @param searchText The search text being processed - */ void searchStarted(const QString &searchText); private: @@ -125,15 +114,15 @@ private: QMap, SearchCacheItem>> m_searchCache; // searchText -> {(offset, limit) -> results} QMutex m_cacheMutex; // Mutex to protect cache access from multiple threads - // All images cache with pagination - QMap, QList> m_allImagesCache; // (offset, limit) -> results + // All images cache — key encodes sort+date+offset+limit + QMap> m_allImagesCache; int m_cachedImageCount; // Total image count cache QDateTime m_lastCacheUpdate; // When the cache was last updated // Search thread management QFuture m_searchFuture; QFutureWatcher m_searchWatcher; - QString m_currentSearchText; + QString m_currentSearchKey; // encoded: text|sort|from|to int m_currentOffset; int m_currentLimit; bool m_searchCancelled; @@ -152,8 +141,8 @@ private: // Prepare FTS query with proper syntax QString prepareFTSQuery(const QString &searchText); - // Perform search operation in background thread - void performSearchInBackground(const QString &searchText, int offset, int limit); + void performSearchInBackground(const QString &searchText, int offset, int limit, + SortOrder sort, const QDate &from, const QDate &to); // Clear expired cache items void cleanupCache(); diff --git a/src/imagegallery.cpp b/src/imagegallery.cpp index e9f4663..81e71a0 100644 --- a/src/imagegallery.cpp +++ b/src/imagegallery.cpp @@ -10,6 +10,9 @@ #include #include #include +#include +#include +#include @@ -154,8 +157,9 @@ void ImageGallery::setDatabaseManager(DatabaseManager *dbManager) // Connect to the DatabaseManager signals if (m_dbManager) { - connect(m_dbManager, &DatabaseManager::searchResultsReady, + connect(m_dbManager, &DatabaseManager::searchResultsReady, this, &ImageGallery::handleSearchResults); + connect(m_dbManager, &DatabaseManager::searchStarted, this, &ImageGallery::handleSearchStarted); } @@ -314,17 +318,19 @@ void ImageGallery::handleSearchTextChanged(const QString &searchText) m_loadingIndicator->show(); m_gridLayout->addWidget(m_loadingIndicator, 0, 0, 1, m_columnsCount); - // Initiate the threaded search with pagination - m_dbManager->searchImages(searchText, m_currentOffset, m_pageSize); + m_dbManager->searchImages(searchText, m_currentOffset, m_pageSize, + m_sortOrder, m_fromDate, m_toDate); } } -void ImageGallery::handleSearchResults(const QList &results, - const QString &searchText, int offset, int limit, - int totalCount) +void ImageGallery::handleSearchResults(const QList &results, + const QString &searchText, int offset, int limit, + int totalCount, DatabaseManager::SortOrder sort, + const QDate &from, const QDate &to) { - // Only update display if this is the result for the most recent search - if (searchText == m_lastSearchQuery) { + // Discard stale results from a superseded search / sort / filter + if (searchText == m_lastSearchQuery && sort == m_sortOrder + && from == m_fromDate && to == m_toDate) { m_isLoading = false; m_totalCount = totalCount; @@ -416,8 +422,8 @@ void ImageGallery::loadMoreImages() int row = (m_thumbnails.size() / m_columnsCount); m_gridLayout->addWidget(m_loadingIndicator, row + 1, 0, 1, m_columnsCount); - // Request next page from database - m_dbManager->searchImages(m_lastSearchQuery, m_currentOffset, m_pageSize); + m_dbManager->searchImages(m_lastSearchQuery, m_currentOffset, m_pageSize, + m_sortOrder, m_fromDate, m_toDate); } // Handle scroll to bottom event @@ -535,29 +541,61 @@ void ImageGallery::handleThumbnailClicked(const QString &filePath) QApplication::restoreOverrideCursor(); } +void ImageGallery::setSortOrder(DatabaseManager::SortOrder sort) +{ + m_sortOrder = sort; +} + +void ImageGallery::setDateFilter(const QDate &from, const QDate &to) +{ + m_fromDate = from; + m_toDate = to; +} + +// Returns the on-disk cache directory, creating it if needed. +static QString thumbnailCacheDir() +{ + QString dir = QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + + "/thumbnails/"; + QDir().mkpath(dir); + return dir; +} + +// Cache key: MD5 of "filePath|mtime" so stale entries are naturally bypassed +// when the source file is modified. +static QString thumbnailCacheKey(const QString &filePath) +{ + QFileInfo fi(filePath); + QByteArray raw = (filePath + "|" + + QString::number(fi.lastModified().toSecsSinceEpoch())).toUtf8(); + return QCryptographicHash::hash(raw, QCryptographicHash::Md5).toHex() + ".png"; +} + void ImageGallery::loadThumbnailAsync(ImageThumbnail *label, const QString &filePath) { - // QImage loading and scaling are thread-safe; QPixmap creation must happen - // on the main thread. We do the heavy work in a background thread and marshal - // the result back via invokeMethod so the main thread never stalls on disk I/O. QPointer ptr(label); QThreadPool::globalInstance()->start([this, ptr, filePath]() { - QImage image; - QFileInfo fi(filePath); - if (fi.exists() && fi.isReadable()) { - image = QImage(filePath); - if (!image.isNull()) - image = image.scaled(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT, - Qt::KeepAspectRatio, Qt::SmoothTransformation); + // 1. Try disk cache (already scaled PNG — much faster than re-decoding source) + const QString cacheFile = thumbnailCacheDir() + thumbnailCacheKey(filePath); + QImage image(cacheFile); + + if (image.isNull()) { + // 2. Cache miss: load source, scale, write cache entry + QFileInfo fi(filePath); + if (fi.exists() && fi.isReadable()) { + image = QImage(filePath); + if (!image.isNull()) { + image = image.scaled(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT, + Qt::KeepAspectRatio, Qt::SmoothTransformation); + image.save(cacheFile, "PNG"); + } + } } + // 3. Marshal QPixmap creation back to the main thread QMetaObject::invokeMethod(this, [ptr, image]() { - if (!ptr) return; - if (image.isNull()) { - // File missing or unreadable — placeholder already shown, nothing to do - return; - } + if (!ptr || image.isNull()) return; ptr->setPixmap(QPixmap::fromImage(image)); }, Qt::QueuedConnection); }); diff --git a/src/imagegallery.h b/src/imagegallery.h index 270561e..235f352 100644 --- a/src/imagegallery.h +++ b/src/imagegallery.h @@ -14,6 +14,7 @@ #include #include #include +#include #include "databasemanager.h" // Global constants @@ -63,10 +64,15 @@ public slots: void handleThumbnailClicked(const QString &filePath); void handleContainerResized(); // New slot to handle resize events void updateGridLayout(); // Adjusts grid based on current window size - void handleSearchResults(const QList &results, const QString &searchText, - int offset, int limit, int totalCount); + void handleSearchResults(const QList &results, + const QString &searchText, int offset, int limit, int totalCount, + DatabaseManager::SortOrder sort, const QDate &from, const QDate &to); void handleSearchStarted(const QString &searchText); - void updateSearchTextDisplay(const QString &searchText); // Update UI with search text without performing search + void updateSearchTextDisplay(const QString &searchText); + + // Sort / filter setters — call then refresh to apply + void setSortOrder(DatabaseManager::SortOrder sort); + void setDateFilter(const QDate &from, const QDate &to); void handleScrolledToBottom(); void handleLoadMoreClicked(); @@ -94,7 +100,10 @@ private: void loadThumbnailAsync(ImageThumbnail *label, const QString &filePath); QPixmap createPlaceholderThumbnail(int width, int height, const QString &message); - QString m_lastSearchQuery; // Track the last search text + QString m_lastSearchQuery; + DatabaseManager::SortOrder m_sortOrder = DatabaseManager::SortOrder::DateNewest; + QDate m_fromDate; + QDate m_toDate; // Track scroll position int m_lastScrollPosition; diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 51509ed..eff38bd 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -94,7 +94,8 @@ MainWindow::MainWindow(QWidget *parent) connect(m_dbManager, &DatabaseManager::searchResultsReady, this, [this](const QList &results, const QString &text, - int offset, int limit, int totalCount) { + int offset, int limit, int totalCount, + DatabaseManager::SortOrder /*sort*/, const QDate & /*from*/, const QDate & /*to*/) { if (text.isEmpty()) { statusBar()->showMessage(tr("Showing all images (paginated) - %1 total").arg(totalCount)); } else { @@ -124,6 +125,16 @@ MainWindow::~MainWindow() // All QObjects will be automatically deleted via parent-child relationship } +static DatabaseManager::SortOrder sortFromIndex(int idx) +{ + switch (idx) { + case 1: return DatabaseManager::SortOrder::DateOldest; + case 2: return DatabaseManager::SortOrder::NameAsc; + case 3: return DatabaseManager::SortOrder::NameDesc; + default: return DatabaseManager::SortOrder::DateNewest; + } +} + void MainWindow::createLayout() { // Create central widget @@ -187,12 +198,56 @@ void MainWindow::createLayout() m_imageGallery = new ImageGallery(this); m_imageGallery->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + // Filter row: sort + date range + clear + QHBoxLayout *filterLayout = new QHBoxLayout(); + filterLayout->setSpacing(6); + + QLabel *sortLabel = new QLabel(tr("Sort:"), this); + m_sortCombo = new QComboBox(this); + m_sortCombo->addItem(tr("Newest First")); + m_sortCombo->addItem(tr("Oldest First")); + m_sortCombo->addItem(tr("Name A-Z")); + m_sortCombo->addItem(tr("Name Z-A")); + + QLabel *fromLabel = new QLabel(tr("From:"), this); + m_fromDateEdit = new QDateEdit(this); + m_fromDateEdit->setCalendarPopup(true); + m_fromDateEdit->setMinimumDate(QDate(1970, 1, 1)); + m_fromDateEdit->setDate(m_fromDateEdit->minimumDate()); + m_fromDateEdit->setSpecialValueText(tr(" Any ")); + m_fromDateEdit->setDisplayFormat("yyyy-MM-dd"); + + QLabel *toLabel = new QLabel(tr("To:"), this); + m_toDateEdit = new QDateEdit(this); + m_toDateEdit->setCalendarPopup(true); + m_toDateEdit->setMinimumDate(QDate(1970, 1, 1)); + m_toDateEdit->setDate(m_toDateEdit->minimumDate()); + m_toDateEdit->setSpecialValueText(tr(" Any ")); + m_toDateEdit->setDisplayFormat("yyyy-MM-dd"); + + m_clearDatesBtn = new QPushButton(tr("Clear Dates"), this); + + filterLayout->addWidget(sortLabel); + filterLayout->addWidget(m_sortCombo); + filterLayout->addSpacing(12); + filterLayout->addWidget(fromLabel); + filterLayout->addWidget(m_fromDateEdit); + filterLayout->addWidget(toLabel); + filterLayout->addWidget(m_toDateEdit); + filterLayout->addWidget(m_clearDatesBtn); + filterLayout->addStretch(); + // Add widgets to layout m_mainLayout->addWidget(m_searchBar); + m_mainLayout->addLayout(filterLayout); m_mainLayout->addWidget(m_imageGallery, 1); - - // Connect signals - use textEdited instead of textChanged to handle only user input + + // Connect signals connect(m_searchBar, &QLineEdit::textEdited, this, &MainWindow::handleSearchTextChanged); + connect(m_sortCombo, QOverload::of(&QComboBox::currentIndexChanged), this, &MainWindow::handleSortChanged); + connect(m_fromDateEdit, &QDateEdit::dateChanged, this, &MainWindow::handleDateFilterChanged); + connect(m_toDateEdit, &QDateEdit::dateChanged, this, &MainWindow::handleDateFilterChanged); + connect(m_clearDatesBtn, &QPushButton::clicked, this, &MainWindow::handleClearDates); } void MainWindow::initializeDatabase() @@ -247,26 +302,26 @@ void MainWindow::displayAllImages() statusBar()->showMessage(tr("No database connection available. Cannot display images."), 5000); return; } - - // Get total image count first + + DatabaseManager::SortOrder sort = sortFromIndex(m_sortCombo->currentIndex()); + QDate from = (m_fromDateEdit->date() == m_fromDateEdit->minimumDate()) ? QDate() : m_fromDateEdit->date(); + QDate to = (m_toDateEdit->date() == m_toDateEdit->minimumDate()) ? QDate() : m_toDateEdit->date(); + + m_imageGallery->setSortOrder(sort); + m_imageGallery->setDateFilter(from, to); + int totalImageCount = m_dbManager->getImageCount(); - - // Get the first page of images with pagination - QList allImages = m_dbManager->getAllImages(0, m_imagePreloadCount); + QList allImages = m_dbManager->getAllImages(0, m_imagePreloadCount, sort, from, to); + if (allImages.isEmpty()) { statusBar()->showMessage(tr("No images found in database"), 5000); } else { statusBar()->showMessage(tr("Loaded %1 of %2 images").arg(allImages.size()).arg(totalImageCount), 3000); } - - // Clear existing images and display first page - bool hasMoreImages = (totalImageCount > allImages.size()); + m_imageGallery->displayImages(allImages, true); - - // ALWAYS force update to ensure Load More button appears correctly - // This is critical for when we reset the search - m_imageGallery->handleSearchResults(allImages, "", 0, m_imagePreloadCount, totalImageCount); - + m_imageGallery->handleSearchResults(allImages, "", 0, m_imagePreloadCount, totalImageCount, sort, from, to); + updateStatusBar(); } @@ -362,6 +417,38 @@ void MainWindow::performSearch() } } +void MainWindow::handleSortChanged(int index) +{ + m_imageGallery->setSortOrder(sortFromIndex(index)); + if (m_lastSearchText.isEmpty()) + displayAllImages(); + else + performSearch(); +} + +void MainWindow::handleDateFilterChanged() +{ + QDate from = (m_fromDateEdit->date() == m_fromDateEdit->minimumDate()) ? QDate() : m_fromDateEdit->date(); + QDate to = (m_toDateEdit->date() == m_toDateEdit->minimumDate()) ? QDate() : m_toDateEdit->date(); + m_imageGallery->setDateFilter(from, to); + if (m_lastSearchText.isEmpty()) + displayAllImages(); + else + performSearch(); +} + +void MainWindow::handleClearDates() +{ + QSignalBlocker b1(m_fromDateEdit), b2(m_toDateEdit); + m_fromDateEdit->setDate(m_fromDateEdit->minimumDate()); + m_toDateEdit->setDate(m_toDateEdit->minimumDate()); + m_imageGallery->setDateFilter(QDate(), QDate()); + if (m_lastSearchText.isEmpty()) + displayAllImages(); + else + performSearch(); +} + // File menu action handlers void MainWindow::handleOpenFile() { diff --git a/src/mainwindow.h b/src/mainwindow.h index 1eafd96..eb09eea 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -4,10 +4,14 @@ #include #include #include +#include #include #include #include #include +#include +#include +#include #include "databasemanager.h" #include "imagegallery.h" #include "updatedatabasedialog.h" @@ -25,6 +29,9 @@ protected: private slots: void handleSearchTextChanged(); + void handleSortChanged(int index); + void handleDateFilterChanged(); + void handleClearDates(); void performSearch(); void updateStatusBar(); void handleTypingInactivityTimeout(); @@ -44,6 +51,10 @@ private: QWidget *m_centralWidget; QVBoxLayout *m_mainLayout; QLineEdit *m_searchBar; + QComboBox *m_sortCombo; + QDateEdit *m_fromDateEdit; + QDateEdit *m_toDateEdit; + QPushButton *m_clearDatesBtn; ImageGallery *m_imageGallery; // Data