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 <noreply@anthropic.com>
This commit is contained in:
yzinchuk
2026-07-03 04:19:04 -04:00
parent 55454d7ef6
commit 850864dc58
7 changed files with 406 additions and 268 deletions
+23 -8
View File
@@ -159,13 +159,17 @@ Central controller. Inherits `QMainWindow`.
Responsibilities: Responsibilities:
- Menu bar: File → Open, Settings, Update Database, Quit - 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`) - Animated "Searching…" status bar indicator (`m_searchingAnimationTimer`)
- Instantiates and owns `DatabaseManager` and `ImageGallery` - Instantiates and owns `DatabaseManager` and `ImageGallery`
- Reads/writes settings via `QSettings` (INI at `~/.config/OrcGallery/settings.ini`) - Reads/writes settings via `QSettings` (INI at `~/.config/OrcGallery/settings.ini`)
- On search complete: updates status bar with result count - On search complete: updates status bar with result count
- On clear search: reloads all images (first page) - 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: Settings keys:
- `databasePath` — path to `.db` file - `databasePath` — path to `.db` file
- `screenshotsDir` — directory scanned by OCR script - `screenshotsDir` — directory scanned by OCR script
@@ -198,17 +202,21 @@ Key public API:
|---|---| |---|---|
| `initialize(path)` | Opens/validates an existing database | | `initialize(path)` | Opens/validates an existing database |
| `createDatabase(path)` | Creates schema from scratch | | `createDatabase(path)` | Creates schema from scratch |
| `getAllImages(offset, limit)` | Paginated full-path list, no search filter | | `getAllImages(offset, limit, sort, from, to)` | Paginated list with optional sort order and date range |
| `getImageCount()` | Total row count (cached) | | `getImageCount()` | Total row count (unfiltered) |
| `searchImages(text, offset, limit)` | FTS5 or LIKE search, runs in background thread via `QtConcurrent::run` | | `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 | | `cancelSearch()` | Sets cancellation flag; background thread checks it |
| `initializeFTS()` | Creates virtual table + triggers if FTS5 available | | `initializeFTS()` | Creates virtual table + triggers if FTS5 available |
| `prepareFTSQuery(text)` | Converts user text to FTS5 query syntax | | `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: Caching:
- `m_searchCache`: `QMap<QString, CachedResult>` keyed by query string, 5-minute TTL - `m_searchCache`: `QMap<QString, QMap<QPair<int,int>, SearchCacheItem>>` — outer key is `makeSearchKey(...)` encoding text+sort+date, inner key is `(offset, limit)`, 5-minute TTL
- `m_allImagesCache`: paginated all-images results - `m_allImagesCache`: `QMap<QString, QList<ImageItem>>` — key encodes sort+date+offset+limit
- `m_imageCountCache`: row count integer - `m_cachedImageCount`: row count integer
- Cache eviction when `m_searchCache` exceeds 50 entries - Cache eviction when `m_searchCache` exceeds 50 entries
- All cache access under `m_cacheMutex` - All cache access under `m_cacheMutex`
@@ -248,6 +256,9 @@ Behavior:
├───────────────────────────────────────────────────────────┤ ├───────────────────────────────────────────────────────────┤
│ [ Search OCR text... ] [×] │ ← Search bar │ [ Search OCR text... ] [×] │ ← Search bar
├───────────────────────────────────────────────────────────┤ ├───────────────────────────────────────────────────────────┤
│ Sort: [Newest First v] From: [ Any ] To: [ Any ] │ ← Filter bar
│ [Clear Dates] │
├───────────────────────────────────────────────────────────┤
│ │ │ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ │ │ │ │ │ │ │ │ ← Thumbnails │ │ │ │ │ │ │ │ │ │ ← 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 | | 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` | | 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 | | 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`). - `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. - 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. - `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. - `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<ImageThumbnail>` guard prevents use-after-free if the gallery clears before the load completes. - 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<ImageThumbnail>` 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".
+146 -157
View File
@@ -6,12 +6,54 @@
#include <QFileInfo> #include <QFileInfo>
#include <QTimer> #include <QTimer>
// --- 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<int>(sort)) + "|"
+ from.toString(Qt::ISODate) + "|" + to.toString(Qt::ISODate);
}
// ----------------------------------------------------------------------------
DatabaseManager::DatabaseManager(QObject *parent) DatabaseManager::DatabaseManager(QObject *parent)
: QObject(parent) : QObject(parent)
, m_initialized(false) , m_initialized(false)
, m_ftsEnabled(false) , m_ftsEnabled(false)
, m_searchCancelled(false) , m_searchCancelled(false)
, m_cachedImageCount(-1) // Initialize to invalid value , m_cachedImageCount(-1)
, m_currentOffset(0) , m_currentOffset(0)
, m_currentLimit(0) , m_currentLimit(0)
{ {
@@ -31,10 +73,10 @@ DatabaseManager::DatabaseManager(QObject *parent)
DatabaseManager::~DatabaseManager() DatabaseManager::~DatabaseManager()
{ {
// Signal cancellation and wait for the background thread to drain cleanly.
{ {
QMutexLocker locker(&m_searchMutex); QMutexLocker locker(&m_searchMutex);
m_searchCancelled = true; m_searchCancelled = true;
m_currentSearchKey.clear();
} }
if (m_searchFuture.isRunning()) if (m_searchFuture.isRunning())
m_searchFuture.waitForFinished(); m_searchFuture.waitForFinished();
@@ -183,76 +225,70 @@ bool DatabaseManager::initialize(const QString &dbPath)
return true; return true;
} }
QList<DatabaseManager::ImageItem> DatabaseManager::getAllImages(int offset, int limit) QList<DatabaseManager::ImageItem> DatabaseManager::getAllImages(int offset, int limit,
SortOrder sort,
const QDate &from,
const QDate &to)
{ {
QList<ImageItem> images; QList<ImageItem> images;
if (!m_initialized) { if (!m_initialized) return images;
qDebug() << "Database not initialized.";
return images;
}
// Check cache first // Cache key encodes all query parameters
QPair<int, int> cacheKey(offset, limit); QString cacheKey = makeSearchKey("", sort, from, to)
+ "|" + QString::number(offset)
+ "|" + QString::number(limit);
{
QMutexLocker cacheLocker(&m_cacheMutex); QMutexLocker cacheLocker(&m_cacheMutex);
if (m_allImagesCache.contains(cacheKey)) { if (m_allImagesCache.contains(cacheKey) &&
// Use cached results if available and not expired m_lastCacheUpdate.secsTo(QDateTime::currentDateTime()) < CACHE_LIFETIME_SECS) {
if (m_lastCacheUpdate.secsTo(QDateTime::currentDateTime()) < CACHE_LIFETIME_SECS) {
return m_allImagesCache[cacheKey]; return m_allImagesCache[cacheKey];
} }
} }
cacheLocker.unlock();
// Verify database is still connected
if (!m_db.isOpen() && !m_db.open()) { if (!m_db.isOpen() && !m_db.open()) {
qDebug() << "Database connection lost and cannot be reopened:" << m_db.lastError().text();
m_initialized = false; m_initialized = false;
return images; return images;
} }
// Start transaction to speed up query
m_db.transaction(); m_db.transaction();
QSqlQuery query; 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) { 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(":limit", limit);
query.bindValue(":offset", offset); 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()) { if (!query.exec()) {
qDebug() << "Failed to fetch images:" << query.lastError().text(); qDebug() << "getAllImages failed:" << query.lastError().text();
m_db.rollback(); m_db.rollback();
return images; return images;
} }
// Reserve space for results to avoid reallocations images.reserve(100);
images.reserve(query.size() > 0 ? query.size() : 100);
while (query.next()) { while (query.next()) {
ImageItem item; ImageItem item;
item.id = query.value(0).toInt(); item.id = query.value(0).toInt();
item.filePath = query.value(1).toString(); item.filePath = query.value(1).toString();
item.ocrText = query.value(2).toString(); item.ocrText = query.value(2).toString();
if (!item.filePath.isEmpty())
// Only add images that have a non-empty path
if (!item.filePath.isEmpty()) {
images.append(item); images.append(item);
} }
}
m_db.commit(); m_db.commit();
// Update cache QMutexLocker cacheLocker(&m_cacheMutex);
cacheLocker.relock();
m_allImagesCache[cacheKey] = images; m_allImagesCache[cacheKey] = images;
m_lastCacheUpdate = QDateTime::currentDateTime(); m_lastCacheUpdate = QDateTime::currentDateTime();
cacheLocker.unlock();
return images; return images;
} }
@@ -325,151 +361,137 @@ QSqlDatabase DatabaseManager::getDatabaseConnection()
return threadDb; 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) { if (!m_initialized) {
qDebug() << "Database not initialized."; emit searchResultsReady({}, searchText, offset, limit, 0, sort, from, to);
emit searchResultsReady(QList<ImageItem>(), searchText, offset, limit, 0);
return; return;
} }
// Verify database is still connected
if (!m_db.isOpen() && !m_db.open()) { if (!m_db.isOpen() && !m_db.open()) {
qDebug() << "Database connection lost and cannot be reopened:" << m_db.lastError().text();
m_initialized = false; m_initialized = false;
emit searchResultsReady(QList<ImageItem>(), searchText, offset, limit, 0); emit searchResultsReady({}, searchText, offset, limit, 0, sort, from, to);
return; return;
} }
// If search text is empty, return all images
if (searchText.isEmpty()) { if (searchText.isEmpty()) {
// For empty search, return all images with pagination QList<ImageItem> allImages = getAllImages(offset, limit, sort, from, to);
QList<ImageItem> allImages = getAllImages(offset, limit);
int totalCount = getImageCount(); int totalCount = getImageCount();
emit searchResultsReady(allImages, searchText, offset, limit, totalCount, sort, from, to);
emit searchResultsReady(allImages, searchText, offset, limit, totalCount);
return; 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<int,int> pageKey(offset, limit);
{ {
QMutexLocker locker(&m_cacheMutex); QMutexLocker locker(&m_cacheMutex);
QPair<int, int> cacheKey(offset, limit); if (m_searchCache.contains(outerKey) && m_searchCache[outerKey].contains(pageKey)) {
if (m_searchCache.contains(searchText) && const SearchCacheItem &ci = m_searchCache[outerKey][pageKey];
m_searchCache[searchText].contains(cacheKey)) { if (ci.timestamp.secsTo(QDateTime::currentDateTime()) < CACHE_LIFETIME_SECS) {
emit searchResultsReady(ci.results, searchText, offset, limit,
SearchCacheItem cacheItem = m_searchCache[searchText][cacheKey]; ci.totalCount, sort, from, to);
// Check if cache is still valid
if (cacheItem.timestamp.secsTo(QDateTime::currentDateTime()) < CACHE_LIFETIME_SECS) {
emit searchResultsReady(cacheItem.results, searchText, offset, limit, cacheItem.totalCount);
return; return;
} }
} }
} }
// No delay needed since we're using typing inactivity timer in MainWindow
// Cancel any ongoing search before starting a new one
cancelSearch(); cancelSearch();
// Store the current search parameters safely
{ {
QMutexLocker locker(&m_searchMutex); QMutexLocker locker(&m_searchMutex);
m_currentSearchText = searchText; m_currentSearchKey = outerKey;
m_currentOffset = offset; m_currentOffset = offset;
m_currentLimit = limit; m_currentLimit = limit;
m_searchCancelled = false; 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, sort, from, to]() {
m_searchFuture = QtConcurrent::run([this, searchText, offset, limit]() { performSearchInBackground(searchText, offset, limit, sort, from, to);
performSearchInBackground(searchText, offset, limit);
}); });
// Show immediate feedback that search is starting
emit searchStarted(searchText); emit searchStarted(searchText);
m_searchWatcher.setFuture(m_searchFuture); m_searchWatcher.setFuture(m_searchFuture);
} }
void DatabaseManager::cancelSearch() 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); QMutexLocker locker(&m_searchMutex);
m_searchCancelled = true; 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<ImageItem> images; QList<ImageItem> images;
const QString searchKey = makeSearchKey(searchText, sort, from, to);
// Get a thread-specific database connection
QSqlDatabase threadDb = getDatabaseConnection(); QSqlDatabase threadDb = getDatabaseConnection();
if (!threadDb.isOpen() && !threadDb.open()) { if (!threadDb.isOpen() && !threadDb.open()) {
qDebug() << "Thread database connection failed:" << threadDb.lastError().text(); qDebug() << "Thread DB connection failed:" << threadDb.lastError().text();
return; return;
} }
// Check if search was cancelled
{ {
QMutexLocker locker(&m_searchMutex); QMutexLocker locker(&m_searchMutex);
if (m_searchCancelled || m_currentSearchText != searchText) { if (m_searchCancelled || m_currentSearchKey != searchKey) return;
return;
}
} }
int totalCount = 0; int totalCount = 0;
// Check if search was cancelled before main query // Check if search was cancelled before main query
{ {
QMutexLocker locker(&m_searchMutex); QMutexLocker locker(&m_searchMutex);
if (m_searchCancelled || m_currentSearchText != searchText) { if (m_searchCancelled || m_currentSearchKey != makeSearchKey(searchText, sort, from, to)) {
return; return;
} }
} }
// Single query: COUNT(*) OVER() gives the total match count without a // Single query: COUNT(*) OVER() gives total match count without a separate round-trip.
// separate round-trip. SQLite evaluates window functions before LIMIT, so
// the count reflects the full result set even when we page with LIMIT/OFFSET.
threadDb.transaction(); threadDb.transaction();
QSqlQuery query(threadDb); QSqlQuery query(threadDb);
const QString dateExtra = dateWhereFragment(from, to);
if (m_ftsEnabled) { if (m_ftsEnabled) {
QString ftsQuery = prepareFTSQuery(searchText); // For FTS5: date filter goes into a subquery so it combines with MATCH cleanly
QString queryStr = 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 " "SELECT r.id, r.full_path, r.ocr_text, COUNT(*) OVER() AS total_count "
"FROM ocr_results r " "FROM ocr_results r "
"JOIN ocr_fts f ON r.id = f.rowid " "JOIN ocr_fts f ON r.id = f.rowid "
"WHERE ocr_fts MATCH :query " "WHERE ocr_fts MATCH :query" + dateExtra + " " + orderBy;
"ORDER BY rank"; if (limit > 0) sql += " LIMIT :limit OFFSET :offset";
if (limit > 0)
queryStr += " LIMIT :limit OFFSET :offset";
query.prepare(queryStr); query.prepare(sql);
query.bindValue(":query", ftsQuery); query.bindValue(":query", ftsQ);
bindDateParams(query, from, to);
if (limit > 0) { if (limit > 0) {
query.bindValue(":limit", limit); query.bindValue(":limit", limit);
query.bindValue(":offset", offset); query.bindValue(":offset", offset);
} }
} else { } else {
QString whereClause = (searchText.length() <= 3) QString textWhere = (searchText.length() <= 3)
? "ocr_text LIKE :search" ? "ocr_text LIKE :search"
: "ocr_text LIKE :search OR ocr_text LIKE :wordstart"; : "ocr_text LIKE :search OR ocr_text LIKE :wordstart";
QString queryStr = QString sql =
"SELECT id, full_path, ocr_text, COUNT(*) OVER() AS total_count " "SELECT id, full_path, ocr_text, COUNT(*) OVER() AS total_count "
"FROM ocr_results WHERE " + whereClause + " ORDER BY id"; "FROM ocr_results WHERE (" + textWhere + ")" + dateExtra
if (limit > 0) + " " + sortClause(sort);
queryStr += " LIMIT :limit OFFSET :offset"; if (limit > 0) sql += " LIMIT :limit OFFSET :offset";
query.prepare(queryStr); query.prepare(sql);
query.bindValue(":search", "%" + searchText + "%"); query.bindValue(":search", "%" + searchText + "%");
if (searchText.length() > 3) if (searchText.length() > 3)
query.bindValue(":wordstart", "% " + searchText + "%"); query.bindValue(":wordstart", "% " + searchText + "%");
bindDateParams(query, from, to);
if (limit > 0) { if (limit > 0) {
query.bindValue(":limit", limit); query.bindValue(":limit", limit);
query.bindValue(":offset", offset); query.bindValue(":offset", offset);
@@ -479,103 +501,71 @@ void DatabaseManager::performSearchInBackground(const QString &searchText, int o
if (!query.exec()) { if (!query.exec()) {
qDebug() << "Search query failed:" << query.lastError().text(); qDebug() << "Search query failed:" << query.lastError().text();
threadDb.rollback(); threadDb.rollback();
// FTS failure: fall back to LIKE for this request
if (m_ftsEnabled) { if (m_ftsEnabled) {
qDebug() << "Falling back to LIKE query"; qDebug() << "Falling back to LIKE";
threadDb.transaction(); threadDb.transaction();
query.prepare( QString sql =
"SELECT id, full_path, ocr_text, COUNT(*) OVER() AS total_count " "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 + "%"); query.bindValue(":search", "%" + searchText + "%");
if (!query.exec()) { bindDateParams(query, from, to);
qDebug() << "Fallback query failed:" << query.lastError().text(); if (limit > 0) { query.bindValue(":limit", limit); query.bindValue(":offset", offset); }
threadDb.rollback(); if (!query.exec()) { threadDb.rollback(); return; }
return;
}
} else { } else {
return; return;
} }
} }
// Check cancellation again after query execution
{ {
QMutexLocker locker(&m_searchMutex); QMutexLocker locker(&m_searchMutex);
if (m_searchCancelled || m_currentSearchText != searchText) { if (m_searchCancelled || m_currentSearchKey != searchKey) { threadDb.rollback(); return; }
threadDb.rollback();
return;
}
} }
images.reserve(100); images.reserve(100);
while (query.next()) { while (query.next()) {
if (query.at() % 20 == 0) { if (query.at() % 20 == 0) {
QMutexLocker locker(&m_searchMutex); QMutexLocker locker(&m_searchMutex);
if (m_searchCancelled || m_currentSearchText != searchText) { if (m_searchCancelled || m_currentSearchKey != searchKey) { threadDb.rollback(); return; }
threadDb.rollback();
return;
} }
} if (totalCount == 0) totalCount = query.value(3).toInt();
// Read total count from the window function column (same on every row)
if (totalCount == 0)
totalCount = query.value(3).toInt();
ImageItem item; ImageItem item;
item.id = query.value(0).toInt(); item.id = query.value(0).toInt();
item.filePath = query.value(1).toString(); item.filePath = query.value(1).toString();
item.ocrText = query.value(2).toString(); item.ocrText = query.value(2).toString();
if (!item.filePath.isEmpty()) images.append(item);
if (!item.filePath.isEmpty())
images.append(item);
} }
threadDb.commit(); threadDb.commit();
// Check if search was cancelled before storing results // Check if search was cancelled before storing results
{ {
QMutexLocker locker(&m_searchMutex); QMutexLocker locker(&m_searchMutex);
if (m_searchCancelled || m_currentSearchText != searchText) { if (m_searchCancelled || m_currentSearchKey != makeSearchKey(searchText, sort, from, to)) {
return; return;
} }
} }
// Cache the result for future queries and emit signal with the results // Store in cache
{
QMutexLocker locker(&m_cacheMutex); QMutexLocker locker(&m_cacheMutex);
SearchCacheItem ci;
// Create cache item with results and metadata ci.results = images;
SearchCacheItem cacheItem; ci.totalCount = totalCount;
cacheItem.results = images; ci.timestamp = QDateTime::currentDateTime();
cacheItem.totalCount = totalCount; if (!m_searchCache.contains(searchKey))
cacheItem.timestamp = QDateTime::currentDateTime(); m_searchCache.insert(searchKey, {});
m_searchCache[searchKey].insert({offset, limit}, ci);
// If this is the first query for this search text, create a new map if (m_searchCache.size() > MAX_CACHE_SIZE)
if (!m_searchCache.contains(searchText)) { m_searchCache.remove(m_searchCache.firstKey());
m_searchCache.insert(searchText, QMap<QPair<int, int>, SearchCacheItem>());
} }
// Store results for this specific offset/limit combination // Emit only if this search is still current
QPair<int, int> 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
QMutexLocker searchLocker(&m_searchMutex); QMutexLocker searchLocker(&m_searchMutex);
if (!m_searchCancelled && m_currentSearchText == searchText) { if (!m_searchCancelled && m_currentSearchKey == searchKey) {
searchLocker.unlock(); 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 // Get current time
QDateTime now = QDateTime::currentDateTime(); QDateTime now = QDateTime::currentDateTime();
// Expire old search cache items
QMutableMapIterator<QString, QMap<QPair<int, int>, SearchCacheItem>> i(m_searchCache); QMutableMapIterator<QString, QMap<QPair<int, int>, SearchCacheItem>> i(m_searchCache);
while (i.hasNext()) { while (i.hasNext()) {
i.next(); i.next();
+27 -38
View File
@@ -13,6 +13,7 @@
#include <QMap> #include <QMap>
#include <QMutex> #include <QMutex>
#include <QWaitCondition> #include <QWaitCondition>
#include <QDate>
#include <QtConcurrent> #include <QtConcurrent>
#include <QFuture> #include <QFuture>
@@ -28,13 +29,17 @@ class DatabaseManager : public QObject
Q_OBJECT Q_OBJECT
public: public:
/** enum class SortOrder {
* @brief Struct to represent an image item with its path and OCR text 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 { struct ImageItem {
QString filePath; QString filePath;
QString ocrText; 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 * @param limit Maximum number of items to return, 0 means no limit
* @return List of ImageItem objects * @return List of ImageItem objects
*/ */
QList<ImageItem> getAllImages(int offset = 0, int limit = 0); QList<ImageItem> 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(); int getImageCount();
public slots: public slots:
/** void searchImages(const QString &searchText, int offset = 0, int limit = 0,
* @brief Search for images matching the search text SortOrder sort = SortOrder::DateNewest,
* @param searchText Text to search for in OCR results const QDate &from = QDate(),
* @param offset Starting position (0-based) for pagination const QDate &to = QDate());
* @param limit Maximum number of items to return, 0 means no limit
*/
void searchImages(const QString &searchText, int offset = 0, int limit = 0);
/**
* @brief Cancel any ongoing search operations
*/
void cancelSearch(); void cancelSearch();
signals: signals:
/** void searchResultsReady(const QList<DatabaseManager::ImageItem> &results,
* @brief Signal emitted when search results are available const QString &searchText,
* @param results The list of image items matching the search int offset, int limit, int totalCount,
* @param searchText The original search text that produced these results DatabaseManager::SortOrder sort,
* @param offset The offset used for this result set const QDate &from, const QDate &to);
* @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<DatabaseManager::ImageItem> &results, const QString &searchText,
int offset, int limit, int totalCount);
/**
* @brief Signal emitted when search operation starts
* @param searchText The search text being processed
*/
void searchStarted(const QString &searchText); void searchStarted(const QString &searchText);
private: private:
@@ -125,15 +114,15 @@ private:
QMap<QString, QMap<QPair<int, int>, SearchCacheItem>> m_searchCache; // searchText -> {(offset, limit) -> results} QMap<QString, QMap<QPair<int, int>, SearchCacheItem>> m_searchCache; // searchText -> {(offset, limit) -> results}
QMutex m_cacheMutex; // Mutex to protect cache access from multiple threads QMutex m_cacheMutex; // Mutex to protect cache access from multiple threads
// All images cache with pagination // All images cache — key encodes sort+date+offset+limit
QMap<QPair<int, int>, QList<ImageItem>> m_allImagesCache; // (offset, limit) -> results QMap<QString, QList<ImageItem>> m_allImagesCache;
int m_cachedImageCount; // Total image count cache int m_cachedImageCount; // Total image count cache
QDateTime m_lastCacheUpdate; // When the cache was last updated QDateTime m_lastCacheUpdate; // When the cache was last updated
// Search thread management // Search thread management
QFuture<void> m_searchFuture; QFuture<void> m_searchFuture;
QFutureWatcher<void> m_searchWatcher; QFutureWatcher<void> m_searchWatcher;
QString m_currentSearchText; QString m_currentSearchKey; // encoded: text|sort|from|to
int m_currentOffset; int m_currentOffset;
int m_currentLimit; int m_currentLimit;
bool m_searchCancelled; bool m_searchCancelled;
@@ -152,8 +141,8 @@ private:
// Prepare FTS query with proper syntax // Prepare FTS query with proper syntax
QString prepareFTSQuery(const QString &searchText); 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 // Clear expired cache items
void cleanupCache(); void cleanupCache();
+55 -17
View File
@@ -10,6 +10,9 @@
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QTimer> #include <QTimer>
#include <QScrollBar> #include <QScrollBar>
#include <QStandardPaths>
#include <QCryptographicHash>
#include <QDir>
@@ -156,6 +159,7 @@ void ImageGallery::setDatabaseManager(DatabaseManager *dbManager)
if (m_dbManager) { if (m_dbManager) {
connect(m_dbManager, &DatabaseManager::searchResultsReady, connect(m_dbManager, &DatabaseManager::searchResultsReady,
this, &ImageGallery::handleSearchResults); this, &ImageGallery::handleSearchResults);
connect(m_dbManager, &DatabaseManager::searchStarted, connect(m_dbManager, &DatabaseManager::searchStarted,
this, &ImageGallery::handleSearchStarted); this, &ImageGallery::handleSearchStarted);
} }
@@ -314,17 +318,19 @@ void ImageGallery::handleSearchTextChanged(const QString &searchText)
m_loadingIndicator->show(); m_loadingIndicator->show();
m_gridLayout->addWidget(m_loadingIndicator, 0, 0, 1, m_columnsCount); 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<DatabaseManager::ImageItem> &results, void ImageGallery::handleSearchResults(const QList<DatabaseManager::ImageItem> &results,
const QString &searchText, int offset, int limit, const QString &searchText, int offset, int limit,
int totalCount) int totalCount, DatabaseManager::SortOrder sort,
const QDate &from, const QDate &to)
{ {
// Only update display if this is the result for the most recent search // Discard stale results from a superseded search / sort / filter
if (searchText == m_lastSearchQuery) { if (searchText == m_lastSearchQuery && sort == m_sortOrder
&& from == m_fromDate && to == m_toDate) {
m_isLoading = false; m_isLoading = false;
m_totalCount = totalCount; m_totalCount = totalCount;
@@ -416,8 +422,8 @@ void ImageGallery::loadMoreImages()
int row = (m_thumbnails.size() / m_columnsCount); int row = (m_thumbnails.size() / m_columnsCount);
m_gridLayout->addWidget(m_loadingIndicator, row + 1, 0, 1, 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 // Handle scroll to bottom event
@@ -535,29 +541,61 @@ void ImageGallery::handleThumbnailClicked(const QString &filePath)
QApplication::restoreOverrideCursor(); 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) 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<ImageThumbnail> ptr(label); QPointer<ImageThumbnail> ptr(label);
QThreadPool::globalInstance()->start([this, ptr, filePath]() { QThreadPool::globalInstance()->start([this, ptr, filePath]() {
QImage image; // 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); QFileInfo fi(filePath);
if (fi.exists() && fi.isReadable()) { if (fi.exists() && fi.isReadable()) {
image = QImage(filePath); image = QImage(filePath);
if (!image.isNull()) if (!image.isNull()) {
image = image.scaled(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT, image = image.scaled(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT,
Qt::KeepAspectRatio, Qt::SmoothTransformation); Qt::KeepAspectRatio, Qt::SmoothTransformation);
image.save(cacheFile, "PNG");
}
}
} }
// 3. Marshal QPixmap creation back to the main thread
QMetaObject::invokeMethod(this, [ptr, image]() { QMetaObject::invokeMethod(this, [ptr, image]() {
if (!ptr) return; if (!ptr || image.isNull()) return;
if (image.isNull()) {
// File missing or unreadable — placeholder already shown, nothing to do
return;
}
ptr->setPixmap(QPixmap::fromImage(image)); ptr->setPixmap(QPixmap::fromImage(image));
}, Qt::QueuedConnection); }, Qt::QueuedConnection);
}); });
+13 -4
View File
@@ -14,6 +14,7 @@
#include <QPushButton> #include <QPushButton>
#include <QPointer> #include <QPointer>
#include <QThreadPool> #include <QThreadPool>
#include <QDate>
#include "databasemanager.h" #include "databasemanager.h"
// Global constants // Global constants
@@ -63,10 +64,15 @@ public slots:
void handleThumbnailClicked(const QString &filePath); void handleThumbnailClicked(const QString &filePath);
void handleContainerResized(); // New slot to handle resize events void handleContainerResized(); // New slot to handle resize events
void updateGridLayout(); // Adjusts grid based on current window size void updateGridLayout(); // Adjusts grid based on current window size
void handleSearchResults(const QList<DatabaseManager::ImageItem> &results, const QString &searchText, void handleSearchResults(const QList<DatabaseManager::ImageItem> &results,
int offset, int limit, int totalCount); const QString &searchText, int offset, int limit, int totalCount,
DatabaseManager::SortOrder sort, const QDate &from, const QDate &to);
void handleSearchStarted(const QString &searchText); 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 handleScrolledToBottom();
void handleLoadMoreClicked(); void handleLoadMoreClicked();
@@ -94,7 +100,10 @@ private:
void loadThumbnailAsync(ImageThumbnail *label, const QString &filePath); void loadThumbnailAsync(ImageThumbnail *label, const QString &filePath);
QPixmap createPlaceholderThumbnail(int width, int height, const QString &message); 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 // Track scroll position
int m_lastScrollPosition; int m_lastScrollPosition;
+99 -12
View File
@@ -94,7 +94,8 @@ MainWindow::MainWindow(QWidget *parent)
connect(m_dbManager, &DatabaseManager::searchResultsReady, connect(m_dbManager, &DatabaseManager::searchResultsReady,
this, [this](const QList<DatabaseManager::ImageItem> &results, const QString &text, this, [this](const QList<DatabaseManager::ImageItem> &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()) { if (text.isEmpty()) {
statusBar()->showMessage(tr("Showing all images (paginated) - %1 total").arg(totalCount)); statusBar()->showMessage(tr("Showing all images (paginated) - %1 total").arg(totalCount));
} else { } else {
@@ -124,6 +125,16 @@ MainWindow::~MainWindow()
// All QObjects will be automatically deleted via parent-child relationship // 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() void MainWindow::createLayout()
{ {
// Create central widget // Create central widget
@@ -187,12 +198,56 @@ void MainWindow::createLayout()
m_imageGallery = new ImageGallery(this); m_imageGallery = new ImageGallery(this);
m_imageGallery->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); 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 // Add widgets to layout
m_mainLayout->addWidget(m_searchBar); m_mainLayout->addWidget(m_searchBar);
m_mainLayout->addLayout(filterLayout);
m_mainLayout->addWidget(m_imageGallery, 1); 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_searchBar, &QLineEdit::textEdited, this, &MainWindow::handleSearchTextChanged);
connect(m_sortCombo, QOverload<int>::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() void MainWindow::initializeDatabase()
@@ -248,24 +303,24 @@ void MainWindow::displayAllImages()
return; return;
} }
// Get total image count first DatabaseManager::SortOrder sort = sortFromIndex(m_sortCombo->currentIndex());
int totalImageCount = m_dbManager->getImageCount(); 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();
QList<DatabaseManager::ImageItem> allImages = m_dbManager->getAllImages(0, m_imagePreloadCount, sort, from, to);
// Get the first page of images with pagination
QList<DatabaseManager::ImageItem> allImages = m_dbManager->getAllImages(0, m_imagePreloadCount);
if (allImages.isEmpty()) { if (allImages.isEmpty()) {
statusBar()->showMessage(tr("No images found in database"), 5000); statusBar()->showMessage(tr("No images found in database"), 5000);
} else { } else {
statusBar()->showMessage(tr("Loaded %1 of %2 images").arg(allImages.size()).arg(totalImageCount), 3000); 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); m_imageGallery->displayImages(allImages, true);
m_imageGallery->handleSearchResults(allImages, "", 0, m_imagePreloadCount, totalImageCount, sort, from, to);
// 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);
updateStatusBar(); 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 // File menu action handlers
void MainWindow::handleOpenFile() void MainWindow::handleOpenFile()
{ {
+11
View File
@@ -4,10 +4,14 @@
#include <QMainWindow> #include <QMainWindow>
#include <QLineEdit> #include <QLineEdit>
#include <QVBoxLayout> #include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLabel> #include <QLabel>
#include <QStatusBar> #include <QStatusBar>
#include <QTimer> #include <QTimer>
#include <QDebug> #include <QDebug>
#include <QComboBox>
#include <QDateEdit>
#include <QPushButton>
#include "databasemanager.h" #include "databasemanager.h"
#include "imagegallery.h" #include "imagegallery.h"
#include "updatedatabasedialog.h" #include "updatedatabasedialog.h"
@@ -25,6 +29,9 @@ protected:
private slots: private slots:
void handleSearchTextChanged(); void handleSearchTextChanged();
void handleSortChanged(int index);
void handleDateFilterChanged();
void handleClearDates();
void performSearch(); void performSearch();
void updateStatusBar(); void updateStatusBar();
void handleTypingInactivityTimeout(); void handleTypingInactivityTimeout();
@@ -44,6 +51,10 @@ private:
QWidget *m_centralWidget; QWidget *m_centralWidget;
QVBoxLayout *m_mainLayout; QVBoxLayout *m_mainLayout;
QLineEdit *m_searchBar; QLineEdit *m_searchBar;
QComboBox *m_sortCombo;
QDateEdit *m_fromDateEdit;
QDateEdit *m_toDateEdit;
QPushButton *m_clearDatesBtn;
ImageGallery *m_imageGallery; ImageGallery *m_imageGallery;
// Data // Data