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:
- 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<QString, CachedResult>` keyed by query string, 5-minute TTL
- `m_allImagesCache`: paginated all-images results
- `m_imageCountCache`: row count integer
- `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`: `QMap<QString, QList<ImageItem>>` — 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<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".
+166 -177
View File
@@ -6,12 +6,54 @@
#include <QFileInfo>
#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)
: 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::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;
if (!m_initialized) {
qDebug() << "Database not initialized.";
return images;
}
// Check cache first
QPair<int, int> 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<ImageItem>(), 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<ImageItem>(), 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<ImageItem> allImages = getAllImages(offset, limit);
QList<ImageItem> 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<int,int> pageKey(offset, limit);
{
QMutexLocker locker(&m_cacheMutex);
QPair<int, int> 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<ImageItem> 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<QPair<int, int>, 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<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
// 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<QString, QMap<QPair<int, int>, SearchCacheItem>> i(m_searchCache);
while (i.hasNext()) {
i.next();
+27 -38
View File
@@ -13,6 +13,7 @@
#include <QMap>
#include <QMutex>
#include <QWaitCondition>
#include <QDate>
#include <QtConcurrent>
#include <QFuture>
@@ -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<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();
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<DatabaseManager::ImageItem> &results, const QString &searchText,
int offset, int limit, int totalCount);
void searchResultsReady(const QList<DatabaseManager::ImageItem> &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<QString, QMap<QPair<int, int>, SearchCacheItem>> m_searchCache; // searchText -> {(offset, limit) -> results}
QMutex m_cacheMutex; // Mutex to protect cache access from multiple threads
// All images cache with pagination
QMap<QPair<int, int>, QList<ImageItem>> m_allImagesCache; // (offset, limit) -> results
// All images cache — key encodes sort+date+offset+limit
QMap<QString, QList<ImageItem>> m_allImagesCache;
int m_cachedImageCount; // Total image count cache
QDateTime m_lastCacheUpdate; // When the cache was last updated
// Search thread management
QFuture<void> m_searchFuture;
QFutureWatcher<void> 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();
+63 -25
View File
@@ -10,6 +10,9 @@
#include <QHBoxLayout>
#include <QTimer>
#include <QScrollBar>
#include <QStandardPaths>
#include <QCryptographicHash>
#include <QDir>
@@ -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<DatabaseManager::ImageItem> &results,
const QString &searchText, int offset, int limit,
int totalCount)
void ImageGallery::handleSearchResults(const QList<DatabaseManager::ImageItem> &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<ImageThumbnail> 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);
});
+13 -4
View File
@@ -14,6 +14,7 @@
#include <QPushButton>
#include <QPointer>
#include <QThreadPool>
#include <QDate>
#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<DatabaseManager::ImageItem> &results, const QString &searchText,
int offset, int limit, int totalCount);
void handleSearchResults(const QList<DatabaseManager::ImageItem> &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;
+103 -16
View File
@@ -94,7 +94,8 @@ MainWindow::MainWindow(QWidget *parent)
connect(m_dbManager, &DatabaseManager::searchResultsReady,
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()) {
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<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()
@@ -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<DatabaseManager::ImageItem> allImages = m_dbManager->getAllImages(0, m_imagePreloadCount);
QList<DatabaseManager::ImageItem> 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()
{
+11
View File
@@ -4,10 +4,14 @@
#include <QMainWindow>
#include <QLineEdit>
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QStatusBar>
#include <QTimer>
#include <QDebug>
#include <QComboBox>
#include <QDateEdit>
#include <QPushButton>
#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