diff --git a/CMakeLists.txt b/CMakeLists.txt index 40bc02a..5aa5152 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -8,7 +8,12 @@ set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC 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 src/main.cpp @@ -18,18 +23,39 @@ set(PROJECT_SOURCES src/imagegallery.h src/databasemanager.cpp 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 Qt6::Core Qt6::Gui Qt6::Widgets Qt6::Sql + Qt6::Concurrent ) install(TARGETS screenshot-gallery BUNDLE DESTINATION . RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} ) + +# Install desktop file +install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/screenshot-gallery.desktop" + DESTINATION "${CMAKE_INSTALL_PREFIX}/share/applications") diff --git a/OCR-scripts/direct_rofi_ocr.sh b/OCR-scripts/direct_rofi_ocr.sh new file mode 100755 index 0000000..528e476 --- /dev/null +++ b/OCR-scripts/direct_rofi_ocr.sh @@ -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 diff --git a/OCR-scripts/ocr_rofi.sh b/OCR-scripts/ocr_rofi.sh new file mode 100755 index 0000000..d9d32f3 --- /dev/null +++ b/OCR-scripts/ocr_rofi.sh @@ -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 diff --git a/OCR-scripts/ocr_screenshots.py b/OCR-scripts/ocr_screenshots.py new file mode 100755 index 0000000..0dd31ac --- /dev/null +++ b/OCR-scripts/ocr_screenshots.py @@ -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() diff --git a/OCR-scripts/ocr_screenshots.sh b/OCR-scripts/ocr_screenshots.sh new file mode 100755 index 0000000..bc56eeb --- /dev/null +++ b/OCR-scripts/ocr_screenshots.sh @@ -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 diff --git a/OCR-scripts/ocr_search_rofi.py b/OCR-scripts/ocr_search_rofi.py new file mode 100755 index 0000000..a60d243 --- /dev/null +++ b/OCR-scripts/ocr_search_rofi.py @@ -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() diff --git a/OCR-scripts/rofi_ocr_search.sh b/OCR-scripts/rofi_ocr_search.sh new file mode 100755 index 0000000..adb1e82 --- /dev/null +++ b/OCR-scripts/rofi_ocr_search.sh @@ -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 diff --git a/OCR-scripts/screenshot_ocr.db b/OCR-scripts/screenshot_ocr.db new file mode 100644 index 0000000..7d6fdb2 Binary files /dev/null and b/OCR-scripts/screenshot_ocr.db differ diff --git a/OCR-scripts/search_ocr.py b/OCR-scripts/search_ocr.py new file mode 100755 index 0000000..7650b1f --- /dev/null +++ b/OCR-scripts/search_ocr.py @@ -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() diff --git a/OCR-scripts/simple_rofi_ocr.sh b/OCR-scripts/simple_rofi_ocr.sh new file mode 100755 index 0000000..6ca7aa2 --- /dev/null +++ b/OCR-scripts/simple_rofi_ocr.sh @@ -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 diff --git a/README.md b/README.md index 9cb2a8c..649c345 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,21 @@ A Qt6-based image gallery application that allows you to search through OCR data ## Features - 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 - 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 +- Menu bar with File options (Open, Open With, Settings, Quit) - Minimal 2px spacing between images for a compact view - Proper error handling for missing files and database issues @@ -88,17 +98,76 @@ After building, run the application: ## Usage -1. When the application starts, it will display all screenshots found in the database -2. Type in the search bar to filter images by OCR text content -3. Results update instantly as you type with optimized search performance -4. When you clear the search bar, all images are immediately shown +1. When the application starts, it will display the first batch of screenshots (20 by default) +2. Click the large "Load More Images" button at the end of the gallery to load additional images with a single click +3. Type in the search bar to filter images by OCR text content +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: - Wider windows show more columns (4x, 5x, etc.) - Narrower windows reduce to fewer columns (3x, 2x) - Very narrow windows show a single centered column (1x) - 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 +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 diff --git a/icons/orcs-gallery-128.png b/icons/orcs-gallery-128.png new file mode 100644 index 0000000..af790c5 Binary files /dev/null and b/icons/orcs-gallery-128.png differ diff --git a/icons/orcs-gallery-256.png b/icons/orcs-gallery-256.png new file mode 100644 index 0000000..5d75422 Binary files /dev/null and b/icons/orcs-gallery-256.png differ diff --git a/icons/orcs-gallery-512.png b/icons/orcs-gallery-512.png new file mode 100644 index 0000000..7b48be5 Binary files /dev/null and b/icons/orcs-gallery-512.png differ diff --git a/icons/orcs-gallery-64.png b/icons/orcs-gallery-64.png new file mode 100644 index 0000000..3a3beaa Binary files /dev/null and b/icons/orcs-gallery-64.png differ diff --git a/resources.qrc b/resources.qrc new file mode 100644 index 0000000..66302ac --- /dev/null +++ b/resources.qrc @@ -0,0 +1,8 @@ + + + icons/orcs-gallery-64.png + icons/orcs-gallery-128.png + icons/orcs-gallery-256.png + icons/orcs-gallery-512.png + + diff --git a/screenshot-gallery.desktop b/screenshot-gallery.desktop index 69e844d..014d186 100644 --- a/screenshot-gallery.desktop +++ b/screenshot-gallery.desktop @@ -7,5 +7,5 @@ Exec=/home/master/screenshot-gallery/build/screenshot-gallery Terminal=false Categories=Graphics;Utility;Viewer; Keywords=screenshots;ocr;gallery;search;images; -Icon=image-viewer +Icon=orcs-gallery StartupNotify=true diff --git a/src/databasemanager.cpp b/src/databasemanager.cpp index 3daae32..aff951c 100644 --- a/src/databasemanager.cpp +++ b/src/databasemanager.cpp @@ -4,23 +4,68 @@ #include #include #include +#include DatabaseManager::DatabaseManager(QObject *parent) : QObject(parent) , 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 m_searchCache.clear(); + m_allImagesCache.clear(); + m_lastCacheUpdate = QDateTime::currentDateTime(); - // Create index on ocr_text if it doesn't exist - // This will be executed once the database is initialized + // Connect future watcher to handle search results + connect(&m_searchWatcher, &QFutureWatcher::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() { + // Cancel any ongoing search and wait for it to finish + cancelSearch(); + if (m_db.isOpen()) { 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) @@ -68,31 +113,40 @@ bool DatabaseManager::initialize(const QString &dbPath) return false; } + bool hasId = false; bool hasFullPath = false; bool hasOcrText = false; while (query.next()) { QString columnName = query.value(1).toString(); + if (columnName == "id") hasId = true; if (columnName == "full_path") hasFullPath = true; if (columnName == "ocr_text") hasOcrText = true; } - if (!hasFullPath || !hasOcrText) { - qDebug() << "Missing required columns in ocr_results table. Need 'full_path' and 'ocr_text'"; + if (!hasId || !hasFullPath || !hasOcrText) { + qDebug() << "Missing required columns in ocr_results table. Need 'id', 'full_path', and 'ocr_text'"; m_db.close(); return false; } - // Create an index on the ocr_text column if it doesn't exist - // This will speed up text searches dramatically - query.exec("CREATE INDEX IF NOT EXISTS idx_ocr_text ON ocr_results(ocr_text)"); + // Initialize FTS5 if available + 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)"); + m_ftsEnabled = false; + } m_initialized = true; qDebug() << "Database initialized successfully."; return true; } -QList DatabaseManager::getAllImages() +QList DatabaseManager::getAllImages(int offset, int limit) { QList images; @@ -100,7 +154,18 @@ QList DatabaseManager::getAllImages() qDebug() << "Database not initialized."; return images; } - + + // Check cache first + QPair cacheKey(offset, limit); + QMutexLocker cacheLocker(&m_cacheMutex); + if (m_allImagesCache.contains(cacheKey)) { + // Use cached results if available and not expired + if (m_lastCacheUpdate.secsTo(QDateTime::currentDateTime()) < CACHE_LIFETIME_SECS) { + return m_allImagesCache[cacheKey]; + } + } + cacheLocker.unlock(); + // Verify database is still connected if (!m_db.isOpen() && !m_db.open()) { qDebug() << "Database connection lost and cannot be reopened:" << m_db.lastError().text(); @@ -112,91 +177,30 @@ QList DatabaseManager::getAllImages() m_db.transaction(); 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()) { 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::searchImages(const QString &searchText) -{ - QList 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(); return images; } // 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(); + 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()) { @@ -206,18 +210,525 @@ QList DatabaseManager::searchImages(const QString &s m_db.commit(); - // Cache the result for future queries - if (images.size() > 0) { - m_searchCache.insert(searchText, images); + // Update cache + cacheLocker.relock(); + m_allImagesCache[cacheKey] = images; + m_lastCacheUpdate = QDateTime::currentDateTime(); + cacheLocker.unlock(); + + 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(), 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(), 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 allImages = getAllImages(offset, limit); + int totalCount = getImageCount(); - // Limit cache size to avoid memory issues - if (m_searchCache.size() > MAX_CACHE_SIZE) { - // Remove the first key (oldest entry) - if (!m_searchCache.isEmpty()) { - m_searchCache.remove(m_searchCache.firstKey()); + emit searchResultsReady(allImages, searchText, offset, limit, totalCount); + return; + } + + // Check if we have a cached result for this search query + { + QMutexLocker locker(&m_cacheMutex); + QPair cacheKey(offset, limit); + if (m_searchCache.contains(searchText) && + m_searchCache[searchText].contains(cacheKey)) { + + SearchCacheItem cacheItem = m_searchCache[searchText][cacheKey]; + + // Check if cache is still valid + if (cacheItem.timestamp.secsTo(QDateTime::currentDateTime()) < CACHE_LIFETIME_SECS) { + emit searchResultsReady(cacheItem.results, searchText, offset, limit, cacheItem.totalCount); + return; } } } - return images; + // 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 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, SearchCacheItem>()); + } + + // Store results for this specific offset/limit combination + QPair cacheKey(offset, limit); + m_searchCache[searchText].insert(cacheKey, cacheItem); + + // Limit cache size to avoid memory issues + if (m_searchCache.size() > MAX_CACHE_SIZE) { + // Remove the oldest entry + if (!m_searchCache.isEmpty()) { + QString oldestKey = m_searchCache.firstKey(); + m_searchCache.remove(oldestKey); + } + } + + // Release mutex before emitting signal + locker.unlock(); + + // Emit signal with results, including pagination info + 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, SearchCacheItem>> i(m_searchCache); + while (i.hasNext()) { + i.next(); + QMutableMapIterator, 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 "); + } } \ No newline at end of file diff --git a/src/databasemanager.h b/src/databasemanager.h index 83d5d97..7e43690 100644 --- a/src/databasemanager.h +++ b/src/databasemanager.h @@ -11,6 +11,13 @@ #include #include #include +#include +#include + +#include +#include +#include +#include /** * @brief The DatabaseManager class handles all database operations @@ -27,6 +34,7 @@ public: struct ImageItem { QString filePath; QString ocrText; + int id; // Add id to support pagination }; /** @@ -48,28 +56,102 @@ public: 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 */ - QList getAllImages(); + QList 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: /** * @brief Search for images matching the search text * @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 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 &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: QSqlDatabase m_db; bool m_initialized; + bool m_ftsEnabled; // Whether FTS5 is available and enabled // Cache for search results to improve response time - QMap> m_searchCache; + // Cache structure includes both results and total count + struct SearchCacheItem { + QList results; + int totalCount; + QDateTime timestamp; + }; - // Maximum number of cached search queries - static const int MAX_CACHE_SIZE = 50; + QMap, SearchCacheItem>> m_searchCache; // searchText -> {(offset, limit) -> results} + QMutex m_cacheMutex; // Mutex to protect cache access from multiple threads + + // All images cache with pagination + QMap, QList> m_allImagesCache; // (offset, limit) -> results + int m_cachedImageCount; // Total image count cache + QDateTime m_lastCacheUpdate; // When the cache was last updated + + // Search thread management + QFuture m_searchFuture; + QFutureWatcher m_searchWatcher; + QString m_currentSearchText; + 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 \ No newline at end of file diff --git a/src/imagegallery.cpp b/src/imagegallery.cpp index 02f8552..6e9c048 100644 --- a/src/imagegallery.cpp +++ b/src/imagegallery.cpp @@ -9,6 +9,9 @@ #include #include #include +#include + + // ImageThumbnail implementation ImageThumbnail::ImageThumbnail(const QString &filePath, QWidget *parent) @@ -43,9 +46,17 @@ void ImageThumbnail::mousePressEvent(QMouseEvent *event) ImageGallery::ImageGallery(QWidget *parent) : QWidget(parent) , 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 m_scrollArea = new QScrollArea(this); + m_scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOn); m_scrollArea->setWidgetResizable(true); m_scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); // Prevent horizontal scrollbar m_scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); @@ -83,6 +94,34 @@ ImageGallery::ImageGallery(QWidget *parent) QTimer *resizeTimer = new QTimer(this); connect(resizeTimer, &QTimer::timeout, this, &ImageGallery::handleContainerResized); 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() @@ -93,19 +132,71 @@ ImageGallery::~ImageGallery() void ImageGallery::setDatabaseManager(DatabaseManager *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); + } + + // Initialize the last search query + m_lastSearchQuery = QString(); } -void ImageGallery::displayImages(const QList &images) +void ImageGallery::displayImages(const QList &images, bool clearExisting) { - // Clear existing thumbnails - clearGallery(); + // Clear existing thumbnails if requested + if (clearExisting) { + 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 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(); 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) { const auto &item = images[i]; @@ -125,17 +216,20 @@ void ImageGallery::displayImages(const QList &images QFileInfo fileNameInfo(item.filePath); 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); + overlay->setObjectName("filenameOverlay"); // Set object name for finding it later overlay->setStyleSheet("background-color: rgba(0, 0, 0, 0.7);"); overlay->setFixedHeight(20); - overlay->setFixedWidth(THUMBNAIL_WIDTH); // Create label for the filename QLabel* fileNameLabel = new QLabel(fileName, overlay); fileNameLabel->setStyleSheet("color: white; background: transparent;"); 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 QHBoxLayout* overlayLayout = new QHBoxLayout(overlay); @@ -143,6 +237,8 @@ void ImageGallery::displayImages(const QList &images overlayLayout->addWidget(fileNameLabel); // 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()); // Connect the thumbnail click signal @@ -169,10 +265,32 @@ void ImageGallery::displayImages(const QList &images noImagesLabel->setAlignment(Qt::AlignCenter); m_gridLayout->addWidget(noImagesLabel, 0, 0, 1, m_columnsCount); 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); + } + + m_loadingIndicator->setVisible(false); } - // Add stretch to the bottom of the grid - 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() @@ -190,11 +308,219 @@ void ImageGallery::clearGallery() void ImageGallery::handleSearchTextChanged(const QString &searchText) { if (m_dbManager) { - QList images = m_dbManager->searchImages(searchText); - displayImages(images); + // Clear existing search results + 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 &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 &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) { // 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) { // 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 } + // 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 return QWidget::eventFilter(watched, event); } @@ -345,6 +698,13 @@ void ImageGallery::updateGridLayout() for (auto thumbnail : m_thumbnails) { thumbnail->setMaximumWidth(THUMBNAIL_WIDTH); thumbnail->setAlignment(Qt::AlignCenter); + + // Update the overlay to match thumbnail width + QFrame* overlay = thumbnail->findChild("filenameOverlay"); + if (overlay) { + overlay->setFixedWidth(THUMBNAIL_WIDTH); + overlay->move(0, THUMBNAIL_HEIGHT - overlay->height()); + } } } else { // For multi-column, use minimal margins @@ -353,12 +713,31 @@ void ImageGallery::updateGridLayout() // Reset thumbnail constraints for (auto thumbnail : m_thumbnails) { thumbnail->setMaximumWidth(QWIDGETSIZE_MAX); + + // Update the overlay to match thumbnail width in multi-column mode + QFrame* overlay = thumbnail->findChild("filenameOverlay"); + if (overlay) { + overlay->setFixedWidth(thumbnail->width()); + overlay->move(0, THUMBNAIL_HEIGHT - overlay->height()); + } } // For multi-column, let the container fill the viewport 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("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 QTimer::singleShot(10, this, [this](){ m_gridLayout->update(); diff --git a/src/imagegallery.h b/src/imagegallery.h index 9912c2b..57982d0 100644 --- a/src/imagegallery.h +++ b/src/imagegallery.h @@ -11,6 +11,7 @@ #include #include #include +#include #include "databasemanager.h" // Global constants @@ -46,18 +47,29 @@ public: ~ImageGallery(); void setDatabaseManager(DatabaseManager *dbManager); - void displayImages(const QList &images); + void displayImages(const QList &images, bool clearExisting = true); + void appendImages(const QList &images); 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: void handleSearchTextChanged(const QString &searchText); void handleThumbnailClicked(const QString &filePath); void handleContainerResized(); // New slot to handle resize events void updateGridLayout(); // Adjusts grid based on current window size + void handleSearchResults(const QList &results, const QString &searchText, + int offset, int limit, int totalCount); + void handleSearchStarted(const QString &searchText); + void updateSearchTextDisplay(const QString &searchText); // Update UI with search text without performing search + void handleScrolledToBottom(); + void handleLoadMoreClicked(); protected: void resizeEvent(QResizeEvent *event) override; bool eventFilter(QObject *watched, QEvent *event) override; + void wheelEvent(QWheelEvent *event) override; private: QGridLayout *m_gridLayout; @@ -67,8 +79,26 @@ private: QList m_thumbnails; 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 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 \ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp index e563d39..7e03d74 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,4 +1,5 @@ #include +#include #include "mainwindow.h" int main(int argc, char *argv[]) @@ -9,6 +10,14 @@ int main(int argc, char *argv[]) app.setApplicationName("Screenshot Gallery"); 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 MainWindow mainWindow; mainWindow.show(); diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 7e8b307..7e6bb6f 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1,24 +1,47 @@ #include "mainwindow.h" +#include "settingsdialog.h" +#include #include #include #include #include #include +#include +#include +#include +#include +#include +#include +#include #include -// Define the static constant for the default database path -const QString MainWindow::DEFAULT_DB_PATH = "/home/master/screenshot_ocr.db"; +// Define settings file path +const QString MainWindow::CONFIG_FILE_PATH = QDir::homePath() + "/.config/ScreenshotOCRGallery/settings.ini"; MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) , 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_databasePath("") + , m_screenshotsDir("") + , m_imagePreloadCount(DEFAULT_PRELOAD_COUNT) { // Set window title and size setWindowTitle(tr("Screenshot OCR Gallery")); 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 // setMinimumSize(640, 480); @@ -31,20 +54,61 @@ MainWindow::MainWindow(QWidget *parent) (availableGeometry.height() - size.height()) / 2); } + // Load settings first + loadSettings(); + // Set up UI createLayout(); - // Initialize timer for delayed search - m_searchDelayTimer->setSingleShot(true); - m_searchDelayTimer->setInterval(SEARCH_DELAY_MS); - connect(m_searchDelayTimer, &QTimer::timeout, this, &MainWindow::performSearch); + // Initialize typing inactivity timer + m_typingTimer->setSingleShot(true); + m_typingTimer->setInterval(500); // 500ms of no typing before searching + 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 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 &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(); - // Set focus to search bar + // Set focus to search bar and disable autocomplete which can cause UI lag m_searchBar->setFocus(); + m_searchBar->setAutoFillBackground(true); + m_searchBar->setAttribute(Qt::WA_MacShowFocusRect, false); // Reduce drawing overhead // Set status bar statusBar()->showMessage(tr("Ready")); @@ -67,13 +131,42 @@ void MainWindow::createLayout() m_mainLayout->setSpacing(5); // Reduce spacing between elements m_mainLayout->setContentsMargins(5, 5, 5, 5); // Reduce margins - // Create title label - m_titleLabel = new QLabel(tr("Screenshot OCR Gallery"), this); - QFont titleFont = m_titleLabel->font(); - titleFont.setPointSize(16); - titleFont.setBold(true); - m_titleLabel->setFont(titleFont); - m_titleLabel->setAlignment(Qt::AlignCenter); + // Title removed - now shown only in the title bar + + // Create menu bar + QMenuBar *menuBar = new QMenuBar(this); + setMenuBar(menuBar); + + // 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 m_searchBar = new QLineEdit(this); @@ -85,34 +178,33 @@ void MainWindow::createLayout() m_imageGallery->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); // Add widgets to layout - m_mainLayout->addWidget(m_titleLabel); m_mainLayout->addWidget(m_searchBar); m_mainLayout->addWidget(m_imageGallery, 1); - // Connect signals - connect(m_searchBar, &QLineEdit::textChanged, this, &MainWindow::handleSearchTextChanged); + // Connect signals - use textEdited instead of textChanged to handle only user input + connect(m_searchBar, &QLineEdit::textEdited, this, &MainWindow::handleSearchTextChanged); } void MainWindow::initializeDatabase() { // Check if database file exists - QFileInfo dbFileInfo(DEFAULT_DB_PATH); + QFileInfo dbFileInfo(m_databasePath); 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); 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; return; } // 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"); statusBar()->showMessage(errorMsg, 10000); QMessageBox::warning(this, tr("Database Error"), 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"; m_hasValidDatabase = false; } else { @@ -130,12 +222,25 @@ void MainWindow::displayAllImages() return; } - QList allImages = m_dbManager->getAllImages(); + // Get total image count first + int totalImageCount = m_dbManager->getImageCount(); + + // Get the first page of images with pagination + QList allImages = m_dbManager->getAllImages(0, m_imagePreloadCount); if (allImages.isEmpty()) { statusBar()->showMessage(tr("No images found in database"), 5000); + } else { + statusBar()->showMessage(tr("Loaded %1 of %2 images").arg(allImages.size()).arg(totalImageCount), 3000); } - 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(); } @@ -146,42 +251,193 @@ void MainWindow::handleSearchTextChanged() return; } + // Capture current text + QString currentText = m_searchBar->text(); + // If search bar is cleared, immediately show all images - if (m_searchBar->text().isEmpty()) { + if (currentText.isEmpty()) { 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 return; // Skip the timer since we've already updated } - // For non-empty searches, restart the timer each time the user types - m_searchDelayTimer->start(); + // Update last search text + 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() { - if (!m_hasValidDatabase) { - statusBar()->showMessage(tr("Cannot perform search: No database connection"), 3000); + if (!m_hasValidDatabase || m_isTyping) { + // Don't search if database is not available or if user is still typing return; } - QString currentText = m_searchBar->text(); - - // Only perform search if text has changed - if (currentText != m_lastSearchText) { - m_lastSearchText = currentText; + try { + // Clear the gallery and prepare for new search with pagination + m_imageGallery->clearGallery(); - try { - m_imageGallery->handleSearchTextChanged(currentText); - updateStatusBar(); - } catch (const std::exception &e) { - QString errorMsg = tr("Search error: %1").arg(e.what()); - statusBar()->showMessage(errorMsg, 5000); - qDebug() << errorMsg; + // Directly pass the search to the image gallery + // The gallery will handle the threaded search operation with pagination + m_imageGallery->handleSearchTextChanged(m_lastSearchText); + updateStatusBar(); + + // Show searching status + statusBar()->showMessage(tr("Searching..."), 1000); + } catch (const std::exception &e) { + QString errorMsg = tr("Search error: %1").arg(e.what()); + statusBar()->showMessage(errorMsg, 5000); + 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::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() { if (!m_hasValidDatabase) { @@ -190,16 +446,15 @@ void MainWindow::updateStatusBar() } try { - int imageCount = m_dbManager->searchImages(m_lastSearchText).count(); - int totalImages = m_dbManager->getAllImages().count(); + // Get the total count of images in the database + int totalImages = m_dbManager->getImageCount(); if (m_lastSearchText.isEmpty()) { - statusBar()->showMessage(tr("Displaying all %1 images").arg(totalImages)); + statusBar()->showMessage(tr("Displaying images (paginated) - %1 total").arg(totalImages)); } else { - statusBar()->showMessage(tr("Found %1 of %2 images matching \"%3\"") - .arg(imageCount) - .arg(totalImages) - .arg(m_lastSearchText)); + // Status message will be updated when search results arrive + // via the searchResultsReady signal + statusBar()->showMessage(tr("Searching...")); } } catch (const std::exception &e) { statusBar()->showMessage(tr("Error updating status: %1").arg(e.what()), 5000); diff --git a/src/mainwindow.h b/src/mainwindow.h index 760e718..ad827a2 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -26,6 +26,12 @@ private slots: void handleSearchTextChanged(); void performSearch(); void updateStatusBar(); + void handleTypingInactivityTimeout(); + void handleOpenFile(); + void handleOpenFileWith(); + void handleSettings(); + void applySettings(); + void loadSettings(); private: void createLayout(); @@ -36,20 +42,30 @@ private: QWidget *m_centralWidget; QVBoxLayout *m_mainLayout; QLineEdit *m_searchBar; - QLabel *m_titleLabel; ImageGallery *m_imageGallery; // Data DatabaseManager *m_dbManager; QString m_lastSearchText; - QTimer *m_searchDelayTimer; + QTimer *m_typingTimer; + QTimer *m_searchingAnimationTimer; + int m_searchingDots; + bool m_isTyping; bool m_hasValidDatabase; + // Settings + QString m_databasePath; + QString m_screenshotsDir; + int m_imagePreloadCount; + + // Settings file path + static const QString CONFIG_FILE_PATH; + // 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_HEIGHT = 800; - static const QString DEFAULT_DB_PATH; + static constexpr int DEFAULT_PRELOAD_COUNT = 20; }; #endif // MAINWINDOW_H \ No newline at end of file diff --git a/src/settingsdialog.cpp b/src/settingsdialog.cpp new file mode 100644 index 0000000..9c6e5f1 --- /dev/null +++ b/src/settingsdialog.cpp @@ -0,0 +1,260 @@ +#include "settingsdialog.h" +#include +#include +#include +#include +#include + +// 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; +} \ No newline at end of file diff --git a/src/settingsdialog.h b/src/settingsdialog.h new file mode 100644 index 0000000..da01d61 --- /dev/null +++ b/src/settingsdialog.h @@ -0,0 +1,60 @@ +#ifndef SETTINGSDIALOG_H +#define SETTINGSDIALOG_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 \ No newline at end of file