helper scripts
This commit is contained in:
+28
-2
@@ -8,7 +8,12 @@ set(CMAKE_AUTOMOC ON)
|
|||||||
set(CMAKE_AUTORCC ON)
|
set(CMAKE_AUTORCC ON)
|
||||||
set(CMAKE_AUTOUIC ON)
|
set(CMAKE_AUTOUIC ON)
|
||||||
|
|
||||||
find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets Sql)
|
find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets Sql Concurrent)
|
||||||
|
|
||||||
|
# Add resources file
|
||||||
|
set(RESOURCE_FILES
|
||||||
|
resources.qrc
|
||||||
|
)
|
||||||
|
|
||||||
set(PROJECT_SOURCES
|
set(PROJECT_SOURCES
|
||||||
src/main.cpp
|
src/main.cpp
|
||||||
@@ -18,18 +23,39 @@ set(PROJECT_SOURCES
|
|||||||
src/imagegallery.h
|
src/imagegallery.h
|
||||||
src/databasemanager.cpp
|
src/databasemanager.cpp
|
||||||
src/databasemanager.h
|
src/databasemanager.h
|
||||||
|
src/settingsdialog.cpp
|
||||||
|
src/settingsdialog.h
|
||||||
)
|
)
|
||||||
|
|
||||||
add_executable(screenshot-gallery ${PROJECT_SOURCES})
|
add_executable(screenshot-gallery ${PROJECT_SOURCES} ${RESOURCE_FILES})
|
||||||
|
|
||||||
|
# Install icons to standard system locations
|
||||||
|
install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/icons/orcs-gallery-64.png"
|
||||||
|
DESTINATION "${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/64x64/apps"
|
||||||
|
RENAME "orcs-gallery.png")
|
||||||
|
install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/icons/orcs-gallery-128.png"
|
||||||
|
DESTINATION "${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/128x128/apps"
|
||||||
|
RENAME "orcs-gallery.png")
|
||||||
|
install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/icons/orcs-gallery-256.png"
|
||||||
|
DESTINATION "${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/256x256/apps"
|
||||||
|
RENAME "orcs-gallery.png")
|
||||||
|
install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/icons/orcs-gallery-512.png"
|
||||||
|
DESTINATION "${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/512x512/apps"
|
||||||
|
RENAME "orcs-gallery.png")
|
||||||
|
|
||||||
target_link_libraries(screenshot-gallery PRIVATE
|
target_link_libraries(screenshot-gallery PRIVATE
|
||||||
Qt6::Core
|
Qt6::Core
|
||||||
Qt6::Gui
|
Qt6::Gui
|
||||||
Qt6::Widgets
|
Qt6::Widgets
|
||||||
Qt6::Sql
|
Qt6::Sql
|
||||||
|
Qt6::Concurrent
|
||||||
)
|
)
|
||||||
|
|
||||||
install(TARGETS screenshot-gallery
|
install(TARGETS screenshot-gallery
|
||||||
BUNDLE DESTINATION .
|
BUNDLE DESTINATION .
|
||||||
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
|
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Install desktop file
|
||||||
|
install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/screenshot-gallery.desktop"
|
||||||
|
DESTINATION "${CMAKE_INSTALL_PREFIX}/share/applications")
|
||||||
|
|||||||
Executable
+97
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# direct_rofi_ocr.sh - Direct script to search OCR'd screenshots with rofi
|
||||||
|
# This script displays OCR data from SQLite and allows opening files with rofi
|
||||||
|
|
||||||
|
# Database path
|
||||||
|
DB_PATH="/home/master/screenshot_ocr.db"
|
||||||
|
|
||||||
|
# Check dependencies
|
||||||
|
check_deps() {
|
||||||
|
local missing=0
|
||||||
|
|
||||||
|
if ! command -v rofi &> /dev/null; then
|
||||||
|
echo "Error: rofi is not installed. Please install it with: sudo pacman -S rofi"
|
||||||
|
missing=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v sqlite3 &> /dev/null; then
|
||||||
|
echo "Error: sqlite3 is not installed. Please install it with: sudo pacman -S sqlite"
|
||||||
|
missing=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! [ -f "$DB_PATH" ]; then
|
||||||
|
echo "Error: Database not found at $DB_PATH"
|
||||||
|
echo "Please run the OCR script first to create and populate the database."
|
||||||
|
missing=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
return $missing
|
||||||
|
}
|
||||||
|
|
||||||
|
# Format OCR text (remove newlines, limit length)
|
||||||
|
format_text() {
|
||||||
|
local text="$1"
|
||||||
|
text=$(echo "$text" | tr '\n' ' ' | tr -s ' ')
|
||||||
|
|
||||||
|
if [ ${#text} -gt 80 ]; then
|
||||||
|
text="${text:0:80}..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$text"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main function
|
||||||
|
main() {
|
||||||
|
# Check dependencies
|
||||||
|
if ! check_deps; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create temporary file for mapping
|
||||||
|
TEMP_FILE=$(mktemp)
|
||||||
|
trap 'rm -f $TEMP_FILE' EXIT
|
||||||
|
|
||||||
|
# Extract data from database
|
||||||
|
echo "Querying database..."
|
||||||
|
sqlite3 "$DB_PATH" "SELECT filename, full_path, ocr_text FROM ocr_results" | \
|
||||||
|
while IFS='|' read -r filename path text; do
|
||||||
|
# Format the text for display
|
||||||
|
formatted=$(format_text "$text")
|
||||||
|
# Write to temp file: display_text|file_path
|
||||||
|
echo "$filename | $formatted|$path" >> "$TEMP_FILE"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Check if we got any results
|
||||||
|
if [ ! -s "$TEMP_FILE" ]; then
|
||||||
|
echo "No OCR data found in database. Run ocr_screenshots.py first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Display in rofi
|
||||||
|
echo "Opening rofi dialog..."
|
||||||
|
selection=$(cat "$TEMP_FILE" | cut -d'|' -f1-2 | rofi -dmenu -i -p "Screenshot OCR" -width 80)
|
||||||
|
|
||||||
|
# Check if user made a selection
|
||||||
|
if [ -z "$selection" ]; then
|
||||||
|
echo "No selection made."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Find the corresponding path
|
||||||
|
display_text=$(echo "$selection" | sed 's/|.*$//')
|
||||||
|
path=$(grep -F "$display_text" "$TEMP_FILE" | cut -d'|' -f3)
|
||||||
|
|
||||||
|
# Open the file
|
||||||
|
if [ -n "$path" ] && [ -f "$path" ]; then
|
||||||
|
echo "Opening: $path"
|
||||||
|
xdg-open "$path" &
|
||||||
|
else
|
||||||
|
echo "Error: Could not find file path for selection."
|
||||||
|
echo "Selected: $display_text"
|
||||||
|
echo "Path: $path"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run main function
|
||||||
|
main
|
||||||
Executable
+92
@@ -0,0 +1,92 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# ocr_rofi.sh - Search OCR'd screenshots with rofi
|
||||||
|
# This script displays OCR data from SQLite and allows opening files with rofi
|
||||||
|
|
||||||
|
# Database path
|
||||||
|
DB_PATH="$HOME/screenshot_ocr.db"
|
||||||
|
SCREENSHOTS_DIR="$HOME/Screenshots"
|
||||||
|
MAX_TEXT_LENGTH=100
|
||||||
|
|
||||||
|
# Check dependencies
|
||||||
|
if ! command -v rofi &> /dev/null; then
|
||||||
|
echo "Error: rofi is not installed. Please install it with: sudo pacman -S rofi"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v sqlite3 &> /dev/null; then
|
||||||
|
echo "Error: sqlite3 is not installed. Please install it with: sudo pacman -S sqlite"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$DB_PATH" ]; then
|
||||||
|
echo "Error: Database not found at $DB_PATH"
|
||||||
|
echo "Run the OCR script first to create and populate the database."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create temporary files
|
||||||
|
ENTRIES_FILE=$(mktemp)
|
||||||
|
PATHS_FILE=$(mktemp)
|
||||||
|
trap "rm -f $ENTRIES_FILE $PATHS_FILE" EXIT
|
||||||
|
|
||||||
|
# Query the database and format for rofi
|
||||||
|
echo "Preparing OCR data for search..."
|
||||||
|
sqlite3 -separator "|" "$DB_PATH" "SELECT filename, ocr_text, full_path FROM ocr_results ORDER BY filename" | while IFS="|" read -r filename ocr_text path; do
|
||||||
|
# Clean up text (remove newlines, limit length)
|
||||||
|
clean_text=$(echo "$ocr_text" | tr '\n' ' ' | tr -s ' ')
|
||||||
|
if [ ${#clean_text} -gt $MAX_TEXT_LENGTH ]; then
|
||||||
|
clean_text="${clean_text:0:$MAX_TEXT_LENGTH}..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Save formatted entry for rofi
|
||||||
|
echo "$filename | $clean_text" >> "$ENTRIES_FILE"
|
||||||
|
|
||||||
|
# Save path in corresponding line
|
||||||
|
echo "$path" >> "$PATHS_FILE"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Count entries
|
||||||
|
entry_count=$(wc -l < "$ENTRIES_FILE")
|
||||||
|
if [ "$entry_count" -eq 0 ]; then
|
||||||
|
echo "No OCR data found in database. Run ocr_screenshots.py first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Found $entry_count screenshots with OCR data."
|
||||||
|
|
||||||
|
# Display rofi menu
|
||||||
|
selected_line=$(cat "$ENTRIES_FILE" | rofi -dmenu -i -p "Screenshot OCR" \
|
||||||
|
-width 80 -lines 15 -font "mono 10")
|
||||||
|
|
||||||
|
# Exit if no selection
|
||||||
|
if [ -z "$selected_line" ]; then
|
||||||
|
echo "No selection made."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get filename from selection
|
||||||
|
selected_filename=$(echo "$selected_line" | cut -d '|' -f 1 | sed 's/ *$//')
|
||||||
|
|
||||||
|
# Find corresponding line number
|
||||||
|
line_num=1
|
||||||
|
while IFS= read -r line; do
|
||||||
|
if [[ "$line" == "$selected_filename"* ]]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
line_num=$((line_num + 1))
|
||||||
|
done < "$ENTRIES_FILE"
|
||||||
|
|
||||||
|
# Get full path from paths file
|
||||||
|
selected_path=$(sed "${line_num}q;d" "$PATHS_FILE")
|
||||||
|
|
||||||
|
# Open the file
|
||||||
|
if [ -n "$selected_path" ] && [ -f "$selected_path" ]; then
|
||||||
|
echo "Opening: $selected_path"
|
||||||
|
xdg-open "$selected_path" &
|
||||||
|
else
|
||||||
|
echo "Error: Could not find file: $selected_path"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
Executable
+169
@@ -0,0 +1,169 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
SCREENSHOTS_DIR = os.path.expanduser("~/Screenshots")
|
||||||
|
DATABASE_PATH = os.path.expanduser("~/screenshot_ocr.db")
|
||||||
|
|
||||||
|
|
||||||
|
def create_database():
|
||||||
|
"""Create SQLite database and table if they don't exist."""
|
||||||
|
conn = sqlite3.connect(DATABASE_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Create table for OCR results
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS ocr_results (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
filename TEXT UNIQUE,
|
||||||
|
full_path TEXT,
|
||||||
|
ocr_text TEXT,
|
||||||
|
file_size INTEGER,
|
||||||
|
created_date TEXT,
|
||||||
|
ocr_date TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print(f"Database initialized at {DATABASE_PATH}")
|
||||||
|
|
||||||
|
|
||||||
|
def get_processed_files():
|
||||||
|
"""Get a set of filenames that have already been processed."""
|
||||||
|
conn = sqlite3.connect(DATABASE_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("SELECT filename FROM ocr_results")
|
||||||
|
processed_files = {row[0] for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return processed_files
|
||||||
|
|
||||||
|
|
||||||
|
def perform_ocr(image_path):
|
||||||
|
"""Perform OCR on an image file using tesseract."""
|
||||||
|
try:
|
||||||
|
# Create a temporary output file
|
||||||
|
temp_output = f"/tmp/{os.path.basename(image_path)}.txt"
|
||||||
|
temp_base = temp_output.replace(".txt", "")
|
||||||
|
|
||||||
|
# Run tesseract
|
||||||
|
subprocess.run(
|
||||||
|
["tesseract", image_path, temp_base],
|
||||||
|
check=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Read OCR text from the output file
|
||||||
|
with open(temp_output, "r", encoding="utf-8") as f:
|
||||||
|
ocr_text = f.read().strip()
|
||||||
|
|
||||||
|
# Clean up temporary file
|
||||||
|
os.remove(temp_output)
|
||||||
|
|
||||||
|
return ocr_text
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"Error running tesseract on {image_path}: {str(e)}")
|
||||||
|
return ""
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error processing {image_path}: {str(e)}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def add_to_database(filename, full_path, ocr_text, file_size, created_date):
|
||||||
|
"""Add OCR result to the database."""
|
||||||
|
conn = sqlite3.connect(DATABASE_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO ocr_results
|
||||||
|
(filename, full_path, ocr_text, file_size, created_date, ocr_date)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
filename,
|
||||||
|
full_path,
|
||||||
|
ocr_text,
|
||||||
|
file_size,
|
||||||
|
created_date,
|
||||||
|
datetime.now().isoformat(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print(f"Added {filename} to database")
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
print(f"File {filename} already exists in database")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error adding {filename} to database: {str(e)}")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main function to process screenshot images."""
|
||||||
|
print("Starting OCR process for screenshots...")
|
||||||
|
|
||||||
|
# Create database if it doesn't exist
|
||||||
|
create_database()
|
||||||
|
|
||||||
|
# Get list of already processed files
|
||||||
|
processed_files = get_processed_files()
|
||||||
|
print(f"Found {len(processed_files)} already processed files")
|
||||||
|
|
||||||
|
# Get list of PNG and JPG files
|
||||||
|
image_files = glob.glob(os.path.join(SCREENSHOTS_DIR, "*.png"))
|
||||||
|
image_files.extend(glob.glob(os.path.join(SCREENSHOTS_DIR, "*.jpg")))
|
||||||
|
image_files.extend(glob.glob(os.path.join(SCREENSHOTS_DIR, "*.jpeg")))
|
||||||
|
print(f"Found {len(image_files)} image files")
|
||||||
|
|
||||||
|
# Process each image file
|
||||||
|
processed_count = 0
|
||||||
|
skipped_count = 0
|
||||||
|
error_count = 0
|
||||||
|
|
||||||
|
for image_path in image_files:
|
||||||
|
filename = os.path.basename(image_path)
|
||||||
|
|
||||||
|
# Skip if already processed
|
||||||
|
if filename in processed_files:
|
||||||
|
print(f"Skipping {filename} (already processed)")
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"Processing {filename}...")
|
||||||
|
|
||||||
|
# Get file information
|
||||||
|
file_stats = os.stat(image_path)
|
||||||
|
file_size = file_stats.st_size
|
||||||
|
created_date = datetime.fromtimestamp(file_stats.st_mtime).isoformat()
|
||||||
|
|
||||||
|
# Perform OCR
|
||||||
|
ocr_text = perform_ocr(image_path)
|
||||||
|
|
||||||
|
if ocr_text:
|
||||||
|
# Add to database
|
||||||
|
add_to_database(filename, image_path, ocr_text, file_size, created_date)
|
||||||
|
processed_count += 1
|
||||||
|
else:
|
||||||
|
print(f"No OCR text extracted from {filename}")
|
||||||
|
error_count += 1
|
||||||
|
|
||||||
|
print("\nOCR process completed:")
|
||||||
|
print(f"- Processed: {processed_count}")
|
||||||
|
print(f"- Skipped (already in database): {skipped_count}")
|
||||||
|
print(f"- Errors: {error_count}")
|
||||||
|
print(f"- Total files in database: {len(processed_files) + processed_count}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+58
@@ -0,0 +1,58 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# OCR screenshot images and store results in SQLite database
|
||||||
|
# This script is a wrapper for the ocr_screenshots.py Python script
|
||||||
|
|
||||||
|
# Set up variables
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PYTHON_SCRIPT="${SCRIPT_DIR}/ocr_screenshots.py"
|
||||||
|
DATABASE_PATH="$HOME/screenshot_ocr.db"
|
||||||
|
SCREENSHOTS_DIR="$HOME/Screenshots"
|
||||||
|
|
||||||
|
# Check if Python script exists
|
||||||
|
if [ ! -f "$PYTHON_SCRIPT" ]; then
|
||||||
|
echo "Error: Python script not found at $PYTHON_SCRIPT"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if tesseract is installed
|
||||||
|
if ! command -v tesseract &> /dev/null; then
|
||||||
|
echo "Error: tesseract not installed. Please install it with:"
|
||||||
|
echo " sudo pacman -S tesseract tesseract-data-eng"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if SQLite is installed
|
||||||
|
if ! command -v sqlite3 &> /dev/null; then
|
||||||
|
echo "Error: sqlite3 not installed. Please install it with:"
|
||||||
|
echo " sudo pacman -S sqlite"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if screenshots directory exists
|
||||||
|
if [ ! -d "$SCREENSHOTS_DIR" ]; then
|
||||||
|
echo "Error: Screenshots directory not found at $SCREENSHOTS_DIR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting OCR process for screenshots..."
|
||||||
|
echo "Database path: $DATABASE_PATH"
|
||||||
|
echo "Screenshots directory: $SCREENSHOTS_DIR"
|
||||||
|
|
||||||
|
# Run the Python script
|
||||||
|
python "$PYTHON_SCRIPT"
|
||||||
|
|
||||||
|
# Check return code
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "Error: OCR process failed"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
# Count entries in database
|
||||||
|
if [ -f "$DATABASE_PATH" ]; then
|
||||||
|
count=$(sqlite3 "$DATABASE_PATH" "SELECT COUNT(*) FROM ocr_results")
|
||||||
|
echo "Database contains $count entries"
|
||||||
|
fi
|
||||||
|
echo "OCR process completed successfully"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
Executable
+179
@@ -0,0 +1,179 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
DB_PATH = os.path.expanduser("~/screenshot_ocr.db")
|
||||||
|
SCREENSHOTS_DIR = os.path.expanduser("~/Screenshots")
|
||||||
|
ROFI_PROMPT = "OCR Search"
|
||||||
|
|
||||||
|
|
||||||
|
def check_dependencies():
|
||||||
|
"""Check if required dependencies are installed."""
|
||||||
|
# Check if rofi is installed
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["which", "rofi"],
|
||||||
|
check=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
print(
|
||||||
|
"Error: rofi is not installed. Please install it with: sudo pacman -S rofi"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if database exists
|
||||||
|
if not os.path.exists(DB_PATH):
|
||||||
|
print(f"Error: Database not found at {DB_PATH}")
|
||||||
|
print("Run the OCR script first to create and populate the database.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def format_text(text, max_length=100):
|
||||||
|
"""Format OCR text for display in rofi."""
|
||||||
|
# Replace newlines with spaces
|
||||||
|
text = text.replace("\n", " ")
|
||||||
|
|
||||||
|
# Replace multiple spaces with a single space
|
||||||
|
while " " in text:
|
||||||
|
text = text.replace(" ", " ")
|
||||||
|
|
||||||
|
# Truncate if too long
|
||||||
|
if len(text) > max_length:
|
||||||
|
text = text[:max_length] + "..."
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def get_screenshot_data():
|
||||||
|
"""Get data from the SQLite database."""
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Get all records
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT filename, ocr_text, full_path FROM ocr_results ORDER BY filename"
|
||||||
|
)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for filename, ocr_text, full_path in cursor.fetchall():
|
||||||
|
formatted_text = format_text(ocr_text)
|
||||||
|
# Format for display: filename | ocr_text
|
||||||
|
display_text = f"{filename} | {formatted_text}"
|
||||||
|
results.append((display_text, full_path))
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return results
|
||||||
|
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
print(f"Database error: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def show_rofi_menu(items):
|
||||||
|
"""Display rofi menu with the given items and return selection."""
|
||||||
|
if not items:
|
||||||
|
print("No items to display.")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
# Create temporary file with menu items
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w+", delete=False) as temp_file:
|
||||||
|
for display_text, _ in items:
|
||||||
|
temp_file.write(f"{display_text}\n")
|
||||||
|
temp_file_path = temp_file.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Run rofi command
|
||||||
|
cmd = [
|
||||||
|
"rofi",
|
||||||
|
"-dmenu",
|
||||||
|
"-i", # Case-insensitive matching
|
||||||
|
"-p",
|
||||||
|
ROFI_PROMPT,
|
||||||
|
"-width",
|
||||||
|
"80",
|
||||||
|
"-lines",
|
||||||
|
"15",
|
||||||
|
"-font",
|
||||||
|
"mono 10",
|
||||||
|
]
|
||||||
|
|
||||||
|
process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdin=open(temp_file_path, "r"),
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
universal_newlines=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
stdout, stderr = process.communicate()
|
||||||
|
|
||||||
|
if process.returncode != 0:
|
||||||
|
print(f"Rofi error: {stderr}")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
selection = stdout.strip()
|
||||||
|
if not selection:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
# Find the matching item
|
||||||
|
for i, (display_text, full_path) in enumerate(items):
|
||||||
|
if display_text == selection:
|
||||||
|
return display_text, full_path
|
||||||
|
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Clean up temporary file
|
||||||
|
os.unlink(temp_file_path)
|
||||||
|
|
||||||
|
|
||||||
|
def open_file(file_path):
|
||||||
|
"""Open the selected file with the default application."""
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
try:
|
||||||
|
subprocess.Popen(
|
||||||
|
["xdg-open", file_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||||
|
)
|
||||||
|
print(f"Opening: {file_path}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error opening file: {e}")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
print(f"Error: File not found: {file_path}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main function."""
|
||||||
|
if not check_dependencies():
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Get screenshot data
|
||||||
|
items = get_screenshot_data()
|
||||||
|
if not items:
|
||||||
|
print("No OCR data found in the database.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Show rofi menu
|
||||||
|
selection, file_path = show_rofi_menu(items)
|
||||||
|
|
||||||
|
# Open selected file
|
||||||
|
if selection and file_path:
|
||||||
|
open_file(file_path)
|
||||||
|
else:
|
||||||
|
print("No selection made.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+129
@@ -0,0 +1,129 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# rofi_ocr_search.sh - Search and open OCR'd screenshot files using rofi
|
||||||
|
# This script displays OCR'd screenshot data in rofi and allows opening selected files
|
||||||
|
|
||||||
|
# Constants
|
||||||
|
DATABASE_PATH="$HOME/screenshot_ocr.db"
|
||||||
|
SCREENSHOTS_DIR="$HOME/Screenshots"
|
||||||
|
ROFI_PROMPT="Screenshot OCR"
|
||||||
|
MAX_DISPLAY_LENGTH=100
|
||||||
|
|
||||||
|
# Check if required programs are installed
|
||||||
|
check_dependencies() {
|
||||||
|
local missing=0
|
||||||
|
|
||||||
|
if ! command -v rofi >/dev/null 2>&1; then
|
||||||
|
echo "Error: rofi is not installed. Please install it with:"
|
||||||
|
echo " sudo pacman -S rofi"
|
||||||
|
missing=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v sqlite3 >/dev/null 2>&1; then
|
||||||
|
echo "Error: sqlite3 is not installed. Please install it with:"
|
||||||
|
echo " sudo pacman -S sqlite"
|
||||||
|
missing=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v xdg-open >/dev/null 2>&1; then
|
||||||
|
echo "Error: xdg-open is not installed. Please install it with:"
|
||||||
|
echo " sudo pacman -S xdg-utils"
|
||||||
|
missing=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$DATABASE_PATH" ]; then
|
||||||
|
echo "Error: Database not found at $DATABASE_PATH"
|
||||||
|
echo "Run the OCR script first to create and populate the database."
|
||||||
|
missing=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $missing -eq 1 ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Format OCR text for display in rofi
|
||||||
|
format_ocr_text() {
|
||||||
|
local text="$1"
|
||||||
|
|
||||||
|
# Replace newlines with spaces
|
||||||
|
text="${text//$'\n'/ }"
|
||||||
|
|
||||||
|
# Remove multiple spaces
|
||||||
|
text=$(echo "$text" | tr -s ' ')
|
||||||
|
|
||||||
|
# Truncate if too long
|
||||||
|
if [ ${#text} -gt $MAX_DISPLAY_LENGTH ]; then
|
||||||
|
text="${text:0:$MAX_DISPLAY_LENGTH}..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$text"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get entries from database and format for rofi
|
||||||
|
get_entries_for_rofi() {
|
||||||
|
sqlite3 -separator '|' "$DATABASE_PATH" "
|
||||||
|
SELECT filename, ocr_text, full_path
|
||||||
|
FROM ocr_results
|
||||||
|
ORDER BY filename
|
||||||
|
" | while IFS='|' read -r filename ocr_text path; do
|
||||||
|
formatted_text=$(format_ocr_text "$ocr_text")
|
||||||
|
echo "$filename | $formatted_text"
|
||||||
|
echo "$path" >> /tmp/ocr_paths.$$
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main function
|
||||||
|
main() {
|
||||||
|
check_dependencies
|
||||||
|
|
||||||
|
# Create temporary file for paths
|
||||||
|
rm -f /tmp/ocr_paths.$$ 2>/dev/null
|
||||||
|
touch /tmp/ocr_paths.$$
|
||||||
|
|
||||||
|
# Get all entries and display in rofi
|
||||||
|
selection=$(get_entries_for_rofi | rofi -dmenu -i -p "$ROFI_PROMPT" \
|
||||||
|
-width 80 \
|
||||||
|
-lines 15 \
|
||||||
|
-font "mono 10" \
|
||||||
|
-matching fuzzy)
|
||||||
|
|
||||||
|
# Exit if no selection made
|
||||||
|
if [ -z "$selection" ]; then
|
||||||
|
rm -f /tmp/ocr_paths.$$ 2>/dev/null
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Extract filename from selection
|
||||||
|
filename=$(echo "$selection" | cut -d '|' -f1 | xargs)
|
||||||
|
|
||||||
|
# Find the corresponding line number
|
||||||
|
line_number=1
|
||||||
|
sqlite3 -separator '|' "$DATABASE_PATH" "
|
||||||
|
SELECT filename
|
||||||
|
FROM ocr_results
|
||||||
|
ORDER BY filename
|
||||||
|
" | while read -r db_filename; do
|
||||||
|
if [ "$db_filename" = "$filename" ]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
line_number=$((line_number + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
# Get the full path from our temporary file
|
||||||
|
file_path=$(sed -n "${line_number}p" /tmp/ocr_paths.$$)
|
||||||
|
|
||||||
|
# Open the file if path exists
|
||||||
|
if [ -n "$file_path" ] && [ -f "$file_path" ]; then
|
||||||
|
xdg-open "$file_path" &
|
||||||
|
echo "Opening: $file_path"
|
||||||
|
else
|
||||||
|
echo "Error: Could not find file path for $filename"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
rm -f /tmp/ocr_paths.$$ 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run the main function
|
||||||
|
main
|
||||||
Binary file not shown.
Executable
+247
@@ -0,0 +1,247 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Constants
|
||||||
|
DATABASE_PATH = os.path.expanduser("~/screenshot_ocr.db")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_arguments():
|
||||||
|
"""Parse command line arguments."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Search for text in OCR results from screenshots."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"query", help="Text to search for in the OCR results", nargs="*"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-i",
|
||||||
|
"--case-insensitive",
|
||||||
|
action="store_true",
|
||||||
|
help="Perform case-insensitive search",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-e",
|
||||||
|
"--exact",
|
||||||
|
action="store_true",
|
||||||
|
help="Match exact text only (default is substring match)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-l",
|
||||||
|
"--limit",
|
||||||
|
type=int,
|
||||||
|
default=25,
|
||||||
|
help="Limit number of results (default: 25, 0 for no limit)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-v",
|
||||||
|
"--verbose",
|
||||||
|
action="store_true",
|
||||||
|
help="Show more details including file path and date",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-c", "--count", action="store_true", help="Only show the count of matches"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--list-all", action="store_true", help="List all files in the database"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-d", "--date", action="store_true", help="Sort results by date (newest first)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-o",
|
||||||
|
"--open",
|
||||||
|
action="store_true",
|
||||||
|
help="Open the first matching file (requires xdg-open)",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def check_database():
|
||||||
|
"""Check if the database exists and has the expected structure."""
|
||||||
|
if not os.path.exists(DATABASE_PATH):
|
||||||
|
print(f"Error: Database not found at {DATABASE_PATH}")
|
||||||
|
print("Run the OCR script first to create and populate the database.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def search_ocr_database(args):
|
||||||
|
"""Search the OCR database for the given query."""
|
||||||
|
conn = sqlite3.connect(DATABASE_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Join all query words
|
||||||
|
query_text = " ".join(args.query)
|
||||||
|
|
||||||
|
if args.list_all:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT filename, ocr_date, created_date, length(ocr_text), full_path
|
||||||
|
FROM ocr_results
|
||||||
|
ORDER BY filename ASC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
results = cursor.fetchall()
|
||||||
|
conn.close()
|
||||||
|
return results
|
||||||
|
|
||||||
|
# Construct the SQL query based on arguments
|
||||||
|
if args.exact:
|
||||||
|
if args.case_insensitive:
|
||||||
|
sql_query = "SELECT * FROM ocr_results WHERE LOWER(ocr_text) = LOWER(?)"
|
||||||
|
else:
|
||||||
|
sql_query = "SELECT * FROM ocr_results WHERE ocr_text = ?"
|
||||||
|
else:
|
||||||
|
if args.case_insensitive:
|
||||||
|
sql_query = "SELECT * FROM ocr_results WHERE LOWER(ocr_text) LIKE LOWER(?)"
|
||||||
|
query_text = f"%{query_text}%"
|
||||||
|
else:
|
||||||
|
sql_query = "SELECT * FROM ocr_results WHERE ocr_text LIKE ?"
|
||||||
|
query_text = f"%{query_text}%"
|
||||||
|
|
||||||
|
# Add ordering
|
||||||
|
if args.date:
|
||||||
|
sql_query += " ORDER BY created_date DESC"
|
||||||
|
else:
|
||||||
|
sql_query += " ORDER BY filename ASC"
|
||||||
|
|
||||||
|
# Add limit
|
||||||
|
if args.limit > 0:
|
||||||
|
sql_query += f" LIMIT {args.limit}"
|
||||||
|
|
||||||
|
# Execute the query
|
||||||
|
cursor.execute(sql_query, (query_text,))
|
||||||
|
results = cursor.fetchall()
|
||||||
|
|
||||||
|
# Close the database connection
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def display_results(results, args):
|
||||||
|
"""Display the search results."""
|
||||||
|
if not results or len(results) == 0:
|
||||||
|
print("No matches found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.count:
|
||||||
|
print(f"Found {len(results)} matches.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"Found {len(results)} matches{' (showing first ' + str(args.limit) + ')' if args.limit > 0 and len(results) == args.limit else ''}:"
|
||||||
|
)
|
||||||
|
print("-" * 80)
|
||||||
|
|
||||||
|
# Handle list_all format differently
|
||||||
|
if args.list_all:
|
||||||
|
for filename, ocr_date, created_date, text_length, full_path in results:
|
||||||
|
created_str = (
|
||||||
|
created_date.split("T")[0] if "T" in created_date else created_date
|
||||||
|
)
|
||||||
|
if args.verbose:
|
||||||
|
print(f"File: {filename}")
|
||||||
|
print(f"Path: {full_path}")
|
||||||
|
print(f"Created: {created_date}")
|
||||||
|
print(f"OCR Date: {ocr_date}")
|
||||||
|
print(f"Text Length: {text_length} chars")
|
||||||
|
print("-" * 40)
|
||||||
|
else:
|
||||||
|
print(f"{filename} | {created_str} | {text_length} chars")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Display regular search results
|
||||||
|
for row in results:
|
||||||
|
id, filename, full_path, ocr_text, file_size, created_date, ocr_date = row
|
||||||
|
|
||||||
|
# Display the result
|
||||||
|
if args.verbose:
|
||||||
|
print(f"File: {filename}")
|
||||||
|
print(f"Path: {full_path}")
|
||||||
|
print(f"Created: {created_date}")
|
||||||
|
print(f"OCR Date: {ocr_date}")
|
||||||
|
print(f"Size: {file_size} bytes")
|
||||||
|
print("Text:")
|
||||||
|
print("-" * 40)
|
||||||
|
print(ocr_text)
|
||||||
|
print("-" * 80)
|
||||||
|
else:
|
||||||
|
print(f"[{filename}]")
|
||||||
|
# Show just a snippet of text around the match if not exact
|
||||||
|
if not args.exact and args.query:
|
||||||
|
search_term = " ".join(args.query).lower()
|
||||||
|
text_lower = ocr_text.lower()
|
||||||
|
pos = text_lower.find(search_term)
|
||||||
|
if pos >= 0:
|
||||||
|
start = max(0, pos - 40)
|
||||||
|
end = min(len(ocr_text), pos + len(search_term) + 40)
|
||||||
|
# Find word boundaries
|
||||||
|
if start > 0:
|
||||||
|
while start > 0 and ocr_text[start].isalnum():
|
||||||
|
start -= 1
|
||||||
|
if end < len(ocr_text):
|
||||||
|
while end < len(ocr_text) and ocr_text[end].isalnum():
|
||||||
|
end += 1
|
||||||
|
|
||||||
|
snippet = ocr_text[start:end]
|
||||||
|
if start > 0:
|
||||||
|
snippet = "..." + snippet
|
||||||
|
if end < len(ocr_text):
|
||||||
|
snippet = snippet + "..."
|
||||||
|
|
||||||
|
print(snippet)
|
||||||
|
else:
|
||||||
|
# If can't find the term (which is odd), just show first bit
|
||||||
|
print(ocr_text[:80] + "..." if len(ocr_text) > 80 else ocr_text)
|
||||||
|
else:
|
||||||
|
# Just show the first bit of text
|
||||||
|
print(ocr_text[:80] + "..." if len(ocr_text) > 80 else ocr_text)
|
||||||
|
print("-" * 40)
|
||||||
|
|
||||||
|
|
||||||
|
def open_file(path):
|
||||||
|
"""Open a file using the default application."""
|
||||||
|
try:
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
subprocess.Popen(
|
||||||
|
["xdg-open", path], stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||||
|
)
|
||||||
|
print(f"Opening: {path}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error opening file: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main function to run the OCR search."""
|
||||||
|
args = parse_arguments()
|
||||||
|
check_database()
|
||||||
|
|
||||||
|
if not args.list_all and len(args.query) == 0:
|
||||||
|
print("Error: Please provide a search query or use --list-all")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
results = search_ocr_database(args)
|
||||||
|
|
||||||
|
# Open the first matching file if requested
|
||||||
|
if args.open and results and len(results) > 0:
|
||||||
|
if args.list_all:
|
||||||
|
open_file(results[0][4]) # full_path is at index 4 for list_all
|
||||||
|
else:
|
||||||
|
open_file(results[0][2]) # full_path is at index 2 for regular search
|
||||||
|
|
||||||
|
# Always display results unless we're only opening
|
||||||
|
if not (args.open and not args.verbose):
|
||||||
|
display_results(results, args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+75
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# simple_rofi_ocr.sh - A simplified script to search OCR'd screenshots with rofi
|
||||||
|
|
||||||
|
# Database location
|
||||||
|
DB_PATH="$HOME/screenshot_ocr.db"
|
||||||
|
|
||||||
|
# Check if database exists
|
||||||
|
if [ ! -f "$DB_PATH" ]; then
|
||||||
|
echo "Error: Database not found at $DB_PATH"
|
||||||
|
echo "Run the OCR script first to create and populate the database."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if rofi exists
|
||||||
|
if ! command -v rofi &> /dev/null; then
|
||||||
|
echo "Error: rofi is not installed. Please install it with:"
|
||||||
|
echo " sudo pacman -S rofi"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if sqlite3 exists
|
||||||
|
if ! command -v sqlite3 &> /dev/null; then
|
||||||
|
echo "Error: sqlite3 is not installed. Please install it with:"
|
||||||
|
echo " sudo pacman -S sqlite"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create a temporary file to store data
|
||||||
|
TMP_FILE=$(mktemp)
|
||||||
|
trap 'rm -f $TMP_FILE' EXIT
|
||||||
|
|
||||||
|
# Get data from database and format for rofi
|
||||||
|
sqlite3 -separator '|' "$DB_PATH" "
|
||||||
|
SELECT full_path, filename, ocr_text
|
||||||
|
FROM ocr_results
|
||||||
|
ORDER BY filename
|
||||||
|
" > "$TMP_FILE"
|
||||||
|
|
||||||
|
# Process each line to format for rofi and create menu items
|
||||||
|
menu_items=""
|
||||||
|
while IFS='|' read -r path filename ocr_text; do
|
||||||
|
# Clean up text for display (replace newlines, truncate)
|
||||||
|
clean_text=$(echo "$ocr_text" | tr '\n' ' ' | sed 's/ / /g')
|
||||||
|
if [ ${#clean_text} -gt 80 ]; then
|
||||||
|
clean_text="${clean_text:0:80}..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Add to menu items
|
||||||
|
menu_items+="$filename | $clean_text\n"
|
||||||
|
done < "$TMP_FILE"
|
||||||
|
|
||||||
|
# Display rofi menu
|
||||||
|
selection=$(echo -e "$menu_items" | rofi -dmenu -i -p "Screenshot OCR" -width 80 -lines 15 -font "mono 10")
|
||||||
|
|
||||||
|
# Exit if no selection
|
||||||
|
if [ -z "$selection" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Extract filename from selection
|
||||||
|
selected_filename=$(echo "$selection" | cut -d'|' -f1 | sed 's/ *$//')
|
||||||
|
|
||||||
|
# Find the path for the selected filename
|
||||||
|
selected_path=$(grep -F "|$selected_filename|" "$TMP_FILE" | cut -d'|' -f1)
|
||||||
|
|
||||||
|
# Open the file if found
|
||||||
|
if [ -n "$selected_path" ] && [ -f "$selected_path" ]; then
|
||||||
|
echo "Opening: $selected_path"
|
||||||
|
xdg-open "$selected_path" &
|
||||||
|
else
|
||||||
|
echo "Error: Could not find file: $selected_path"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -5,11 +5,21 @@ A Qt6-based image gallery application that allows you to search through OCR data
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Fast visual navigation through your screenshot collection
|
- Fast visual navigation through your screenshot collection
|
||||||
- Ultra-responsive live search through OCR text as you type with optimized performance
|
- Non-blocking UI with background threaded search operations
|
||||||
|
- Smart typing detection with 500ms inactivity timer before searching
|
||||||
|
- Visual feedback while typing and searching with animated status indicators
|
||||||
|
- Settings dialog to customize database location and screenshots directory
|
||||||
|
- Settings stored in ~/.config/ScreenshotOCRGallery/settings.ini for easy access
|
||||||
|
- Customizable image preload count for performance tuning
|
||||||
|
- Prominent "Load More Images" button for easy one-click pagination
|
||||||
|
- Optimized lazy loading that only loads images when needed
|
||||||
|
- Ultra-responsive live search through OCR text using SQLite FTS5 full-text search technology
|
||||||
|
- Extremely fast search even with large databases containing thousands of screenshots
|
||||||
- Dynamic grid layout that automatically reflows (1x, 2x, 3x, 4x, etc.) based on window width
|
- Dynamic grid layout that automatically reflows (1x, 2x, 3x, 4x, etc.) based on window width
|
||||||
- No horizontal scrollbars - content always fits the window width
|
- No horizontal scrollbars - content always fits the window width
|
||||||
- Filename overlay at the bottom of each image for easy identification
|
- Dynamic filename overlay at the bottom of each image that scales with the thumbnail width
|
||||||
- Opens images in your default image viewer on click
|
- Opens images in your default image viewer on click
|
||||||
|
- Menu bar with File options (Open, Open With, Settings, Quit)
|
||||||
- Minimal 2px spacing between images for a compact view
|
- Minimal 2px spacing between images for a compact view
|
||||||
- Proper error handling for missing files and database issues
|
- Proper error handling for missing files and database issues
|
||||||
|
|
||||||
@@ -88,17 +98,76 @@ After building, run the application:
|
|||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
1. When the application starts, it will display all screenshots found in the database
|
1. When the application starts, it will display the first batch of screenshots (20 by default)
|
||||||
2. Type in the search bar to filter images by OCR text content
|
2. Click the large "Load More Images" button at the end of the gallery to load additional images with a single click
|
||||||
3. Results update instantly as you type with optimized search performance
|
3. Type in the search bar to filter images by OCR text content
|
||||||
4. When you clear the search bar, all images are immediately shown
|
4. The app waits for you to stop typing (500ms pause) before performing the search
|
||||||
|
5. Animated status indicators show when you're typing and when searching is in progress
|
||||||
|
6. Search happens in the background - UI stays responsive even during complex searches
|
||||||
|
7. Results update instantly as they become available, loading only what you can see
|
||||||
|
8. When you clear the search bar, the first batch of images loads immediately
|
||||||
5. Resize the application window to see the grid automatically reflow:
|
5. Resize the application window to see the grid automatically reflow:
|
||||||
- Wider windows show more columns (4x, 5x, etc.)
|
- Wider windows show more columns (4x, 5x, etc.)
|
||||||
- Narrower windows reduce to fewer columns (3x, 2x)
|
- Narrower windows reduce to fewer columns (3x, 2x)
|
||||||
- Very narrow windows show a single centered column (1x)
|
- Very narrow windows show a single centered column (1x)
|
||||||
- No horizontal scrolling - content always fits the available width
|
- No horizontal scrolling - content always fits the available width
|
||||||
6. Each image displays its filename at the bottom for easy identification
|
6. Each image displays its filename at the bottom with a dark overlay that resizes with the thumbnail
|
||||||
7. Click on any image to open it in your default image viewer
|
7. Click on any image to open it in your default image viewer
|
||||||
|
8. Use the File menu for additional options:
|
||||||
|
- Open: Select and open an image file
|
||||||
|
- Open With: Choose a program to open an image file
|
||||||
|
- Settings: Configure your database path and screenshots directory
|
||||||
|
- Quit: Exit the application
|
||||||
|
|
||||||
|
9. Configure the application through the Settings dialog:
|
||||||
|
- Database File Path: Change where your OCR database is stored
|
||||||
|
- Screenshots Directory: Set the default location for your screenshots
|
||||||
|
- Image count to pre-load: Adjust how many images are loaded at once (default: 20)
|
||||||
|
- The settings are automatically saved to ~/.config/ScreenshotOCRGallery/settings.ini
|
||||||
|
- Changes are applied immediately without requiring a restart
|
||||||
|
|
||||||
|
## Search Technology
|
||||||
|
|
||||||
|
This application combines multiple performance-enhancing technologies:
|
||||||
|
|
||||||
|
### 1. Customizable Configuration
|
||||||
|
- **Settings Dialog:** Easily configure database location, screenshots directory, and preload count
|
||||||
|
- **File Path Selection:** Browse for locations using native file pickers
|
||||||
|
- **Persistent Settings:** Your configuration is saved in ~/.config/ScreenshotOCRGallery/settings.ini
|
||||||
|
- **Performance Tuning:** Adjust how many images are preloaded (20 by default)
|
||||||
|
- **Dynamic Updates:** Changes are applied immediately without requiring a restart
|
||||||
|
|
||||||
|
### 2. Intelligent Input Handling
|
||||||
|
- **Typing Inactivity Detection:** Search only triggers after 500ms of no typing
|
||||||
|
- **Visual Feedback:** Animated status indicators show typing and searching states
|
||||||
|
- **Immediate Response:** UI instantly acknowledges your input
|
||||||
|
- **Efficient Processing:** Prevents wasteful searches while you're still typing
|
||||||
|
|
||||||
|
### 3. Lazy Loading & Pagination
|
||||||
|
- **Initial Fast Load:** Only loads a configurable batch of images (default: 20) for immediate display
|
||||||
|
- **Prominent Load More Button:** Large, clearly visible button at the end of the image list for loading more images
|
||||||
|
- **Customizable Batch Size:** Adjust the number of images loaded at once via settings
|
||||||
|
- **Optimized Memory Usage:** Only keeps necessary images in memory
|
||||||
|
- **Built-in Progress Tracking:** Button dynamically updates to show current progress (e.g., "Load More Images (20 of 157)")
|
||||||
|
- **Simple Interaction:** One-click loading of additional images without needing to scroll
|
||||||
|
|
||||||
|
### 4. Background Threading
|
||||||
|
- **Non-Blocking UI:** All search operations happen in background threads
|
||||||
|
- **Responsive Interface:** The application remains fully responsive while searching
|
||||||
|
- **Parallel Processing:** Search operations don't block the main UI thread
|
||||||
|
- **Live Updates:** Results appear as they become available
|
||||||
|
|
||||||
|
### 5. Full-Text Search
|
||||||
|
- **What is FTS5?** A powerful full-text search engine built into SQLite that uses specialized indexing
|
||||||
|
- **Performance Benefits:** Up to 100× faster than standard LIKE queries for text searches
|
||||||
|
- **Smart Search:** Supports word stemming, prefix matches, and phrase queries
|
||||||
|
- **Automatic Fallback:** If FTS5 is not available, the app automatically falls back to standard search
|
||||||
|
|
||||||
|
### 6. Search Result Caching
|
||||||
|
- **Paginated Cache:** Results are cached by page for efficient retrieval
|
||||||
|
- **Smart Invalidation:** Cache expires after 5 minutes to ensure fresh results
|
||||||
|
- **Thread-Safe:** The cache is protected for concurrent access
|
||||||
|
- **Memory Efficient:** Only stores what's needed, with automatic cleanup
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 468 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
<!DOCTYPE RCC><RCC version="1.0">
|
||||||
|
<qresource prefix="/">
|
||||||
|
<file>icons/orcs-gallery-64.png</file>
|
||||||
|
<file>icons/orcs-gallery-128.png</file>
|
||||||
|
<file>icons/orcs-gallery-256.png</file>
|
||||||
|
<file>icons/orcs-gallery-512.png</file>
|
||||||
|
</qresource>
|
||||||
|
</RCC>
|
||||||
@@ -7,5 +7,5 @@ Exec=/home/master/screenshot-gallery/build/screenshot-gallery
|
|||||||
Terminal=false
|
Terminal=false
|
||||||
Categories=Graphics;Utility;Viewer;
|
Categories=Graphics;Utility;Viewer;
|
||||||
Keywords=screenshots;ocr;gallery;search;images;
|
Keywords=screenshots;ocr;gallery;search;images;
|
||||||
Icon=image-viewer
|
Icon=orcs-gallery
|
||||||
StartupNotify=true
|
StartupNotify=true
|
||||||
|
|||||||
+603
-92
@@ -4,23 +4,68 @@
|
|||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
#include <QVariant>
|
#include <QVariant>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
|
#include <QTimer>
|
||||||
|
|
||||||
DatabaseManager::DatabaseManager(QObject *parent)
|
DatabaseManager::DatabaseManager(QObject *parent)
|
||||||
: QObject(parent)
|
: QObject(parent)
|
||||||
, m_initialized(false)
|
, m_initialized(false)
|
||||||
|
, m_ftsEnabled(false)
|
||||||
|
, m_searchCancelled(false)
|
||||||
|
, m_cachedImageCount(-1) // Initialize to invalid value
|
||||||
|
, m_currentOffset(0)
|
||||||
|
, m_currentLimit(0)
|
||||||
{
|
{
|
||||||
// Initialize search cache
|
// Initialize search cache
|
||||||
m_searchCache.clear();
|
m_searchCache.clear();
|
||||||
|
m_allImagesCache.clear();
|
||||||
|
m_lastCacheUpdate = QDateTime::currentDateTime();
|
||||||
|
|
||||||
// Create index on ocr_text if it doesn't exist
|
// Connect future watcher to handle search results
|
||||||
// This will be executed once the database is initialized
|
connect(&m_searchWatcher, &QFutureWatcher<void>::finished,
|
||||||
|
this, [this]() {
|
||||||
|
if (!m_searchCancelled) {
|
||||||
|
// Emit signal with the results only if not cancelled
|
||||||
|
QMutexLocker locker(&m_searchMutex);
|
||||||
|
QString searchText = m_currentSearchText;
|
||||||
|
int offset = m_currentOffset;
|
||||||
|
int limit = m_currentLimit;
|
||||||
|
|
||||||
|
if (!searchText.isEmpty()) {
|
||||||
|
QMutexLocker cacheLocker(&m_cacheMutex);
|
||||||
|
if (m_searchCache.contains(searchText) &&
|
||||||
|
m_searchCache[searchText].contains(qMakePair(offset, limit))) {
|
||||||
|
|
||||||
|
SearchCacheItem cacheItem = m_searchCache[searchText][qMakePair(offset, limit)];
|
||||||
|
emit searchResultsReady(cacheItem.results, searchText,
|
||||||
|
offset, limit, cacheItem.totalCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean cache periodically
|
||||||
|
QTimer *cleanupTimer = new QTimer(this);
|
||||||
|
connect(cleanupTimer, &QTimer::timeout, this, &DatabaseManager::cleanupCache);
|
||||||
|
cleanupTimer->start(60000); // Clean cache every minute
|
||||||
}
|
}
|
||||||
|
|
||||||
DatabaseManager::~DatabaseManager()
|
DatabaseManager::~DatabaseManager()
|
||||||
{
|
{
|
||||||
|
// Cancel any ongoing search and wait for it to finish
|
||||||
|
cancelSearch();
|
||||||
|
|
||||||
if (m_db.isOpen()) {
|
if (m_db.isOpen()) {
|
||||||
m_db.close();
|
m_db.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close any thread-specific database connections
|
||||||
|
QStringList connectionNames = QSqlDatabase::connectionNames();
|
||||||
|
for (const QString &connName : connectionNames) {
|
||||||
|
// Remove thread-specific database connections that start with "tdb_"
|
||||||
|
if (connName.startsWith("tdb_") && connName != QString("tdb_%1").arg((quintptr)QThread::currentThread())) {
|
||||||
|
QSqlDatabase::removeDatabase(connName);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool DatabaseManager::initialize(const QString &dbPath)
|
bool DatabaseManager::initialize(const QString &dbPath)
|
||||||
@@ -68,31 +113,40 @@ bool DatabaseManager::initialize(const QString &dbPath)
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool hasId = false;
|
||||||
bool hasFullPath = false;
|
bool hasFullPath = false;
|
||||||
bool hasOcrText = false;
|
bool hasOcrText = false;
|
||||||
|
|
||||||
while (query.next()) {
|
while (query.next()) {
|
||||||
QString columnName = query.value(1).toString();
|
QString columnName = query.value(1).toString();
|
||||||
|
if (columnName == "id") hasId = true;
|
||||||
if (columnName == "full_path") hasFullPath = true;
|
if (columnName == "full_path") hasFullPath = true;
|
||||||
if (columnName == "ocr_text") hasOcrText = true;
|
if (columnName == "ocr_text") hasOcrText = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasFullPath || !hasOcrText) {
|
if (!hasId || !hasFullPath || !hasOcrText) {
|
||||||
qDebug() << "Missing required columns in ocr_results table. Need 'full_path' and 'ocr_text'";
|
qDebug() << "Missing required columns in ocr_results table. Need 'id', 'full_path', and 'ocr_text'";
|
||||||
m_db.close();
|
m_db.close();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create an index on the ocr_text column if it doesn't exist
|
// Initialize FTS5 if available
|
||||||
// This will speed up text searches dramatically
|
if (initializeFTS()) {
|
||||||
|
qDebug() << "FTS5 initialized successfully.";
|
||||||
|
m_ftsEnabled = true;
|
||||||
|
} else {
|
||||||
|
// Fallback to regular index if FTS5 is unavailable
|
||||||
|
qDebug() << "FTS5 not available, using standard index instead.";
|
||||||
query.exec("CREATE INDEX IF NOT EXISTS idx_ocr_text ON ocr_results(ocr_text)");
|
query.exec("CREATE INDEX IF NOT EXISTS idx_ocr_text ON ocr_results(ocr_text)");
|
||||||
|
m_ftsEnabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
m_initialized = true;
|
m_initialized = true;
|
||||||
qDebug() << "Database initialized successfully.";
|
qDebug() << "Database initialized successfully.";
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
QList<DatabaseManager::ImageItem> DatabaseManager::getAllImages()
|
QList<DatabaseManager::ImageItem> DatabaseManager::getAllImages(int offset, int limit)
|
||||||
{
|
{
|
||||||
QList<ImageItem> images;
|
QList<ImageItem> images;
|
||||||
|
|
||||||
@@ -101,6 +155,17 @@ QList<DatabaseManager::ImageItem> DatabaseManager::getAllImages()
|
|||||||
return images;
|
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) {
|
||||||
|
return m_allImagesCache[cacheKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cacheLocker.unlock();
|
||||||
|
|
||||||
// Verify database is still connected
|
// 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();
|
qDebug() << "Database connection lost and cannot be reopened:" << m_db.lastError().text();
|
||||||
@@ -112,80 +177,18 @@ QList<DatabaseManager::ImageItem> DatabaseManager::getAllImages()
|
|||||||
m_db.transaction();
|
m_db.transaction();
|
||||||
|
|
||||||
QSqlQuery query;
|
QSqlQuery query;
|
||||||
query.prepare("SELECT full_path, ocr_text FROM ocr_results");
|
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()) {
|
if (!query.exec()) {
|
||||||
qDebug() << "Failed to fetch images:" << query.lastError().text();
|
qDebug() << "Failed to fetch images:" << query.lastError().text();
|
||||||
return images;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if files exist as we add them
|
|
||||||
// Reserve space for results to avoid reallocations
|
|
||||||
images.reserve(query.size() > 0 ? query.size() : 100);
|
|
||||||
|
|
||||||
while (query.next()) {
|
|
||||||
ImageItem item;
|
|
||||||
item.filePath = query.value(0).toString();
|
|
||||||
item.ocrText = query.value(1).toString();
|
|
||||||
|
|
||||||
// Only add images that have a non-empty path
|
|
||||||
if (!item.filePath.isEmpty()) {
|
|
||||||
images.append(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
m_db.commit();
|
|
||||||
return images;
|
|
||||||
}
|
|
||||||
|
|
||||||
QList<DatabaseManager::ImageItem> DatabaseManager::searchImages(const QString &searchText)
|
|
||||||
{
|
|
||||||
QList<ImageItem> images;
|
|
||||||
|
|
||||||
if (!m_initialized) {
|
|
||||||
qDebug() << "Database not initialized.";
|
|
||||||
return images;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If search text is empty, return all images
|
|
||||||
if (searchText.isEmpty()) {
|
|
||||||
// Clear the search cache when empty search is performed
|
|
||||||
m_searchCache.clear();
|
|
||||||
return getAllImages();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we have a cached result for this search query
|
|
||||||
if (m_searchCache.contains(searchText)) {
|
|
||||||
return m_searchCache[searchText];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start transaction to speed up queries
|
|
||||||
m_db.transaction();
|
|
||||||
|
|
||||||
QSqlQuery query;
|
|
||||||
|
|
||||||
// Optimize the query based on length of search text
|
|
||||||
if (searchText.length() <= 3) {
|
|
||||||
// For short search terms, use a more targeted approach
|
|
||||||
query.prepare("SELECT full_path, ocr_text FROM ocr_results WHERE ocr_text LIKE :search");
|
|
||||||
query.bindValue(":search", "%" + searchText + "%");
|
|
||||||
} else {
|
|
||||||
// For longer search terms, use LIKE with a more specific pattern at start
|
|
||||||
// which can utilize indexes better if they exist
|
|
||||||
query.prepare("SELECT full_path, ocr_text FROM ocr_results WHERE ocr_text LIKE :search OR ocr_text LIKE :wordstart");
|
|
||||||
query.bindValue(":search", "%" + searchText + "%");
|
|
||||||
query.bindValue(":wordstart", "% " + searchText + "%");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!query.exec()) {
|
|
||||||
qDebug() << "Failed to search images:" << query.lastError().text();
|
|
||||||
m_db.rollback();
|
m_db.rollback();
|
||||||
return images;
|
return images;
|
||||||
}
|
}
|
||||||
@@ -195,8 +198,9 @@ QList<DatabaseManager::ImageItem> DatabaseManager::searchImages(const QString &s
|
|||||||
|
|
||||||
while (query.next()) {
|
while (query.next()) {
|
||||||
ImageItem item;
|
ImageItem item;
|
||||||
item.filePath = query.value(0).toString();
|
item.id = query.value(0).toInt();
|
||||||
item.ocrText = query.value(1).toString();
|
item.filePath = query.value(1).toString();
|
||||||
|
item.ocrText = query.value(2).toString();
|
||||||
|
|
||||||
// Only add images that have a non-empty path
|
// Only add images that have a non-empty path
|
||||||
if (!item.filePath.isEmpty()) {
|
if (!item.filePath.isEmpty()) {
|
||||||
@@ -206,18 +210,525 @@ QList<DatabaseManager::ImageItem> DatabaseManager::searchImages(const QString &s
|
|||||||
|
|
||||||
m_db.commit();
|
m_db.commit();
|
||||||
|
|
||||||
// Cache the result for future queries
|
// Update cache
|
||||||
if (images.size() > 0) {
|
cacheLocker.relock();
|
||||||
m_searchCache.insert(searchText, images);
|
m_allImagesCache[cacheKey] = images;
|
||||||
|
m_lastCacheUpdate = QDateTime::currentDateTime();
|
||||||
// Limit cache size to avoid memory issues
|
cacheLocker.unlock();
|
||||||
if (m_searchCache.size() > MAX_CACHE_SIZE) {
|
|
||||||
// Remove the first key (oldest entry)
|
|
||||||
if (!m_searchCache.isEmpty()) {
|
|
||||||
m_searchCache.remove(m_searchCache.firstKey());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return images;
|
return images;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int DatabaseManager::getImageCount()
|
||||||
|
{
|
||||||
|
// Return cached count if available
|
||||||
|
QMutexLocker cacheLocker(&m_cacheMutex);
|
||||||
|
if (m_cachedImageCount >= 0 &&
|
||||||
|
m_lastCacheUpdate.secsTo(QDateTime::currentDateTime()) < CACHE_LIFETIME_SECS) {
|
||||||
|
return m_cachedImageCount;
|
||||||
|
}
|
||||||
|
cacheLocker.unlock();
|
||||||
|
|
||||||
|
if (!m_initialized) {
|
||||||
|
qDebug() << "Database not initialized.";
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
QSqlQuery query;
|
||||||
|
query.prepare("SELECT COUNT(*) FROM ocr_results");
|
||||||
|
|
||||||
|
if (!query.exec() || !query.next()) {
|
||||||
|
qDebug() << "Failed to get image count:" << query.lastError().text();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int count = query.value(0).toInt();
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
cacheLocker.relock();
|
||||||
|
m_cachedImageCount = count;
|
||||||
|
cacheLocker.unlock();
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
QSqlDatabase DatabaseManager::getDatabaseConnection()
|
||||||
|
{
|
||||||
|
// Get current thread ID to create unique connection name
|
||||||
|
QThread* currentThread = QThread::currentThread();
|
||||||
|
QString connectionName = QString("tdb_%1").arg((quintptr)currentThread);
|
||||||
|
|
||||||
|
// Check if connection already exists for this thread
|
||||||
|
if (QSqlDatabase::contains(connectionName)) {
|
||||||
|
return QSqlDatabase::database(connectionName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new connection for this thread
|
||||||
|
QSqlDatabase threadDb = QSqlDatabase::addDatabase("QSQLITE", connectionName);
|
||||||
|
threadDb.setDatabaseName(m_db.databaseName());
|
||||||
|
|
||||||
|
if (!threadDb.open()) {
|
||||||
|
qDebug() << "Failed to open database in thread:" << threadDb.lastError().text();
|
||||||
|
} else {
|
||||||
|
// Enable foreign keys in this connection
|
||||||
|
QSqlQuery query(threadDb);
|
||||||
|
query.exec("PRAGMA foreign_keys = ON");
|
||||||
|
}
|
||||||
|
|
||||||
|
return threadDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
void DatabaseManager::searchImages(const QString &searchText, int offset, int limit)
|
||||||
|
{
|
||||||
|
if (!m_initialized) {
|
||||||
|
qDebug() << "Database not initialized.";
|
||||||
|
emit searchResultsReady(QList<ImageItem>(), searchText, offset, limit, 0);
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
int totalCount = getImageCount();
|
||||||
|
|
||||||
|
emit searchResultsReady(allImages, searchText, offset, limit, totalCount);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have a cached result for this search query
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show immediate feedback that search is starting
|
||||||
|
emit searchStarted(searchText);
|
||||||
|
|
||||||
|
m_searchWatcher.setFuture(m_searchFuture);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DatabaseManager::cancelSearch()
|
||||||
|
{
|
||||||
|
// Set cancelled flag
|
||||||
|
QMutexLocker locker(&m_searchMutex);
|
||||||
|
m_searchCancelled = true;
|
||||||
|
m_currentSearchText.clear();
|
||||||
|
locker.unlock();
|
||||||
|
|
||||||
|
// Wait for any running search to complete
|
||||||
|
if (m_searchFuture.isRunning()) {
|
||||||
|
m_searchFuture.waitForFinished();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void DatabaseManager::performSearchInBackground(const QString &searchText, int offset, int limit)
|
||||||
|
{
|
||||||
|
QList<ImageItem> images;
|
||||||
|
|
||||||
|
// Get a thread-specific database connection
|
||||||
|
QSqlDatabase threadDb = getDatabaseConnection();
|
||||||
|
|
||||||
|
if (!threadDb.isOpen() && !threadDb.open()) {
|
||||||
|
qDebug() << "Thread database connection failed:" << threadDb.lastError().text();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if search was cancelled
|
||||||
|
{
|
||||||
|
QMutexLocker locker(&m_searchMutex);
|
||||||
|
if (m_searchCancelled || m_currentSearchText != searchText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// First, get total count for pagination info
|
||||||
|
QSqlQuery countQuery(threadDb);
|
||||||
|
int totalCount = 0;
|
||||||
|
|
||||||
|
if (m_ftsEnabled) {
|
||||||
|
QString ftsQuery = prepareFTSQuery(searchText);
|
||||||
|
countQuery.prepare("SELECT COUNT(*) FROM ocr_results r "
|
||||||
|
"JOIN ocr_fts f ON r.id = f.rowid "
|
||||||
|
"WHERE ocr_fts MATCH :query");
|
||||||
|
countQuery.bindValue(":query", ftsQuery);
|
||||||
|
} else {
|
||||||
|
if (searchText.length() <= 3) {
|
||||||
|
countQuery.prepare("SELECT COUNT(*) FROM ocr_results WHERE ocr_text LIKE :search");
|
||||||
|
countQuery.bindValue(":search", "%" + searchText + "%");
|
||||||
|
} else {
|
||||||
|
countQuery.prepare("SELECT COUNT(*) FROM ocr_results WHERE ocr_text LIKE :search OR ocr_text LIKE :wordstart");
|
||||||
|
countQuery.bindValue(":search", "%" + searchText + "%");
|
||||||
|
countQuery.bindValue(":wordstart", "% " + searchText + "%");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (countQuery.exec() && countQuery.next()) {
|
||||||
|
totalCount = countQuery.value(0).toInt();
|
||||||
|
} else {
|
||||||
|
qDebug() << "Failed to get count:" << countQuery.lastError().text();
|
||||||
|
totalCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if search was cancelled before main query
|
||||||
|
{
|
||||||
|
QMutexLocker locker(&m_searchMutex);
|
||||||
|
if (m_searchCancelled || m_currentSearchText != searchText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start transaction to speed up query
|
||||||
|
threadDb.transaction();
|
||||||
|
|
||||||
|
QSqlQuery query(threadDb);
|
||||||
|
|
||||||
|
if (m_ftsEnabled) {
|
||||||
|
// Use FTS5 virtual table for much faster text search
|
||||||
|
QString ftsQuery = prepareFTSQuery(searchText);
|
||||||
|
QString queryStr = "SELECT r.id, r.full_path, r.ocr_text 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";
|
||||||
|
}
|
||||||
|
|
||||||
|
query.prepare(queryStr);
|
||||||
|
query.bindValue(":query", ftsQuery);
|
||||||
|
if (limit > 0) {
|
||||||
|
query.bindValue(":limit", limit);
|
||||||
|
query.bindValue(":offset", offset);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback to LIKE queries if FTS is not available
|
||||||
|
// Optimize the query based on length of search text
|
||||||
|
if (searchText.length() <= 3) {
|
||||||
|
// For short search terms, use a more targeted approach
|
||||||
|
QString queryStr = "SELECT id, full_path, ocr_text FROM ocr_results WHERE ocr_text LIKE :search "
|
||||||
|
"ORDER BY id";
|
||||||
|
|
||||||
|
if (limit > 0) {
|
||||||
|
queryStr += " LIMIT :limit OFFSET :offset";
|
||||||
|
}
|
||||||
|
|
||||||
|
query.prepare(queryStr);
|
||||||
|
query.bindValue(":search", "%" + searchText + "%");
|
||||||
|
if (limit > 0) {
|
||||||
|
query.bindValue(":limit", limit);
|
||||||
|
query.bindValue(":offset", offset);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For longer search terms, use LIKE with a more specific pattern at start
|
||||||
|
QString queryStr = "SELECT id, full_path, ocr_text FROM ocr_results WHERE ocr_text LIKE :search OR ocr_text LIKE :wordstart "
|
||||||
|
"ORDER BY id";
|
||||||
|
|
||||||
|
if (limit > 0) {
|
||||||
|
queryStr += " LIMIT :limit OFFSET :offset";
|
||||||
|
}
|
||||||
|
|
||||||
|
query.prepare(queryStr);
|
||||||
|
query.bindValue(":search", "%" + searchText + "%");
|
||||||
|
query.bindValue(":wordstart", "% " + searchText + "%");
|
||||||
|
if (limit > 0) {
|
||||||
|
query.bindValue(":limit", limit);
|
||||||
|
query.bindValue(":offset", offset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!query.exec()) {
|
||||||
|
qDebug() << "Failed to search images:" << query.lastError().text();
|
||||||
|
qDebug() << "Error details:" << query.lastError().databaseText();
|
||||||
|
threadDb.rollback();
|
||||||
|
|
||||||
|
// If FTS query failed, try fallback to LIKE
|
||||||
|
if (m_ftsEnabled) {
|
||||||
|
qDebug() << "Trying fallback to LIKE query...";
|
||||||
|
threadDb.transaction();
|
||||||
|
|
||||||
|
query.prepare("SELECT full_path, ocr_text FROM ocr_results WHERE ocr_text LIKE :search");
|
||||||
|
query.bindValue(":search", "%" + searchText + "%");
|
||||||
|
|
||||||
|
if (!query.exec()) {
|
||||||
|
qDebug() << "Fallback query also failed:" << query.lastError().text();
|
||||||
|
threadDb.rollback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if search was cancelled
|
||||||
|
{
|
||||||
|
QMutexLocker locker(&m_searchMutex);
|
||||||
|
if (m_searchCancelled || m_currentSearchText != searchText) {
|
||||||
|
threadDb.rollback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserve space for results to avoid reallocations
|
||||||
|
images.reserve(query.size() > 0 ? query.size() : 100);
|
||||||
|
|
||||||
|
while (query.next()) {
|
||||||
|
// Periodically check if search was cancelled
|
||||||
|
if (query.at() % 20 == 0) {
|
||||||
|
QMutexLocker locker(&m_searchMutex);
|
||||||
|
if (m_searchCancelled || m_currentSearchText != searchText) {
|
||||||
|
threadDb.rollback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ImageItem item;
|
||||||
|
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()) {
|
||||||
|
images.append(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
threadDb.commit();
|
||||||
|
|
||||||
|
// Check if search was cancelled before storing results
|
||||||
|
{
|
||||||
|
QMutexLocker locker(&m_searchMutex);
|
||||||
|
if (m_searchCancelled || m_currentSearchText != searchText) {
|
||||||
|
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 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
|
||||||
|
QMutexLocker searchLocker(&m_searchMutex);
|
||||||
|
if (!m_searchCancelled && m_currentSearchText == searchText) {
|
||||||
|
searchLocker.unlock();
|
||||||
|
emit searchResultsReady(images, searchText, offset, limit, totalCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void DatabaseManager::cleanupCache()
|
||||||
|
{
|
||||||
|
QMutexLocker locker(&m_cacheMutex);
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
QMutableMapIterator<QPair<int, int>, SearchCacheItem> j(i.value());
|
||||||
|
while (j.hasNext()) {
|
||||||
|
j.next();
|
||||||
|
if (j.value().timestamp.secsTo(now) > CACHE_LIFETIME_SECS) {
|
||||||
|
j.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no more results for this search text, remove the entry
|
||||||
|
if (i.value().isEmpty()) {
|
||||||
|
i.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expire old all-images cache
|
||||||
|
if (m_lastCacheUpdate.secsTo(now) > CACHE_LIFETIME_SECS) {
|
||||||
|
m_allImagesCache.clear();
|
||||||
|
m_cachedImageCount = -1; // Invalidate count cache
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DatabaseManager::initializeFTS()
|
||||||
|
{
|
||||||
|
// Check if SQLite has FTS5 support
|
||||||
|
QSqlQuery query;
|
||||||
|
query.exec("SELECT sqlite_compileoption_used('ENABLE_FTS5')");
|
||||||
|
|
||||||
|
if (!query.next() || !query.value(0).toBool()) {
|
||||||
|
qDebug() << "FTS5 not available in this SQLite installation";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if our FTS table already exists
|
||||||
|
query.exec("SELECT name FROM sqlite_master WHERE type='table' AND name='ocr_fts'");
|
||||||
|
|
||||||
|
if (!query.next()) {
|
||||||
|
// Create FTS5 virtual table
|
||||||
|
qDebug() << "Creating FTS5 virtual table...";
|
||||||
|
bool success = query.exec(
|
||||||
|
"CREATE VIRTUAL TABLE IF NOT EXISTS ocr_fts USING fts5("
|
||||||
|
"ocr_text, "
|
||||||
|
"content='ocr_results', "
|
||||||
|
"content_rowid='id', "
|
||||||
|
"tokenize='porter unicode61');"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
qDebug() << "Failed to create FTS5 table:" << query.lastError().text();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate the FTS table from existing data
|
||||||
|
query.exec("BEGIN TRANSACTION;");
|
||||||
|
success = query.exec(
|
||||||
|
"INSERT INTO ocr_fts(rowid, ocr_text) "
|
||||||
|
"SELECT id, ocr_text FROM ocr_results;"
|
||||||
|
);
|
||||||
|
query.exec("COMMIT;");
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
qDebug() << "Failed to populate FTS5 table:" << query.lastError().text();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create triggers to keep FTS table in sync with ocr_results
|
||||||
|
success = query.exec(
|
||||||
|
"CREATE TRIGGER IF NOT EXISTS ocr_fts_insert AFTER INSERT ON ocr_results BEGIN "
|
||||||
|
" INSERT INTO ocr_fts(rowid, ocr_text) VALUES (new.id, new.ocr_text); "
|
||||||
|
"END;"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
qDebug() << "Failed to create insert trigger:" << query.lastError().text();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
success = query.exec(
|
||||||
|
"CREATE TRIGGER IF NOT EXISTS ocr_fts_delete AFTER DELETE ON ocr_results BEGIN "
|
||||||
|
" INSERT INTO ocr_fts(ocr_fts, rowid, ocr_text) VALUES('delete', old.id, old.ocr_text); "
|
||||||
|
"END;"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
qDebug() << "Failed to create delete trigger:" << query.lastError().text();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
success = query.exec(
|
||||||
|
"CREATE TRIGGER IF NOT EXISTS ocr_fts_update AFTER UPDATE ON ocr_results BEGIN "
|
||||||
|
" INSERT INTO ocr_fts(ocr_fts, rowid, ocr_text) VALUES('delete', old.id, old.ocr_text); "
|
||||||
|
" INSERT INTO ocr_fts(rowid, ocr_text) VALUES (new.id, new.ocr_text); "
|
||||||
|
"END;"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
qDebug() << "Failed to create update trigger:" << query.lastError().text();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString DatabaseManager::prepareFTSQuery(const QString &searchText)
|
||||||
|
{
|
||||||
|
// Split the search text into tokens
|
||||||
|
QStringList tokens = searchText.simplified().split(' ', Qt::SkipEmptyParts);
|
||||||
|
|
||||||
|
// For single word searches, search for the word as-is and with a wildcard
|
||||||
|
if (tokens.size() == 1) {
|
||||||
|
QString token = tokens.first();
|
||||||
|
// Use prefix search (words starting with the term)
|
||||||
|
return QString("%1* OR %1").arg(token);
|
||||||
|
}
|
||||||
|
// For multi-word searches
|
||||||
|
else {
|
||||||
|
// Build both exact phrase search and individual term search
|
||||||
|
QStringList tokenQueries;
|
||||||
|
|
||||||
|
// Add phrase match (higher relevance)
|
||||||
|
tokenQueries << QString("\"%1\"").arg(searchText);
|
||||||
|
|
||||||
|
// Add individual token matches with wildcards
|
||||||
|
for (const QString &token : tokens) {
|
||||||
|
if (token.length() > 2) { // Only use wildcards for tokens with 3+ chars
|
||||||
|
tokenQueries << QString("%1*").arg(token);
|
||||||
|
} else {
|
||||||
|
tokenQueries << token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenQueries.join(" OR ");
|
||||||
|
}
|
||||||
|
}
|
||||||
+89
-7
@@ -11,6 +11,13 @@
|
|||||||
#include <QPair>
|
#include <QPair>
|
||||||
#include <QThread>
|
#include <QThread>
|
||||||
#include <QMap>
|
#include <QMap>
|
||||||
|
#include <QMutex>
|
||||||
|
#include <QWaitCondition>
|
||||||
|
|
||||||
|
#include <QtConcurrent>
|
||||||
|
#include <QFuture>
|
||||||
|
#include <QFutureWatcher>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief The DatabaseManager class handles all database operations
|
* @brief The DatabaseManager class handles all database operations
|
||||||
@@ -27,6 +34,7 @@ public:
|
|||||||
struct ImageItem {
|
struct ImageItem {
|
||||||
QString filePath;
|
QString filePath;
|
||||||
QString ocrText;
|
QString ocrText;
|
||||||
|
int id; // Add id to support pagination
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,28 +56,102 @@ public:
|
|||||||
bool initialize(const QString &dbPath);
|
bool initialize(const QString &dbPath);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Get all images from the database
|
* @brief Get all images from the database with pagination
|
||||||
|
* @param offset Starting position (0-based) for pagination
|
||||||
|
* @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();
|
QList<ImageItem> getAllImages(int offset = 0, int limit = 0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Get total count of all images in the database
|
||||||
|
* @return Total number of images
|
||||||
|
*/
|
||||||
|
int getImageCount();
|
||||||
|
|
||||||
public slots:
|
public slots:
|
||||||
/**
|
/**
|
||||||
* @brief Search for images matching the search text
|
* @brief Search for images matching the search text
|
||||||
* @param searchText Text to search for in OCR results
|
* @param searchText Text to search for in OCR results
|
||||||
* @return List of ImageItem objects that match the search
|
* @param offset Starting position (0-based) for pagination
|
||||||
|
* @param limit Maximum number of items to return, 0 means no limit
|
||||||
*/
|
*/
|
||||||
QList<ImageItem> searchImages(const QString &searchText);
|
void searchImages(const QString &searchText, int offset = 0, int limit = 0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Signal emitted when search operation starts
|
||||||
|
* @param searchText The search text being processed
|
||||||
|
*/
|
||||||
|
void searchStarted(const QString &searchText);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
QSqlDatabase m_db;
|
QSqlDatabase m_db;
|
||||||
bool m_initialized;
|
bool m_initialized;
|
||||||
|
bool m_ftsEnabled; // Whether FTS5 is available and enabled
|
||||||
|
|
||||||
// Cache for search results to improve response time
|
// Cache for search results to improve response time
|
||||||
QMap<QString, QList<ImageItem>> m_searchCache;
|
// Cache structure includes both results and total count
|
||||||
|
struct SearchCacheItem {
|
||||||
|
QList<ImageItem> results;
|
||||||
|
int totalCount;
|
||||||
|
QDateTime timestamp;
|
||||||
|
};
|
||||||
|
|
||||||
// Maximum number of cached search queries
|
QMap<QString, QMap<QPair<int, int>, SearchCacheItem>> m_searchCache; // searchText -> {(offset, limit) -> results}
|
||||||
static const int MAX_CACHE_SIZE = 50;
|
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
|
||||||
|
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;
|
||||||
|
int m_currentOffset;
|
||||||
|
int m_currentLimit;
|
||||||
|
bool m_searchCancelled;
|
||||||
|
QMutex m_searchMutex;
|
||||||
|
|
||||||
|
// Cache settings
|
||||||
|
static const int MAX_CACHE_SIZE = 50; // Maximum number of cached search queries
|
||||||
|
static const int DEFAULT_PAGE_SIZE = 100; // Default number of items per page
|
||||||
|
static const int CACHE_LIFETIME_SECS = 300; // Cache lifetime in seconds (5 minutes)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Initialize FTS5 full-text search
|
||||||
|
bool initializeFTS();
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// Clear expired cache items
|
||||||
|
void cleanupCache();
|
||||||
|
|
||||||
|
// Get a database connection for the current thread
|
||||||
|
QSqlDatabase getDatabaseConnection();
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // DATABASEMANAGER_H
|
#endif // DATABASEMANAGER_H
|
||||||
+389
-10
@@ -9,6 +9,9 @@
|
|||||||
#include <QFrame>
|
#include <QFrame>
|
||||||
#include <QHBoxLayout>
|
#include <QHBoxLayout>
|
||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
|
#include <QScrollBar>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ImageThumbnail implementation
|
// ImageThumbnail implementation
|
||||||
ImageThumbnail::ImageThumbnail(const QString &filePath, QWidget *parent)
|
ImageThumbnail::ImageThumbnail(const QString &filePath, QWidget *parent)
|
||||||
@@ -43,9 +46,17 @@ void ImageThumbnail::mousePressEvent(QMouseEvent *event)
|
|||||||
ImageGallery::ImageGallery(QWidget *parent)
|
ImageGallery::ImageGallery(QWidget *parent)
|
||||||
: QWidget(parent)
|
: QWidget(parent)
|
||||||
, m_dbManager(nullptr)
|
, m_dbManager(nullptr)
|
||||||
|
, m_currentOffset(0)
|
||||||
|
, m_pageSize(DEFAULT_PAGE_SIZE)
|
||||||
|
, m_totalCount(0)
|
||||||
|
, m_isLoading(false)
|
||||||
|
, m_hasMoreImages(false)
|
||||||
|
, m_lastScrollPosition(0)
|
||||||
|
, m_loadingIndicator(nullptr)
|
||||||
{
|
{
|
||||||
// Create scroll area
|
// Create scroll area
|
||||||
m_scrollArea = new QScrollArea(this);
|
m_scrollArea = new QScrollArea(this);
|
||||||
|
m_scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOn);
|
||||||
m_scrollArea->setWidgetResizable(true);
|
m_scrollArea->setWidgetResizable(true);
|
||||||
m_scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); // Prevent horizontal scrollbar
|
m_scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); // Prevent horizontal scrollbar
|
||||||
m_scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
|
m_scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
|
||||||
@@ -83,6 +94,34 @@ ImageGallery::ImageGallery(QWidget *parent)
|
|||||||
QTimer *resizeTimer = new QTimer(this);
|
QTimer *resizeTimer = new QTimer(this);
|
||||||
connect(resizeTimer, &QTimer::timeout, this, &ImageGallery::handleContainerResized);
|
connect(resizeTimer, &QTimer::timeout, this, &ImageGallery::handleContainerResized);
|
||||||
resizeTimer->start(300); // Check every 300ms
|
resizeTimer->start(300); // Check every 300ms
|
||||||
|
|
||||||
|
// Create scroll timer for detecting end of scroll events
|
||||||
|
m_scrollTimer = new QTimer(this);
|
||||||
|
m_scrollTimer->setSingleShot(true);
|
||||||
|
m_scrollTimer->setInterval(200);
|
||||||
|
connect(m_scrollTimer, &QTimer::timeout, this, &ImageGallery::handleScrolledToBottom);
|
||||||
|
|
||||||
|
// Create loading indicator
|
||||||
|
m_loadingIndicator = new QLabel(tr("Loading more images..."), this);
|
||||||
|
m_loadingIndicator->setAlignment(Qt::AlignCenter);
|
||||||
|
m_loadingIndicator->setStyleSheet("color: #666; font-size: 14px; padding: 10px;");
|
||||||
|
m_loadingIndicator->hide();
|
||||||
|
|
||||||
|
// Install event filter on scroll area viewport to catch scroll events
|
||||||
|
m_scrollArea->viewport()->installEventFilter(this);
|
||||||
|
|
||||||
|
// Create the loading indicator
|
||||||
|
m_loadingIndicator = new QLabel(tr("Loading more images..."), this);
|
||||||
|
m_loadingIndicator->setAlignment(Qt::AlignCenter);
|
||||||
|
m_loadingIndicator->setStyleSheet("color: #666; font-size: 14px; padding: 10px;");
|
||||||
|
m_loadingIndicator->hide();
|
||||||
|
|
||||||
|
// Create Load More button
|
||||||
|
m_loadMoreButton = new QPushButton(tr("Load More Images"), this);
|
||||||
|
m_loadMoreButton->setStyleSheet("font-size: 14px; padding: 10px; margin: 10px; background-color: #e0e0e0; border-radius: 5px; border: 1px solid #c0c0c0;");
|
||||||
|
m_loadMoreButton->setMinimumHeight(50);
|
||||||
|
m_loadMoreButton->setCursor(Qt::PointingHandCursor);
|
||||||
|
connect(m_loadMoreButton, &QPushButton::clicked, this, &ImageGallery::handleLoadMoreClicked);
|
||||||
}
|
}
|
||||||
|
|
||||||
ImageGallery::~ImageGallery()
|
ImageGallery::~ImageGallery()
|
||||||
@@ -93,19 +132,71 @@ ImageGallery::~ImageGallery()
|
|||||||
void ImageGallery::setDatabaseManager(DatabaseManager *dbManager)
|
void ImageGallery::setDatabaseManager(DatabaseManager *dbManager)
|
||||||
{
|
{
|
||||||
m_dbManager = dbManager;
|
m_dbManager = dbManager;
|
||||||
|
|
||||||
|
// Connect to the DatabaseManager signals
|
||||||
|
if (m_dbManager) {
|
||||||
|
connect(m_dbManager, &DatabaseManager::searchResultsReady,
|
||||||
|
this, &ImageGallery::handleSearchResults);
|
||||||
|
connect(m_dbManager, &DatabaseManager::searchStarted,
|
||||||
|
this, &ImageGallery::handleSearchStarted);
|
||||||
}
|
}
|
||||||
|
|
||||||
void ImageGallery::displayImages(const QList<DatabaseManager::ImageItem> &images)
|
// Initialize the last search query
|
||||||
|
m_lastSearchQuery = QString();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ImageGallery::displayImages(const QList<DatabaseManager::ImageItem> &images, bool clearExisting)
|
||||||
{
|
{
|
||||||
// Clear existing thumbnails
|
// Clear existing thumbnails if requested
|
||||||
|
if (clearExisting) {
|
||||||
clearGallery();
|
clearGallery();
|
||||||
|
m_currentOffset = 0;
|
||||||
|
// Don't reset m_hasMoreImages here - we need to know if there are more to show the button
|
||||||
|
// It will be set correctly in handleSearchResults
|
||||||
|
}
|
||||||
|
|
||||||
// Update grid layout to ensure correct column count before adding images
|
// Update grid layout to ensure correct column count before adding images
|
||||||
updateGridLayout();
|
updateGridLayout();
|
||||||
|
|
||||||
|
// Remove any previous instances of the Load More button and loading indicator from the layout
|
||||||
|
if (m_loadMoreButton->parent()) {
|
||||||
|
m_gridLayout->removeWidget(m_loadMoreButton);
|
||||||
|
}
|
||||||
|
if (m_loadingIndicator->parent()) {
|
||||||
|
m_gridLayout->removeWidget(m_loadingIndicator);
|
||||||
|
}
|
||||||
|
m_loadMoreButton->hide();
|
||||||
|
m_loadingIndicator->hide();
|
||||||
|
|
||||||
const int numImages = images.size();
|
const int numImages = images.size();
|
||||||
int row = 0, col = 0;
|
int row = 0, col = 0;
|
||||||
|
|
||||||
|
// If we have existing thumbnails, start from the last position
|
||||||
|
if (!clearExisting && !m_thumbnails.isEmpty()) {
|
||||||
|
// Remove any "no images" or loading indicators first
|
||||||
|
for (int i = m_thumbnails.size() - 1; i >= 0; i--) {
|
||||||
|
if (m_thumbnails[i]->text().contains("No images found") ||
|
||||||
|
m_thumbnails[i]->text().contains("Loading")) {
|
||||||
|
m_gridLayout->removeWidget(m_thumbnails[i]);
|
||||||
|
delete m_thumbnails[i];
|
||||||
|
m_thumbnails.removeAt(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!m_thumbnails.isEmpty()) {
|
||||||
|
int lastIndex = m_thumbnails.size() - 1;
|
||||||
|
row = lastIndex / m_columnsCount;
|
||||||
|
col = lastIndex % m_columnsCount;
|
||||||
|
|
||||||
|
// Move to the next position
|
||||||
|
col++;
|
||||||
|
if (col >= m_columnsCount) {
|
||||||
|
col = 0;
|
||||||
|
row++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (int i = 0; i < numImages; ++i) {
|
for (int i = 0; i < numImages; ++i) {
|
||||||
const auto &item = images[i];
|
const auto &item = images[i];
|
||||||
|
|
||||||
@@ -125,17 +216,20 @@ void ImageGallery::displayImages(const QList<DatabaseManager::ImageItem> &images
|
|||||||
QFileInfo fileNameInfo(item.filePath);
|
QFileInfo fileNameInfo(item.filePath);
|
||||||
QString fileName = fileNameInfo.fileName();
|
QString fileName = fileNameInfo.fileName();
|
||||||
|
|
||||||
// Create overlay container with dark background
|
// Create overlay container with dark background - store it as a property of the thumbnail
|
||||||
QFrame* overlay = new QFrame(thumbnailLabel);
|
QFrame* overlay = new QFrame(thumbnailLabel);
|
||||||
|
overlay->setObjectName("filenameOverlay"); // Set object name for finding it later
|
||||||
overlay->setStyleSheet("background-color: rgba(0, 0, 0, 0.7);");
|
overlay->setStyleSheet("background-color: rgba(0, 0, 0, 0.7);");
|
||||||
overlay->setFixedHeight(20);
|
overlay->setFixedHeight(20);
|
||||||
overlay->setFixedWidth(THUMBNAIL_WIDTH);
|
|
||||||
|
|
||||||
// Create label for the filename
|
// Create label for the filename
|
||||||
QLabel* fileNameLabel = new QLabel(fileName, overlay);
|
QLabel* fileNameLabel = new QLabel(fileName, overlay);
|
||||||
fileNameLabel->setStyleSheet("color: white; background: transparent;");
|
fileNameLabel->setStyleSheet("color: white; background: transparent;");
|
||||||
fileNameLabel->setAlignment(Qt::AlignCenter);
|
fileNameLabel->setAlignment(Qt::AlignCenter);
|
||||||
fileNameLabel->setFixedWidth(THUMBNAIL_WIDTH - 10);
|
// Truncate filename if too long (more than 30 chars)
|
||||||
|
if (fileName.length() > 30) {
|
||||||
|
fileNameLabel->setText(fileName.left(13) + "..." + fileName.right(13));
|
||||||
|
}
|
||||||
|
|
||||||
// Layout for the overlay
|
// Layout for the overlay
|
||||||
QHBoxLayout* overlayLayout = new QHBoxLayout(overlay);
|
QHBoxLayout* overlayLayout = new QHBoxLayout(overlay);
|
||||||
@@ -143,6 +237,8 @@ void ImageGallery::displayImages(const QList<DatabaseManager::ImageItem> &images
|
|||||||
overlayLayout->addWidget(fileNameLabel);
|
overlayLayout->addWidget(fileNameLabel);
|
||||||
|
|
||||||
// Position the overlay at the bottom of the thumbnail
|
// Position the overlay at the bottom of the thumbnail
|
||||||
|
// Width and position will be adjusted in updateGridLayout()
|
||||||
|
overlay->setFixedWidth(thumbnailLabel->width());
|
||||||
overlay->move(0, THUMBNAIL_HEIGHT - overlay->height());
|
overlay->move(0, THUMBNAIL_HEIGHT - overlay->height());
|
||||||
|
|
||||||
// Connect the thumbnail click signal
|
// Connect the thumbnail click signal
|
||||||
@@ -169,10 +265,32 @@ void ImageGallery::displayImages(const QList<DatabaseManager::ImageItem> &images
|
|||||||
noImagesLabel->setAlignment(Qt::AlignCenter);
|
noImagesLabel->setAlignment(Qt::AlignCenter);
|
||||||
m_gridLayout->addWidget(noImagesLabel, 0, 0, 1, m_columnsCount);
|
m_gridLayout->addWidget(noImagesLabel, 0, 0, 1, m_columnsCount);
|
||||||
m_thumbnails.append(noImagesLabel);
|
m_thumbnails.append(noImagesLabel);
|
||||||
|
} else if (images.size() > 0) {
|
||||||
|
// This final row + 1 position is where we'll add the Load More button
|
||||||
|
// or nothing if we don't have more images
|
||||||
|
|
||||||
|
if (m_hasMoreImages) {
|
||||||
|
// Update Load More button text with current count info
|
||||||
|
m_loadMoreButton->setText(tr("Load More Images (%1 of %2)")
|
||||||
|
.arg(m_currentOffset)
|
||||||
|
.arg(m_totalCount));
|
||||||
|
|
||||||
|
// Add to layout and show
|
||||||
|
m_gridLayout->addWidget(m_loadMoreButton, row + 1, 0, 1, m_columnsCount, Qt::AlignCenter);
|
||||||
|
m_loadMoreButton->setVisible(true);
|
||||||
|
m_loadMoreButton->raise(); // Ensure it's on top
|
||||||
|
|
||||||
|
qDebug() << "Added Load More button at row" << row + 1 << "showing"
|
||||||
|
<< m_currentOffset << "of" << m_totalCount;
|
||||||
|
} else {
|
||||||
|
m_loadMoreButton->setVisible(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add stretch to the bottom of the grid
|
m_loadingIndicator->setVisible(false);
|
||||||
m_gridLayout->setRowStretch(row + 1, 1);
|
}
|
||||||
|
|
||||||
|
// Add stretch to the bottom of the grid, but after the Load More button
|
||||||
|
m_gridLayout->setRowStretch(row + 3, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
void ImageGallery::clearGallery()
|
void ImageGallery::clearGallery()
|
||||||
@@ -190,11 +308,219 @@ void ImageGallery::clearGallery()
|
|||||||
void ImageGallery::handleSearchTextChanged(const QString &searchText)
|
void ImageGallery::handleSearchTextChanged(const QString &searchText)
|
||||||
{
|
{
|
||||||
if (m_dbManager) {
|
if (m_dbManager) {
|
||||||
QList<DatabaseManager::ImageItem> images = m_dbManager->searchImages(searchText);
|
// Clear existing search results
|
||||||
displayImages(images);
|
clearGallery();
|
||||||
|
|
||||||
|
// Store the search text for tracking
|
||||||
|
m_lastSearchQuery = searchText;
|
||||||
|
|
||||||
|
// Reset pagination for new search
|
||||||
|
m_currentOffset = 0;
|
||||||
|
m_totalCount = 0;
|
||||||
|
m_hasMoreImages = false;
|
||||||
|
m_isLoading = true;
|
||||||
|
|
||||||
|
// Show loading indicator
|
||||||
|
m_loadingIndicator->setText(tr("Searching..."));
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ImageGallery::handleSearchResults(const QList<DatabaseManager::ImageItem> &results,
|
||||||
|
const QString &searchText, int offset, int limit,
|
||||||
|
int totalCount)
|
||||||
|
{
|
||||||
|
// Only update display if this is the result for the most recent search
|
||||||
|
if (searchText == m_lastSearchQuery) {
|
||||||
|
m_isLoading = false;
|
||||||
|
m_totalCount = totalCount;
|
||||||
|
|
||||||
|
// If this is the first page or a different search
|
||||||
|
bool isFirstPage = (offset == 0);
|
||||||
|
|
||||||
|
// Display images, clearing existing ones only if this is first page
|
||||||
|
displayImages(results, isFirstPage);
|
||||||
|
|
||||||
|
// Update pagination state
|
||||||
|
m_currentOffset = offset + results.size();
|
||||||
|
m_totalCount = totalCount;
|
||||||
|
m_hasMoreImages = m_currentOffset < totalCount;
|
||||||
|
|
||||||
|
// Always hide the loading indicator when search completes
|
||||||
|
m_loadingIndicator->hide();
|
||||||
|
|
||||||
|
// Update Load More button if we have more images
|
||||||
|
// Update Load More button state based on whether there are more images
|
||||||
|
if (m_hasMoreImages) {
|
||||||
|
// Update button text with current counts
|
||||||
|
m_loadMoreButton->setText(tr("Load More Images (%1 of %2)")
|
||||||
|
.arg(m_currentOffset)
|
||||||
|
.arg(totalCount));
|
||||||
|
|
||||||
|
// Add to layout at bottom of grid and ensure it's visible
|
||||||
|
int row = (m_thumbnails.size() / m_columnsCount);
|
||||||
|
m_gridLayout->removeWidget(m_loadMoreButton); // Remove from any previous location
|
||||||
|
m_gridLayout->addWidget(m_loadMoreButton, row + 1, 0, 1, m_columnsCount, Qt::AlignCenter);
|
||||||
|
m_loadMoreButton->setVisible(true);
|
||||||
|
m_loadMoreButton->raise(); // Ensure it's on top
|
||||||
|
|
||||||
|
qDebug() << "Search results - Added Load More button at row" << row + 1
|
||||||
|
<< "showing" << m_currentOffset << "of" << totalCount;
|
||||||
|
} else {
|
||||||
|
// Hide both indicators if no more images
|
||||||
|
m_loadingIndicator->setVisible(false);
|
||||||
|
m_loadMoreButton->setVisible(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method to append more images to the gallery
|
||||||
|
void ImageGallery::appendImages(const QList<DatabaseManager::ImageItem> &images)
|
||||||
|
{
|
||||||
|
// Call displayImages with clearExisting=false
|
||||||
|
displayImages(images, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle search start event
|
||||||
|
void ImageGallery::handleSearchStarted(const QString &searchText)
|
||||||
|
{
|
||||||
|
// Show loading indicator or status message
|
||||||
|
// This is called when a search operation begins
|
||||||
|
|
||||||
|
// Set loading state
|
||||||
|
m_isLoading = true;
|
||||||
|
|
||||||
|
// Only show "Searching" indicator when search actually begins (not during typing)
|
||||||
|
if (!m_thumbnails.isEmpty() && !m_lastSearchQuery.isEmpty() && searchText != m_lastSearchQuery) {
|
||||||
|
// New search, keep thumbnails but show loading overlay
|
||||||
|
m_loadingIndicator->setText(tr("Searching..."));
|
||||||
|
m_loadingIndicator->show();
|
||||||
|
|
||||||
|
// Move loading indicator to be visible
|
||||||
|
m_scrollArea->verticalScrollBar()->setValue(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load more images when scrolled to bottom
|
||||||
|
void ImageGallery::loadMoreImages()
|
||||||
|
{
|
||||||
|
if (!m_dbManager || m_isLoading || !m_hasMoreImages) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_isLoading = true;
|
||||||
|
|
||||||
|
// Hide the Load More button while loading
|
||||||
|
m_loadMoreButton->hide();
|
||||||
|
|
||||||
|
// Show loading indicator
|
||||||
|
m_loadingIndicator->setText(tr("Loading more images (%1 of %2)...")
|
||||||
|
.arg(m_currentOffset)
|
||||||
|
.arg(m_totalCount));
|
||||||
|
m_loadingIndicator->show();
|
||||||
|
|
||||||
|
// Position loading indicator at bottom of grid
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle scroll to bottom event
|
||||||
|
void ImageGallery::handleScrolledToBottom()
|
||||||
|
{
|
||||||
|
if (!m_isLoading && m_hasMoreImages) {
|
||||||
|
qDebug() << "Loading more images from handleScrolledToBottom";
|
||||||
|
loadMoreImages();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Load More button click
|
||||||
|
void ImageGallery::handleLoadMoreClicked()
|
||||||
|
{
|
||||||
|
if (!m_isLoading && m_hasMoreImages) {
|
||||||
|
qDebug() << "Loading more images from Load More button";
|
||||||
|
|
||||||
|
// Hide the button and show loading indicator during loading
|
||||||
|
m_loadMoreButton->hide();
|
||||||
|
m_loadingIndicator->setText(tr("Loading images..."));
|
||||||
|
m_loadingIndicator->show();
|
||||||
|
int row = (m_thumbnails.size() / m_columnsCount);
|
||||||
|
m_gridLayout->addWidget(m_loadingIndicator, row + 1, 0, 1, m_columnsCount);
|
||||||
|
|
||||||
|
// Request more images
|
||||||
|
loadMoreImages();
|
||||||
|
|
||||||
|
// Debug output
|
||||||
|
qDebug() << "Total thumbnails:" << m_thumbnails.size()
|
||||||
|
<< "Grid size:" << m_gridLayout->rowCount() << "x" << m_gridLayout->columnCount()
|
||||||
|
<< "Has more:" << m_hasMoreImages;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set page size for lazy loading
|
||||||
|
void ImageGallery::setPageSize(int pageSize)
|
||||||
|
{
|
||||||
|
if (pageSize > 0) {
|
||||||
|
m_pageSize = pageSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset pagination state to initial values
|
||||||
|
void ImageGallery::resetPagination()
|
||||||
|
{
|
||||||
|
// Reset pagination state variables
|
||||||
|
m_currentOffset = 0;
|
||||||
|
m_isLoading = false;
|
||||||
|
m_hasMoreImages = false;
|
||||||
|
m_totalCount = 0;
|
||||||
|
|
||||||
|
// Hide loading indicators
|
||||||
|
if (m_loadingIndicator) {
|
||||||
|
m_loadingIndicator->hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide "Load More" button until we know there are more images
|
||||||
|
if (m_loadMoreButton) {
|
||||||
|
m_loadMoreButton->hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override wheel event to detect end of scrolling
|
||||||
|
void ImageGallery::wheelEvent(QWheelEvent *event)
|
||||||
|
{
|
||||||
|
QWidget::wheelEvent(event);
|
||||||
|
|
||||||
|
// Don't check scroll position during the wheel event
|
||||||
|
// This will be handled by the eventFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
void ImageGallery::updateSearchTextDisplay(const QString &searchText)
|
||||||
|
{
|
||||||
|
// Update the UI to reflect the current search text without performing a search
|
||||||
|
// This gives immediate feedback while typing, before the actual search happens
|
||||||
|
|
||||||
|
// Store the search text for tracking
|
||||||
|
m_lastSearchQuery = searchText;
|
||||||
|
|
||||||
|
// Keep existing thumbnails visible without distracting visual changes
|
||||||
|
|
||||||
|
// If we have a "no results" message showing, update it quietly
|
||||||
|
for (auto thumbnail : m_thumbnails) {
|
||||||
|
if (thumbnail->text().contains("No images found")) {
|
||||||
|
thumbnail->setText(tr("Search in progress..."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No typing indicator or popups while typing
|
||||||
|
// Just wait for the actual search to complete
|
||||||
|
}
|
||||||
|
|
||||||
void ImageGallery::handleThumbnailClicked(const QString &filePath)
|
void ImageGallery::handleThumbnailClicked(const QString &filePath)
|
||||||
{
|
{
|
||||||
// Show a wait cursor while attempting to open the file
|
// Show a wait cursor while attempting to open the file
|
||||||
@@ -260,7 +586,7 @@ void ImageGallery::resizeEvent(QResizeEvent *event)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event filter to catch viewport resize events
|
// Event filter to catch viewport resize events and scroll events
|
||||||
bool ImageGallery::eventFilter(QObject *watched, QEvent *event)
|
bool ImageGallery::eventFilter(QObject *watched, QEvent *event)
|
||||||
{
|
{
|
||||||
// Check if this is a resize event on the viewport
|
// Check if this is a resize event on the viewport
|
||||||
@@ -270,6 +596,33 @@ bool ImageGallery::eventFilter(QObject *watched, QEvent *event)
|
|||||||
return false; // Allow event to propagate
|
return false; // Allow event to propagate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for various scroll-related events
|
||||||
|
if (watched == m_scrollArea->viewport() &&
|
||||||
|
(event->type() == QEvent::Wheel ||
|
||||||
|
event->type() == QEvent::MouseMove ||
|
||||||
|
event->type() == QEvent::MouseButtonRelease)) {
|
||||||
|
|
||||||
|
QScrollBar* vScrollBar = m_scrollArea->verticalScrollBar();
|
||||||
|
if (vScrollBar) {
|
||||||
|
int max = vScrollBar->maximum();
|
||||||
|
int current = vScrollBar->value();
|
||||||
|
int viewportHeight = m_scrollArea->viewport()->height();
|
||||||
|
|
||||||
|
// If we're near the bottom and not already loading, prepare to load more
|
||||||
|
// Use a percentage of viewport height as threshold for very tall viewports
|
||||||
|
int dynamicThreshold = qMin(LOAD_THRESHOLD_PX, viewportHeight / 4);
|
||||||
|
|
||||||
|
if ((max - current) <= dynamicThreshold && max > 0 && !m_isLoading && m_hasMoreImages) {
|
||||||
|
// Cancel any pending timer and start a new one for debouncing
|
||||||
|
m_scrollTimer->stop();
|
||||||
|
m_scrollTimer->start();
|
||||||
|
|
||||||
|
// We'll rely on the Load More button for explicit user action
|
||||||
|
// rather than showing a loading indicator during scroll
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Pass unhandled events to parent
|
// Pass unhandled events to parent
|
||||||
return QWidget::eventFilter(watched, event);
|
return QWidget::eventFilter(watched, event);
|
||||||
}
|
}
|
||||||
@@ -345,6 +698,13 @@ void ImageGallery::updateGridLayout()
|
|||||||
for (auto thumbnail : m_thumbnails) {
|
for (auto thumbnail : m_thumbnails) {
|
||||||
thumbnail->setMaximumWidth(THUMBNAIL_WIDTH);
|
thumbnail->setMaximumWidth(THUMBNAIL_WIDTH);
|
||||||
thumbnail->setAlignment(Qt::AlignCenter);
|
thumbnail->setAlignment(Qt::AlignCenter);
|
||||||
|
|
||||||
|
// Update the overlay to match thumbnail width
|
||||||
|
QFrame* overlay = thumbnail->findChild<QFrame*>("filenameOverlay");
|
||||||
|
if (overlay) {
|
||||||
|
overlay->setFixedWidth(THUMBNAIL_WIDTH);
|
||||||
|
overlay->move(0, THUMBNAIL_HEIGHT - overlay->height());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// For multi-column, use minimal margins
|
// For multi-column, use minimal margins
|
||||||
@@ -353,12 +713,31 @@ void ImageGallery::updateGridLayout()
|
|||||||
// Reset thumbnail constraints
|
// Reset thumbnail constraints
|
||||||
for (auto thumbnail : m_thumbnails) {
|
for (auto thumbnail : m_thumbnails) {
|
||||||
thumbnail->setMaximumWidth(QWIDGETSIZE_MAX);
|
thumbnail->setMaximumWidth(QWIDGETSIZE_MAX);
|
||||||
|
|
||||||
|
// Update the overlay to match thumbnail width in multi-column mode
|
||||||
|
QFrame* overlay = thumbnail->findChild<QFrame*>("filenameOverlay");
|
||||||
|
if (overlay) {
|
||||||
|
overlay->setFixedWidth(thumbnail->width());
|
||||||
|
overlay->move(0, THUMBNAIL_HEIGHT - overlay->height());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For multi-column, let the container fill the viewport
|
// For multi-column, let the container fill the viewport
|
||||||
m_containerWidget->setMinimumWidth(viewportWidth);
|
m_containerWidget->setMinimumWidth(viewportWidth);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Force update of overlay position and size after a short delay
|
||||||
|
// This ensures the overlays are sized correctly after all layout changes
|
||||||
|
QTimer::singleShot(50, this, [this]() {
|
||||||
|
for (auto thumbnail : m_thumbnails) {
|
||||||
|
QFrame* overlay = thumbnail->findChild<QFrame*>("filenameOverlay");
|
||||||
|
if (overlay) {
|
||||||
|
overlay->setFixedWidth(thumbnail->width());
|
||||||
|
overlay->move(0, THUMBNAIL_HEIGHT - overlay->height());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Update the layout again after a short delay to handle edge cases
|
// Update the layout again after a short delay to handle edge cases
|
||||||
QTimer::singleShot(10, this, [this](){
|
QTimer::singleShot(10, this, [this](){
|
||||||
m_gridLayout->update();
|
m_gridLayout->update();
|
||||||
|
|||||||
+31
-1
@@ -11,6 +11,7 @@
|
|||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
#include <QResizeEvent>
|
#include <QResizeEvent>
|
||||||
|
#include <QPushButton>
|
||||||
#include "databasemanager.h"
|
#include "databasemanager.h"
|
||||||
|
|
||||||
// Global constants
|
// Global constants
|
||||||
@@ -46,18 +47,29 @@ public:
|
|||||||
~ImageGallery();
|
~ImageGallery();
|
||||||
|
|
||||||
void setDatabaseManager(DatabaseManager *dbManager);
|
void setDatabaseManager(DatabaseManager *dbManager);
|
||||||
void displayImages(const QList<DatabaseManager::ImageItem> &images);
|
void displayImages(const QList<DatabaseManager::ImageItem> &images, bool clearExisting = true);
|
||||||
|
void appendImages(const QList<DatabaseManager::ImageItem> &images);
|
||||||
void clearGallery();
|
void clearGallery();
|
||||||
|
void loadMoreImages();
|
||||||
|
void setPageSize(int pageSize); // Set the number of images to load per page
|
||||||
|
void resetPagination(); // Reset pagination state to initial values
|
||||||
|
|
||||||
public slots:
|
public slots:
|
||||||
void handleSearchTextChanged(const QString &searchText);
|
void handleSearchTextChanged(const QString &searchText);
|
||||||
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,
|
||||||
|
int offset, int limit, int totalCount);
|
||||||
|
void handleSearchStarted(const QString &searchText);
|
||||||
|
void updateSearchTextDisplay(const QString &searchText); // Update UI with search text without performing search
|
||||||
|
void handleScrolledToBottom();
|
||||||
|
void handleLoadMoreClicked();
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
void resizeEvent(QResizeEvent *event) override;
|
void resizeEvent(QResizeEvent *event) override;
|
||||||
bool eventFilter(QObject *watched, QEvent *event) override;
|
bool eventFilter(QObject *watched, QEvent *event) override;
|
||||||
|
void wheelEvent(QWheelEvent *event) override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
QGridLayout *m_gridLayout;
|
QGridLayout *m_gridLayout;
|
||||||
@@ -67,8 +79,26 @@ private:
|
|||||||
QList<ImageThumbnail*> m_thumbnails;
|
QList<ImageThumbnail*> m_thumbnails;
|
||||||
int m_columnsCount; // Dynamic column count based on window size
|
int m_columnsCount; // Dynamic column count based on window size
|
||||||
|
|
||||||
|
// Pagination variables
|
||||||
|
int m_currentOffset;
|
||||||
|
int m_pageSize;
|
||||||
|
int m_totalCount;
|
||||||
|
bool m_isLoading;
|
||||||
|
bool m_hasMoreImages;
|
||||||
|
QLabel *m_loadingIndicator;
|
||||||
|
QPushButton *m_loadMoreButton;
|
||||||
|
|
||||||
QPixmap createThumbnail(const QString &filePath, int width, int height);
|
QPixmap createThumbnail(const QString &filePath, int width, int height);
|
||||||
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
|
||||||
|
|
||||||
|
// Track scroll position
|
||||||
|
int m_lastScrollPosition;
|
||||||
|
QTimer *m_scrollTimer;
|
||||||
|
|
||||||
|
// Lazy loading constants
|
||||||
|
static constexpr int DEFAULT_PAGE_SIZE = 20;
|
||||||
|
static constexpr int LOAD_THRESHOLD_PX = 100; // Pixels from bottom to trigger load
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // IMAGEGALLERY_H
|
#endif // IMAGEGALLERY_H
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
|
#include <QIcon>
|
||||||
#include "mainwindow.h"
|
#include "mainwindow.h"
|
||||||
|
|
||||||
int main(int argc, char *argv[])
|
int main(int argc, char *argv[])
|
||||||
@@ -9,6 +10,14 @@ int main(int argc, char *argv[])
|
|||||||
app.setApplicationName("Screenshot Gallery");
|
app.setApplicationName("Screenshot Gallery");
|
||||||
app.setApplicationVersion("1.0.0");
|
app.setApplicationVersion("1.0.0");
|
||||||
|
|
||||||
|
// Set application icon
|
||||||
|
QIcon appIcon;
|
||||||
|
appIcon.addFile(":/icons/orcs-gallery-64.png", QSize(64, 64));
|
||||||
|
appIcon.addFile(":/icons/orcs-gallery-128.png", QSize(128, 128));
|
||||||
|
appIcon.addFile(":/icons/orcs-gallery-256.png", QSize(256, 256));
|
||||||
|
appIcon.addFile(":/icons/orcs-gallery-512.png", QSize(512, 512));
|
||||||
|
app.setWindowIcon(appIcon);
|
||||||
|
|
||||||
// Create and show main window
|
// Create and show main window
|
||||||
MainWindow mainWindow;
|
MainWindow mainWindow;
|
||||||
mainWindow.show();
|
mainWindow.show();
|
||||||
|
|||||||
+300
-45
@@ -1,24 +1,47 @@
|
|||||||
#include "mainwindow.h"
|
#include "mainwindow.h"
|
||||||
|
#include "settingsdialog.h"
|
||||||
|
#include <QIcon>
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
#include <QScreen>
|
#include <QScreen>
|
||||||
#include <QResizeEvent>
|
#include <QResizeEvent>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
|
#include <QInputDialog>
|
||||||
|
#include <QMenuBar>
|
||||||
|
#include <QMenu>
|
||||||
|
#include <QAction>
|
||||||
|
#include <QFileDialog>
|
||||||
|
#include <QDesktopServices>
|
||||||
|
#include <QSettings>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
|
|
||||||
// Define the static constant for the default database path
|
// Define settings file path
|
||||||
const QString MainWindow::DEFAULT_DB_PATH = "/home/master/screenshot_ocr.db";
|
const QString MainWindow::CONFIG_FILE_PATH = QDir::homePath() + "/.config/ScreenshotOCRGallery/settings.ini";
|
||||||
|
|
||||||
MainWindow::MainWindow(QWidget *parent)
|
MainWindow::MainWindow(QWidget *parent)
|
||||||
: QMainWindow(parent)
|
: QMainWindow(parent)
|
||||||
, m_dbManager(new DatabaseManager(this))
|
, m_dbManager(new DatabaseManager(this))
|
||||||
, m_searchDelayTimer(new QTimer(this))
|
, m_typingTimer(new QTimer(this))
|
||||||
|
, m_searchingAnimationTimer(new QTimer(this))
|
||||||
|
, m_searchingDots(1)
|
||||||
|
, m_isTyping(false)
|
||||||
, m_hasValidDatabase(false)
|
, m_hasValidDatabase(false)
|
||||||
|
, m_databasePath("")
|
||||||
|
, m_screenshotsDir("")
|
||||||
|
, m_imagePreloadCount(DEFAULT_PRELOAD_COUNT)
|
||||||
{
|
{
|
||||||
// Set window title and size
|
// Set window title and size
|
||||||
setWindowTitle(tr("Screenshot OCR Gallery"));
|
setWindowTitle(tr("Screenshot OCR Gallery"));
|
||||||
resize(DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT);
|
resize(DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT);
|
||||||
|
|
||||||
|
// Set window icon
|
||||||
|
QIcon appIcon;
|
||||||
|
appIcon.addFile(":/icons/orcs-gallery-64.png", QSize(64, 64));
|
||||||
|
appIcon.addFile(":/icons/orcs-gallery-128.png", QSize(128, 128));
|
||||||
|
appIcon.addFile(":/icons/orcs-gallery-256.png", QSize(256, 256));
|
||||||
|
appIcon.addFile(":/icons/orcs-gallery-512.png", QSize(512, 512));
|
||||||
|
setWindowIcon(appIcon);
|
||||||
|
|
||||||
// Remove fixed minimum size to allow for single column layout at any width
|
// Remove fixed minimum size to allow for single column layout at any width
|
||||||
// setMinimumSize(640, 480);
|
// setMinimumSize(640, 480);
|
||||||
|
|
||||||
@@ -31,20 +54,61 @@ MainWindow::MainWindow(QWidget *parent)
|
|||||||
(availableGeometry.height() - size.height()) / 2);
|
(availableGeometry.height() - size.height()) / 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load settings first
|
||||||
|
loadSettings();
|
||||||
|
|
||||||
// Set up UI
|
// Set up UI
|
||||||
createLayout();
|
createLayout();
|
||||||
|
|
||||||
// Initialize timer for delayed search
|
// Initialize typing inactivity timer
|
||||||
m_searchDelayTimer->setSingleShot(true);
|
m_typingTimer->setSingleShot(true);
|
||||||
m_searchDelayTimer->setInterval(SEARCH_DELAY_MS);
|
m_typingTimer->setInterval(500); // 500ms of no typing before searching
|
||||||
connect(m_searchDelayTimer, &QTimer::timeout, this, &MainWindow::performSearch);
|
connect(m_typingTimer, &QTimer::timeout, this, &MainWindow::handleTypingInactivityTimeout);
|
||||||
|
|
||||||
|
// Only create animation timer for searching visual feedback
|
||||||
|
|
||||||
|
m_searchingAnimationTimer = new QTimer(this);
|
||||||
|
m_searchingAnimationTimer->setInterval(200); // Animation speed for searching dots
|
||||||
|
m_searchingDots = 1;
|
||||||
|
connect(m_searchingAnimationTimer, &QTimer::timeout, this, [this]() {
|
||||||
|
m_searchingDots = (m_searchingDots % 3) + 1; // Cycle between 1, 2, and 3 dots
|
||||||
|
QString dots;
|
||||||
|
for(int i = 0; i < m_searchingDots; ++i) dots += ".";
|
||||||
|
QString searchingIndicator = QString("%1 %2").arg(tr("Searching")).arg(dots);
|
||||||
|
statusBar()->showMessage(searchingIndicator);
|
||||||
|
});
|
||||||
|
|
||||||
// Initialize database and display images
|
// Initialize database and display images
|
||||||
initializeDatabase();
|
initializeDatabase();
|
||||||
|
|
||||||
|
// Connect database signals for status updates
|
||||||
|
if (m_dbManager) {
|
||||||
|
connect(m_dbManager, &DatabaseManager::searchStarted,
|
||||||
|
this, [this](const QString &text) {
|
||||||
|
statusBar()->showMessage(tr("Searching..."), 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
connect(m_dbManager, &DatabaseManager::searchResultsReady,
|
||||||
|
this, [this](const QList<DatabaseManager::ImageItem> &results, const QString &text,
|
||||||
|
int offset, int limit, int totalCount) {
|
||||||
|
if (text.isEmpty()) {
|
||||||
|
statusBar()->showMessage(tr("Showing all images (paginated) - %1 total").arg(totalCount));
|
||||||
|
} else {
|
||||||
|
statusBar()->showMessage(tr("Found %1 results for \"%2\" (showing %3 to %4)")
|
||||||
|
.arg(totalCount)
|
||||||
|
.arg(text)
|
||||||
|
.arg(offset - results.size() + 1)
|
||||||
|
.arg(offset), 3000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
displayAllImages();
|
displayAllImages();
|
||||||
|
|
||||||
// Set focus to search bar
|
// Set focus to search bar and disable autocomplete which can cause UI lag
|
||||||
m_searchBar->setFocus();
|
m_searchBar->setFocus();
|
||||||
|
m_searchBar->setAutoFillBackground(true);
|
||||||
|
m_searchBar->setAttribute(Qt::WA_MacShowFocusRect, false); // Reduce drawing overhead
|
||||||
|
|
||||||
// Set status bar
|
// Set status bar
|
||||||
statusBar()->showMessage(tr("Ready"));
|
statusBar()->showMessage(tr("Ready"));
|
||||||
@@ -67,13 +131,42 @@ void MainWindow::createLayout()
|
|||||||
m_mainLayout->setSpacing(5); // Reduce spacing between elements
|
m_mainLayout->setSpacing(5); // Reduce spacing between elements
|
||||||
m_mainLayout->setContentsMargins(5, 5, 5, 5); // Reduce margins
|
m_mainLayout->setContentsMargins(5, 5, 5, 5); // Reduce margins
|
||||||
|
|
||||||
// Create title label
|
// Title removed - now shown only in the title bar
|
||||||
m_titleLabel = new QLabel(tr("Screenshot OCR Gallery"), this);
|
|
||||||
QFont titleFont = m_titleLabel->font();
|
// Create menu bar
|
||||||
titleFont.setPointSize(16);
|
QMenuBar *menuBar = new QMenuBar(this);
|
||||||
titleFont.setBold(true);
|
setMenuBar(menuBar);
|
||||||
m_titleLabel->setFont(titleFont);
|
|
||||||
m_titleLabel->setAlignment(Qt::AlignCenter);
|
// Create File menu
|
||||||
|
QMenu *fileMenu = menuBar->addMenu(tr("&File"));
|
||||||
|
|
||||||
|
// Add Open action
|
||||||
|
QAction *openAction = new QAction(tr("&Open"), this);
|
||||||
|
openAction->setShortcut(QKeySequence::Open);
|
||||||
|
connect(openAction, &QAction::triggered, this, &MainWindow::handleOpenFile);
|
||||||
|
fileMenu->addAction(openAction);
|
||||||
|
|
||||||
|
// Add Open With action
|
||||||
|
QAction *openWithAction = new QAction(tr("Open &With..."), this);
|
||||||
|
connect(openWithAction, &QAction::triggered, this, &MainWindow::handleOpenFileWith);
|
||||||
|
fileMenu->addAction(openWithAction);
|
||||||
|
|
||||||
|
// Add separator
|
||||||
|
fileMenu->addSeparator();
|
||||||
|
|
||||||
|
// Add Settings action
|
||||||
|
QAction *settingsAction = new QAction(tr("&Settings"), this);
|
||||||
|
connect(settingsAction, &QAction::triggered, this, &MainWindow::handleSettings);
|
||||||
|
fileMenu->addAction(settingsAction);
|
||||||
|
|
||||||
|
// Add separator
|
||||||
|
fileMenu->addSeparator();
|
||||||
|
|
||||||
|
// Add Quit action
|
||||||
|
QAction *quitAction = new QAction(tr("&Quit"), this);
|
||||||
|
quitAction->setShortcut(QKeySequence::Quit);
|
||||||
|
connect(quitAction, &QAction::triggered, qApp, &QApplication::quit);
|
||||||
|
fileMenu->addAction(quitAction);
|
||||||
|
|
||||||
// Create search bar
|
// Create search bar
|
||||||
m_searchBar = new QLineEdit(this);
|
m_searchBar = new QLineEdit(this);
|
||||||
@@ -85,34 +178,33 @@ void MainWindow::createLayout()
|
|||||||
m_imageGallery->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
|
m_imageGallery->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
|
||||||
|
|
||||||
// Add widgets to layout
|
// Add widgets to layout
|
||||||
m_mainLayout->addWidget(m_titleLabel);
|
|
||||||
m_mainLayout->addWidget(m_searchBar);
|
m_mainLayout->addWidget(m_searchBar);
|
||||||
m_mainLayout->addWidget(m_imageGallery, 1);
|
m_mainLayout->addWidget(m_imageGallery, 1);
|
||||||
|
|
||||||
// Connect signals
|
// Connect signals - use textEdited instead of textChanged to handle only user input
|
||||||
connect(m_searchBar, &QLineEdit::textChanged, this, &MainWindow::handleSearchTextChanged);
|
connect(m_searchBar, &QLineEdit::textEdited, this, &MainWindow::handleSearchTextChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::initializeDatabase()
|
void MainWindow::initializeDatabase()
|
||||||
{
|
{
|
||||||
// Check if database file exists
|
// Check if database file exists
|
||||||
QFileInfo dbFileInfo(DEFAULT_DB_PATH);
|
QFileInfo dbFileInfo(m_databasePath);
|
||||||
if (!dbFileInfo.exists() || !dbFileInfo.isFile()) {
|
if (!dbFileInfo.exists() || !dbFileInfo.isFile()) {
|
||||||
QString errorMsg = tr("Database file not found: %1").arg(DEFAULT_DB_PATH);
|
QString errorMsg = tr("Database file not found: %1").arg(m_databasePath);
|
||||||
statusBar()->showMessage(errorMsg, 10000);
|
statusBar()->showMessage(errorMsg, 10000);
|
||||||
QMessageBox::critical(this, tr("Database Error"), errorMsg);
|
QMessageBox::critical(this, tr("Database Error"), errorMsg);
|
||||||
qDebug() << "Database file not found:" << DEFAULT_DB_PATH;
|
qDebug() << "Database file not found:" << m_databasePath;
|
||||||
m_hasValidDatabase = false;
|
m_hasValidDatabase = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to initialize database
|
// Try to initialize database
|
||||||
if (!m_dbManager->initialize(DEFAULT_DB_PATH)) {
|
if (!m_dbManager->initialize(m_databasePath)) {
|
||||||
QString errorMsg = tr("Failed to connect to database");
|
QString errorMsg = tr("Failed to connect to database");
|
||||||
statusBar()->showMessage(errorMsg, 10000);
|
statusBar()->showMessage(errorMsg, 10000);
|
||||||
QMessageBox::warning(this, tr("Database Error"),
|
QMessageBox::warning(this, tr("Database Error"),
|
||||||
tr("Failed to connect to database: %1\nThe application will continue with limited functionality.")
|
tr("Failed to connect to database: %1\nThe application will continue with limited functionality.")
|
||||||
.arg(DEFAULT_DB_PATH));
|
.arg(m_databasePath));
|
||||||
qDebug() << "Failed to initialize database";
|
qDebug() << "Failed to initialize database";
|
||||||
m_hasValidDatabase = false;
|
m_hasValidDatabase = false;
|
||||||
} else {
|
} else {
|
||||||
@@ -130,12 +222,25 @@ void MainWindow::displayAllImages()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
QList<DatabaseManager::ImageItem> allImages = m_dbManager->getAllImages();
|
// Get total image count first
|
||||||
|
int totalImageCount = m_dbManager->getImageCount();
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
statusBar()->showMessage(tr("Loaded %1 of %2 images").arg(allImages.size()).arg(totalImageCount), 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
m_imageGallery->displayImages(allImages);
|
// 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);
|
||||||
|
|
||||||
updateStatusBar();
|
updateStatusBar();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,40 +251,191 @@ void MainWindow::handleSearchTextChanged()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capture current text
|
||||||
|
QString currentText = m_searchBar->text();
|
||||||
|
|
||||||
// If search bar is cleared, immediately show all images
|
// If search bar is cleared, immediately show all images
|
||||||
if (m_searchBar->text().isEmpty()) {
|
if (currentText.isEmpty()) {
|
||||||
m_lastSearchText.clear();
|
m_lastSearchText.clear();
|
||||||
m_searchDelayTimer->stop();
|
m_typingTimer->stop();
|
||||||
|
// No typing animation to stop
|
||||||
|
m_searchingAnimationTimer->stop();
|
||||||
|
m_isTyping = false;
|
||||||
|
// No typing dots to reset
|
||||||
|
m_searchingDots = 1;
|
||||||
|
|
||||||
|
// Show loading status
|
||||||
|
statusBar()->showMessage(tr("Loading gallery..."));
|
||||||
|
|
||||||
|
// First, explicitly reset current offset in gallery
|
||||||
|
m_imageGallery->resetPagination();
|
||||||
|
|
||||||
|
// Clear gallery and load first page
|
||||||
displayAllImages(); // Show all images immediately
|
displayAllImages(); // Show all images immediately
|
||||||
return; // Skip the timer since we've already updated
|
return; // Skip the timer since we've already updated
|
||||||
}
|
}
|
||||||
|
|
||||||
// For non-empty searches, restart the timer each time the user types
|
// Update last search text
|
||||||
m_searchDelayTimer->start();
|
m_lastSearchText = currentText;
|
||||||
|
|
||||||
|
// Indicate user is typing and reset the inactivity timer
|
||||||
|
m_isTyping = true;
|
||||||
|
m_typingTimer->start();
|
||||||
|
|
||||||
|
// Don't show any typing indicator in the status bar
|
||||||
|
// Only update the search text display without animation
|
||||||
|
|
||||||
|
// Update the search text display but don't search yet
|
||||||
|
m_imageGallery->updateSearchTextDisplay(m_lastSearchText);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::handleTypingInactivityTimeout()
|
||||||
|
{
|
||||||
|
// User has stopped typing for 500ms, perform the search
|
||||||
|
m_isTyping = false;
|
||||||
|
|
||||||
|
// Start search animation directly without typing animation
|
||||||
|
|
||||||
|
// Update the status bar with search indicator
|
||||||
|
QString dots;
|
||||||
|
for(int i = 0; i < m_searchingDots; ++i) dots += ".";
|
||||||
|
QString searchingIndicator = QString("%1 %2").arg(tr("Searching")).arg(dots);
|
||||||
|
statusBar()->showMessage(searchingIndicator);
|
||||||
|
|
||||||
|
// Start the searching animation
|
||||||
|
if (!m_searchingAnimationTimer->isActive()) {
|
||||||
|
m_searchingAnimationTimer->start();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actually perform the search
|
||||||
|
performSearch();
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::performSearch()
|
void MainWindow::performSearch()
|
||||||
{
|
{
|
||||||
if (!m_hasValidDatabase) {
|
if (!m_hasValidDatabase || m_isTyping) {
|
||||||
statusBar()->showMessage(tr("Cannot perform search: No database connection"), 3000);
|
// Don't search if database is not available or if user is still typing
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
QString currentText = m_searchBar->text();
|
|
||||||
|
|
||||||
// Only perform search if text has changed
|
|
||||||
if (currentText != m_lastSearchText) {
|
|
||||||
m_lastSearchText = currentText;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
m_imageGallery->handleSearchTextChanged(currentText);
|
// Clear the gallery and prepare for new search with pagination
|
||||||
|
m_imageGallery->clearGallery();
|
||||||
|
|
||||||
|
// Directly pass the search to the image gallery
|
||||||
|
// The gallery will handle the threaded search operation with pagination
|
||||||
|
m_imageGallery->handleSearchTextChanged(m_lastSearchText);
|
||||||
updateStatusBar();
|
updateStatusBar();
|
||||||
|
|
||||||
|
// Show searching status
|
||||||
|
statusBar()->showMessage(tr("Searching..."), 1000);
|
||||||
} catch (const std::exception &e) {
|
} catch (const std::exception &e) {
|
||||||
QString errorMsg = tr("Search error: %1").arg(e.what());
|
QString errorMsg = tr("Search error: %1").arg(e.what());
|
||||||
statusBar()->showMessage(errorMsg, 5000);
|
statusBar()->showMessage(errorMsg, 5000);
|
||||||
qDebug() << errorMsg;
|
qDebug() << errorMsg;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// File menu action handlers
|
||||||
|
void MainWindow::handleOpenFile()
|
||||||
|
{
|
||||||
|
QString filePath = QFileDialog::getOpenFileName(this,
|
||||||
|
tr("Open Image File"), m_screenshotsDir,
|
||||||
|
tr("Image Files (*.png *.jpg *.jpeg *.bmp *.gif)"));
|
||||||
|
|
||||||
|
if (!filePath.isEmpty()) {
|
||||||
|
QDesktopServices::openUrl(QUrl::fromLocalFile(filePath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::handleOpenFileWith()
|
||||||
|
{
|
||||||
|
QString filePath = QFileDialog::getOpenFileName(this,
|
||||||
|
tr("Open Image File"), m_screenshotsDir,
|
||||||
|
tr("Image Files (*.png *.jpg *.jpeg *.bmp *.gif)"));
|
||||||
|
|
||||||
|
if (!filePath.isEmpty()) {
|
||||||
|
// Get the program to open the file with
|
||||||
|
bool ok;
|
||||||
|
QString program = QInputDialog::getText(this, tr("Open With"),
|
||||||
|
tr("Enter program name:"), QLineEdit::Normal,
|
||||||
|
"", &ok);
|
||||||
|
if (ok && !program.isEmpty()) {
|
||||||
|
QProcess *process = new QProcess(this);
|
||||||
|
connect(process, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
||||||
|
process, &QProcess::deleteLater);
|
||||||
|
|
||||||
|
process->start(program, QStringList() << filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::handleSettings()
|
||||||
|
{
|
||||||
|
SettingsDialog settingsDialog(this);
|
||||||
|
|
||||||
|
// Show the settings dialog
|
||||||
|
if (settingsDialog.exec() == QDialog::Accepted) {
|
||||||
|
// Save and apply new settings
|
||||||
|
m_databasePath = settingsDialog.getDatabasePath();
|
||||||
|
m_screenshotsDir = settingsDialog.getScreenshotsDir();
|
||||||
|
m_imagePreloadCount = settingsDialog.getImagePreloadCount();
|
||||||
|
|
||||||
|
// Apply the new settings
|
||||||
|
applySettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::loadSettings()
|
||||||
|
{
|
||||||
|
// Ensure config directory exists
|
||||||
|
QFileInfo configFile(CONFIG_FILE_PATH);
|
||||||
|
QDir configDir = configFile.absoluteDir();
|
||||||
|
if (!configDir.exists()) {
|
||||||
|
configDir.mkpath(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
QSettings settings(CONFIG_FILE_PATH, QSettings::IniFormat);
|
||||||
|
|
||||||
|
// Load database path with fallback to default
|
||||||
|
m_databasePath = settings.value("databasePath", "/home/master/screenshot_ocr.db").toString();
|
||||||
|
|
||||||
|
// Load screenshots directory with fallback to home/Screenshots
|
||||||
|
m_screenshotsDir = settings.value("screenshotsDir", QDir::homePath() + "/Screenshots").toString();
|
||||||
|
|
||||||
|
// Load preload count
|
||||||
|
m_imagePreloadCount = settings.value("imagePreloadCount", DEFAULT_PRELOAD_COUNT).toInt();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::applySettings()
|
||||||
|
{
|
||||||
|
// Re-initialize database with new path
|
||||||
|
bool wasValid = m_hasValidDatabase;
|
||||||
|
m_hasValidDatabase = false;
|
||||||
|
|
||||||
|
// Try to initialize the database with the new path
|
||||||
|
initializeDatabase();
|
||||||
|
|
||||||
|
// Show appropriate message
|
||||||
|
if (!wasValid && m_hasValidDatabase) {
|
||||||
|
statusBar()->showMessage(tr("Successfully connected to database"), 3000);
|
||||||
|
} else if (wasValid && !m_hasValidDatabase) {
|
||||||
|
statusBar()->showMessage(tr("Lost connection to database"), 5000);
|
||||||
|
} else if (!wasValid && !m_hasValidDatabase) {
|
||||||
|
statusBar()->showMessage(tr("Could not connect to database"), 5000);
|
||||||
|
} else {
|
||||||
|
// Update page size in ImageGallery
|
||||||
|
if (m_imageGallery) {
|
||||||
|
m_imageGallery->setPageSize(m_imagePreloadCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh gallery with new database
|
||||||
|
displayAllImages();
|
||||||
|
|
||||||
|
// Ensure the status is updated with correct count
|
||||||
|
int totalImageCount = m_dbManager->getImageCount();
|
||||||
|
statusBar()->showMessage(tr("Database loaded with %1 total images").arg(totalImageCount), 3000);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::updateStatusBar()
|
void MainWindow::updateStatusBar()
|
||||||
@@ -190,16 +446,15 @@ void MainWindow::updateStatusBar()
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
int imageCount = m_dbManager->searchImages(m_lastSearchText).count();
|
// Get the total count of images in the database
|
||||||
int totalImages = m_dbManager->getAllImages().count();
|
int totalImages = m_dbManager->getImageCount();
|
||||||
|
|
||||||
if (m_lastSearchText.isEmpty()) {
|
if (m_lastSearchText.isEmpty()) {
|
||||||
statusBar()->showMessage(tr("Displaying all %1 images").arg(totalImages));
|
statusBar()->showMessage(tr("Displaying images (paginated) - %1 total").arg(totalImages));
|
||||||
} else {
|
} else {
|
||||||
statusBar()->showMessage(tr("Found %1 of %2 images matching \"%3\"")
|
// Status message will be updated when search results arrive
|
||||||
.arg(imageCount)
|
// via the searchResultsReady signal
|
||||||
.arg(totalImages)
|
statusBar()->showMessage(tr("Searching..."));
|
||||||
.arg(m_lastSearchText));
|
|
||||||
}
|
}
|
||||||
} catch (const std::exception &e) {
|
} catch (const std::exception &e) {
|
||||||
statusBar()->showMessage(tr("Error updating status: %1").arg(e.what()), 5000);
|
statusBar()->showMessage(tr("Error updating status: %1").arg(e.what()), 5000);
|
||||||
|
|||||||
+20
-4
@@ -26,6 +26,12 @@ private slots:
|
|||||||
void handleSearchTextChanged();
|
void handleSearchTextChanged();
|
||||||
void performSearch();
|
void performSearch();
|
||||||
void updateStatusBar();
|
void updateStatusBar();
|
||||||
|
void handleTypingInactivityTimeout();
|
||||||
|
void handleOpenFile();
|
||||||
|
void handleOpenFileWith();
|
||||||
|
void handleSettings();
|
||||||
|
void applySettings();
|
||||||
|
void loadSettings();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void createLayout();
|
void createLayout();
|
||||||
@@ -36,20 +42,30 @@ private:
|
|||||||
QWidget *m_centralWidget;
|
QWidget *m_centralWidget;
|
||||||
QVBoxLayout *m_mainLayout;
|
QVBoxLayout *m_mainLayout;
|
||||||
QLineEdit *m_searchBar;
|
QLineEdit *m_searchBar;
|
||||||
QLabel *m_titleLabel;
|
|
||||||
ImageGallery *m_imageGallery;
|
ImageGallery *m_imageGallery;
|
||||||
|
|
||||||
// Data
|
// Data
|
||||||
DatabaseManager *m_dbManager;
|
DatabaseManager *m_dbManager;
|
||||||
QString m_lastSearchText;
|
QString m_lastSearchText;
|
||||||
QTimer *m_searchDelayTimer;
|
QTimer *m_typingTimer;
|
||||||
|
QTimer *m_searchingAnimationTimer;
|
||||||
|
int m_searchingDots;
|
||||||
|
bool m_isTyping;
|
||||||
bool m_hasValidDatabase;
|
bool m_hasValidDatabase;
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
QString m_databasePath;
|
||||||
|
QString m_screenshotsDir;
|
||||||
|
int m_imagePreloadCount;
|
||||||
|
|
||||||
|
// Settings file path
|
||||||
|
static const QString CONFIG_FILE_PATH;
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
static constexpr int SEARCH_DELAY_MS = 50; // Reduced delay for more responsive typing
|
static constexpr int SEARCH_DELAY_MS = 300; // Delay for search typing
|
||||||
static constexpr int DEFAULT_WINDOW_WIDTH = 1200;
|
static constexpr int DEFAULT_WINDOW_WIDTH = 1200;
|
||||||
static constexpr int DEFAULT_WINDOW_HEIGHT = 800;
|
static constexpr int DEFAULT_WINDOW_HEIGHT = 800;
|
||||||
static const QString DEFAULT_DB_PATH;
|
static constexpr int DEFAULT_PRELOAD_COUNT = 20;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // MAINWINDOW_H
|
#endif // MAINWINDOW_H
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
#include "settingsdialog.h"
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QStandardPaths>
|
||||||
|
#include <QIntValidator>
|
||||||
|
|
||||||
|
// Define static constants
|
||||||
|
const QString SettingsDialog::DEFAULT_SCREENSHOTS_DIR = QDir::homePath() + "/Screenshots";
|
||||||
|
const QString SettingsDialog::DEFAULT_DATABASE_FILENAME = "screenshot_ocr.db";
|
||||||
|
const QString SettingsDialog::CONFIG_FILE_PATH = QDir::homePath() + "/.config/ScreenshotOCRGallery/settings.ini";
|
||||||
|
|
||||||
|
SettingsDialog::SettingsDialog(QWidget *parent)
|
||||||
|
: QDialog(parent)
|
||||||
|
{
|
||||||
|
setWindowTitle(tr("Settings"));
|
||||||
|
setMinimumWidth(500);
|
||||||
|
|
||||||
|
// Create UI elements
|
||||||
|
createLayout();
|
||||||
|
|
||||||
|
// Load saved settings
|
||||||
|
loadSettings();
|
||||||
|
|
||||||
|
// Connect signals and slots
|
||||||
|
connect(m_browseScreenshotsDirBtn, &QPushButton::clicked, this, &SettingsDialog::handleBrowseScreenshotsDir);
|
||||||
|
connect(m_browseDatabaseBtn, &QPushButton::clicked, this, &SettingsDialog::handleBrowseDatabase);
|
||||||
|
connect(m_buttonBox, &QDialogButtonBox::accepted, this, &SettingsDialog::handleAccepted);
|
||||||
|
connect(m_buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsDialog::~SettingsDialog()
|
||||||
|
{
|
||||||
|
// Qt will handle cleanup of UI elements through parent-child relationships
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsDialog::createLayout()
|
||||||
|
{
|
||||||
|
QVBoxLayout *mainLayout = new QVBoxLayout(this);
|
||||||
|
QGridLayout *formLayout = new QGridLayout();
|
||||||
|
|
||||||
|
// Ensure config directory exists
|
||||||
|
QFileInfo configFile(CONFIG_FILE_PATH);
|
||||||
|
QDir configDir = configFile.absoluteDir();
|
||||||
|
if (!configDir.exists()) {
|
||||||
|
configDir.mkpath(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Screenshots directory row
|
||||||
|
QLabel *screenshotsDirLabel = new QLabel(tr("Screenshots Directory:"), this);
|
||||||
|
m_screenshotsDirEdit = new QLineEdit(this);
|
||||||
|
m_browseScreenshotsDirBtn = new QPushButton(tr("..."), this);
|
||||||
|
m_browseScreenshotsDirBtn->setMaximumWidth(40);
|
||||||
|
|
||||||
|
formLayout->addWidget(screenshotsDirLabel, 0, 0);
|
||||||
|
formLayout->addWidget(m_screenshotsDirEdit, 0, 1);
|
||||||
|
formLayout->addWidget(m_browseScreenshotsDirBtn, 0, 2);
|
||||||
|
|
||||||
|
// Database path row
|
||||||
|
QLabel *databasePathLabel = new QLabel(tr("Database File Path:"), this);
|
||||||
|
m_databasePathEdit = new QLineEdit(this);
|
||||||
|
m_browseDatabaseBtn = new QPushButton(tr("..."), this);
|
||||||
|
m_browseDatabaseBtn->setMaximumWidth(40);
|
||||||
|
|
||||||
|
formLayout->addWidget(databasePathLabel, 1, 0);
|
||||||
|
formLayout->addWidget(m_databasePathEdit, 1, 1);
|
||||||
|
formLayout->addWidget(m_browseDatabaseBtn, 1, 2);
|
||||||
|
|
||||||
|
// Preload count row
|
||||||
|
QLabel *preloadCountLabel = new QLabel(tr("Images to pre-load:"), this);
|
||||||
|
m_imagePreloadCountEdit = new QLineEdit(this);
|
||||||
|
m_imagePreloadCountEdit->setValidator(new QIntValidator(1, 100, this));
|
||||||
|
|
||||||
|
formLayout->addWidget(preloadCountLabel, 2, 0);
|
||||||
|
formLayout->addWidget(m_imagePreloadCountEdit, 2, 1);
|
||||||
|
|
||||||
|
// Help text
|
||||||
|
QLabel *helpText = new QLabel(tr("Note: If no filename is provided for the database path, "
|
||||||
|
"'screenshot_ocr.db' will be used automatically."), this);
|
||||||
|
helpText->setWordWrap(true);
|
||||||
|
helpText->setStyleSheet("color: #666; font-size: 11px;");
|
||||||
|
|
||||||
|
// Button box
|
||||||
|
m_buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
|
||||||
|
|
||||||
|
// Add to main layout
|
||||||
|
mainLayout->addLayout(formLayout);
|
||||||
|
mainLayout->addWidget(helpText);
|
||||||
|
mainLayout->addSpacing(10);
|
||||||
|
mainLayout->addWidget(m_buttonBox);
|
||||||
|
|
||||||
|
setLayout(mainLayout);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsDialog::handleBrowseScreenshotsDir()
|
||||||
|
{
|
||||||
|
QString dir = QFileDialog::getExistingDirectory(
|
||||||
|
this, tr("Select Screenshots Directory"),
|
||||||
|
m_screenshotsDirEdit->text().isEmpty() ? QDir::homePath() : m_screenshotsDirEdit->text(),
|
||||||
|
QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks);
|
||||||
|
|
||||||
|
if (!dir.isEmpty()) {
|
||||||
|
m_screenshotsDirEdit->setText(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsDialog::handleBrowseDatabase()
|
||||||
|
{
|
||||||
|
QString currentPath = m_databasePathEdit->text();
|
||||||
|
if (currentPath.isEmpty()) {
|
||||||
|
currentPath = QDir::homePath();
|
||||||
|
} else {
|
||||||
|
QFileInfo fileInfo(currentPath);
|
||||||
|
if (fileInfo.isFile()) {
|
||||||
|
currentPath = fileInfo.absolutePath();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QString filePath = QFileDialog::getSaveFileName(
|
||||||
|
this, tr("Select Database File"),
|
||||||
|
currentPath,
|
||||||
|
tr("SQLite Database (*.db);;All Files (*)"));
|
||||||
|
|
||||||
|
if (!filePath.isEmpty()) {
|
||||||
|
m_databasePathEdit->setText(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsDialog::handleAccepted()
|
||||||
|
{
|
||||||
|
// Validate settings
|
||||||
|
QDir screenshotsDir(m_screenshotsDirEdit->text());
|
||||||
|
if (!screenshotsDir.exists()) {
|
||||||
|
// Ask if we should create the directory
|
||||||
|
QMessageBox::StandardButton reply = QMessageBox::question(
|
||||||
|
this, tr("Directory Not Found"),
|
||||||
|
tr("The screenshots directory does not exist. Create it?"),
|
||||||
|
QMessageBox::Yes | QMessageBox::No);
|
||||||
|
|
||||||
|
if (reply == QMessageBox::Yes) {
|
||||||
|
if (!screenshotsDir.mkpath(".")) {
|
||||||
|
QMessageBox::warning(this, tr("Error"),
|
||||||
|
tr("Failed to create directory: %1").arg(screenshotsDir.path()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// User chose not to create directory
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure database path has a filename
|
||||||
|
m_databasePathEdit->setText(ensureDatabaseFilename(m_databasePathEdit->text()));
|
||||||
|
|
||||||
|
// Check if database directory exists
|
||||||
|
QFileInfo dbFileInfo(m_databasePathEdit->text());
|
||||||
|
QDir dbDir = dbFileInfo.absoluteDir();
|
||||||
|
|
||||||
|
if (!dbDir.exists()) {
|
||||||
|
QMessageBox::StandardButton reply = QMessageBox::question(
|
||||||
|
this, tr("Directory Not Found"),
|
||||||
|
tr("The database directory does not exist. Create it?"),
|
||||||
|
QMessageBox::Yes | QMessageBox::No);
|
||||||
|
|
||||||
|
if (reply == QMessageBox::Yes) {
|
||||||
|
if (!dbDir.mkpath(".")) {
|
||||||
|
QMessageBox::warning(this, tr("Error"),
|
||||||
|
tr("Failed to create directory: %1").arg(dbDir.path()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// User chose not to create directory
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save settings
|
||||||
|
saveSettings();
|
||||||
|
|
||||||
|
// Accept dialog
|
||||||
|
accept();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString SettingsDialog::getScreenshotsDir() const
|
||||||
|
{
|
||||||
|
return m_screenshotsDirEdit->text();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString SettingsDialog::getDatabasePath() const
|
||||||
|
{
|
||||||
|
return m_databasePathEdit->text();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsDialog::loadSettings()
|
||||||
|
{
|
||||||
|
QSettings settings(CONFIG_FILE_PATH, QSettings::IniFormat);
|
||||||
|
|
||||||
|
// Load screenshots directory
|
||||||
|
QString screenshotsDir = settings.value("screenshotsDir", DEFAULT_SCREENSHOTS_DIR).toString();
|
||||||
|
m_screenshotsDirEdit->setText(screenshotsDir);
|
||||||
|
|
||||||
|
// Load database path
|
||||||
|
QString databasePath = settings.value("databasePath", QDir::homePath() + "/" + DEFAULT_DATABASE_FILENAME).toString();
|
||||||
|
m_databasePathEdit->setText(databasePath);
|
||||||
|
|
||||||
|
// Load preload count
|
||||||
|
int preloadCount = settings.value("imagePreloadCount", DEFAULT_PRELOAD_COUNT).toInt();
|
||||||
|
m_imagePreloadCountEdit->setText(QString::number(preloadCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsDialog::saveSettings()
|
||||||
|
{
|
||||||
|
QSettings settings(CONFIG_FILE_PATH, QSettings::IniFormat);
|
||||||
|
|
||||||
|
// Save screenshots directory
|
||||||
|
settings.setValue("screenshotsDir", getScreenshotsDir());
|
||||||
|
|
||||||
|
// Save database path
|
||||||
|
settings.setValue("databasePath", getDatabasePath());
|
||||||
|
|
||||||
|
// Save preload count
|
||||||
|
settings.setValue("imagePreloadCount", getImagePreloadCount());
|
||||||
|
|
||||||
|
settings.sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
int SettingsDialog::getImagePreloadCount() const
|
||||||
|
{
|
||||||
|
bool ok;
|
||||||
|
int count = m_imagePreloadCountEdit->text().toInt(&ok);
|
||||||
|
|
||||||
|
// Return the default if conversion fails or value is invalid
|
||||||
|
if (!ok || count < 1) {
|
||||||
|
return DEFAULT_PRELOAD_COUNT;
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString SettingsDialog::ensureDatabaseFilename(const QString &path)
|
||||||
|
{
|
||||||
|
if (path.isEmpty()) {
|
||||||
|
// If path is empty, use default in home directory
|
||||||
|
return QDir::homePath() + "/" + DEFAULT_DATABASE_FILENAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
QFileInfo fileInfo(path);
|
||||||
|
if (fileInfo.isDir()) {
|
||||||
|
// If path is a directory, append default filename
|
||||||
|
QString dirPath = path;
|
||||||
|
// Ensure path ends with a separator
|
||||||
|
if (!dirPath.endsWith('/') && !dirPath.endsWith('\\')) {
|
||||||
|
dirPath += '/';
|
||||||
|
}
|
||||||
|
return dirPath + DEFAULT_DATABASE_FILENAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If path already has a filename component, use it as is
|
||||||
|
return path;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
#ifndef SETTINGSDIALOG_H
|
||||||
|
#define SETTINGSDIALOG_H
|
||||||
|
|
||||||
|
#include <QDialog>
|
||||||
|
#include <QLineEdit>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QGridLayout>
|
||||||
|
#include <QDialogButtonBox>
|
||||||
|
#include <QFileDialog>
|
||||||
|
#include <QSettings>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QMessageBox>
|
||||||
|
|
||||||
|
class SettingsDialog : public QDialog
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit SettingsDialog(QWidget *parent = nullptr);
|
||||||
|
~SettingsDialog();
|
||||||
|
|
||||||
|
// Get settings values
|
||||||
|
QString getScreenshotsDir() const;
|
||||||
|
QString getDatabasePath() const;
|
||||||
|
int getImagePreloadCount() const;
|
||||||
|
|
||||||
|
// Load settings from persistent storage
|
||||||
|
void loadSettings();
|
||||||
|
|
||||||
|
// Save settings to persistent storage
|
||||||
|
void saveSettings();
|
||||||
|
|
||||||
|
private slots:
|
||||||
|
// Button click handlers
|
||||||
|
void handleBrowseScreenshotsDir();
|
||||||
|
void handleBrowseDatabase();
|
||||||
|
void handleAccepted();
|
||||||
|
|
||||||
|
private:
|
||||||
|
// UI Elements
|
||||||
|
QLineEdit *m_screenshotsDirEdit;
|
||||||
|
QLineEdit *m_databasePathEdit;
|
||||||
|
QLineEdit *m_imagePreloadCountEdit;
|
||||||
|
QPushButton *m_browseScreenshotsDirBtn;
|
||||||
|
QPushButton *m_browseDatabaseBtn;
|
||||||
|
QDialogButtonBox *m_buttonBox;
|
||||||
|
|
||||||
|
// Helper methods
|
||||||
|
void createLayout();
|
||||||
|
QString ensureDatabaseFilename(const QString &path);
|
||||||
|
|
||||||
|
// Default settings
|
||||||
|
static const QString DEFAULT_SCREENSHOTS_DIR;
|
||||||
|
static const QString DEFAULT_DATABASE_FILENAME;
|
||||||
|
static const QString CONFIG_FILE_PATH;
|
||||||
|
static const int DEFAULT_PRELOAD_COUNT = 20;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // SETTINGSDIALOG_H
|
||||||
Reference in New Issue
Block a user