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:
@@ -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".
|
||||||
|
|||||||
+153
-164
@@ -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)
|
||||||
QMutexLocker cacheLocker(&m_cacheMutex);
|
+ "|" + QString::number(offset)
|
||||||
if (m_allImagesCache.contains(cacheKey)) {
|
+ "|" + QString::number(limit);
|
||||||
// Use cached results if available and not expired
|
{
|
||||||
if (m_lastCacheUpdate.secsTo(QDateTime::currentDateTime()) < CACHE_LIFETIME_SECS) {
|
QMutexLocker cacheLocker(&m_cacheMutex);
|
||||||
|
if (m_allImagesCache.contains(cacheKey) &&
|
||||||
|
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);
|
||||||
// Create cache item with results and metadata
|
SearchCacheItem ci;
|
||||||
SearchCacheItem cacheItem;
|
ci.results = images;
|
||||||
cacheItem.results = images;
|
ci.totalCount = totalCount;
|
||||||
cacheItem.totalCount = totalCount;
|
ci.timestamp = QDateTime::currentDateTime();
|
||||||
cacheItem.timestamp = QDateTime::currentDateTime();
|
if (!m_searchCache.contains(searchKey))
|
||||||
|
m_searchCache.insert(searchKey, {});
|
||||||
// If this is the first query for this search text, create a new map
|
m_searchCache[searchKey].insert({offset, limit}, ci);
|
||||||
if (!m_searchCache.contains(searchText)) {
|
if (m_searchCache.size() > MAX_CACHE_SIZE)
|
||||||
m_searchCache.insert(searchText, QMap<QPair<int, int>, SearchCacheItem>());
|
m_searchCache.remove(m_searchCache.firstKey());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
@@ -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();
|
||||||
|
|||||||
+60
-22
@@ -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)
|
||||||
QFileInfo fi(filePath);
|
const QString cacheFile = thumbnailCacheDir() + thumbnailCacheKey(filePath);
|
||||||
if (fi.exists() && fi.isReadable()) {
|
QImage image(cacheFile);
|
||||||
image = QImage(filePath);
|
|
||||||
if (!image.isNull())
|
if (image.isNull()) {
|
||||||
image = image.scaled(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT,
|
// 2. Cache miss: load source, scale, write cache entry
|
||||||
Qt::KeepAspectRatio, Qt::SmoothTransformation);
|
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]() {
|
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
@@ -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
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user