helper scripts

This commit is contained in:
master
2025-11-16 01:42:27 -05:00
parent af8784a15e
commit 274f3ad344
26 changed files with 2926 additions and 175 deletions
+28 -2
View File
@@ -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")
+97
View File
@@ -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
+92
View File
@@ -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
+169
View File
@@ -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()
+58
View File
@@ -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
+179
View File
@@ -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()
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
# rofi_ocr_search.sh - Search and open OCR'd screenshot files using rofi
# This script displays OCR'd screenshot data in rofi and allows opening selected files
# Constants
DATABASE_PATH="$HOME/screenshot_ocr.db"
SCREENSHOTS_DIR="$HOME/Screenshots"
ROFI_PROMPT="Screenshot OCR"
MAX_DISPLAY_LENGTH=100
# Check if required programs are installed
check_dependencies() {
local missing=0
if ! command -v rofi >/dev/null 2>&1; then
echo "Error: rofi is not installed. Please install it with:"
echo " sudo pacman -S rofi"
missing=1
fi
if ! command -v sqlite3 >/dev/null 2>&1; then
echo "Error: sqlite3 is not installed. Please install it with:"
echo " sudo pacman -S sqlite"
missing=1
fi
if ! command -v xdg-open >/dev/null 2>&1; then
echo "Error: xdg-open is not installed. Please install it with:"
echo " sudo pacman -S xdg-utils"
missing=1
fi
if [ ! -f "$DATABASE_PATH" ]; then
echo "Error: Database not found at $DATABASE_PATH"
echo "Run the OCR script first to create and populate the database."
missing=1
fi
if [ $missing -eq 1 ]; then
exit 1
fi
}
# Format OCR text for display in rofi
format_ocr_text() {
local text="$1"
# Replace newlines with spaces
text="${text//$'\n'/ }"
# Remove multiple spaces
text=$(echo "$text" | tr -s ' ')
# Truncate if too long
if [ ${#text} -gt $MAX_DISPLAY_LENGTH ]; then
text="${text:0:$MAX_DISPLAY_LENGTH}..."
fi
echo "$text"
}
# Get entries from database and format for rofi
get_entries_for_rofi() {
sqlite3 -separator '|' "$DATABASE_PATH" "
SELECT filename, ocr_text, full_path
FROM ocr_results
ORDER BY filename
" | while IFS='|' read -r filename ocr_text path; do
formatted_text=$(format_ocr_text "$ocr_text")
echo "$filename | $formatted_text"
echo "$path" >> /tmp/ocr_paths.$$
done
}
# Main function
main() {
check_dependencies
# Create temporary file for paths
rm -f /tmp/ocr_paths.$$ 2>/dev/null
touch /tmp/ocr_paths.$$
# Get all entries and display in rofi
selection=$(get_entries_for_rofi | rofi -dmenu -i -p "$ROFI_PROMPT" \
-width 80 \
-lines 15 \
-font "mono 10" \
-matching fuzzy)
# Exit if no selection made
if [ -z "$selection" ]; then
rm -f /tmp/ocr_paths.$$ 2>/dev/null
exit 0
fi
# Extract filename from selection
filename=$(echo "$selection" | cut -d '|' -f1 | xargs)
# Find the corresponding line number
line_number=1
sqlite3 -separator '|' "$DATABASE_PATH" "
SELECT filename
FROM ocr_results
ORDER BY filename
" | while read -r db_filename; do
if [ "$db_filename" = "$filename" ]; then
break
fi
line_number=$((line_number + 1))
done
# Get the full path from our temporary file
file_path=$(sed -n "${line_number}p" /tmp/ocr_paths.$$)
# Open the file if path exists
if [ -n "$file_path" ] && [ -f "$file_path" ]; then
xdg-open "$file_path" &
echo "Opening: $file_path"
else
echo "Error: Could not find file path for $filename"
fi
# Clean up
rm -f /tmp/ocr_paths.$$ 2>/dev/null
}
# Run the main function
main
Binary file not shown.
+247
View File
@@ -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()
+75
View File
@@ -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
+76 -7
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+8
View File
@@ -0,0 +1,8 @@
<!DOCTYPE RCC><RCC version="1.0">
<qresource prefix="/">
<file>icons/orcs-gallery-64.png</file>
<file>icons/orcs-gallery-128.png</file>
<file>icons/orcs-gallery-256.png</file>
<file>icons/orcs-gallery-512.png</file>
</qresource>
</RCC>
+1 -1
View File
@@ -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
+603 -92
View File
@@ -4,23 +4,68 @@
#include <QDebug>
#include <QVariant>
#include <QFileInfo>
#include <QTimer>
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<void>::finished,
this, [this]() {
if (!m_searchCancelled) {
// Emit signal with the results only if not cancelled
QMutexLocker locker(&m_searchMutex);
QString searchText = m_currentSearchText;
int offset = m_currentOffset;
int limit = m_currentLimit;
if (!searchText.isEmpty()) {
QMutexLocker cacheLocker(&m_cacheMutex);
if (m_searchCache.contains(searchText) &&
m_searchCache[searchText].contains(qMakePair(offset, limit))) {
SearchCacheItem cacheItem = m_searchCache[searchText][qMakePair(offset, limit)];
emit searchResultsReady(cacheItem.results, searchText,
offset, limit, cacheItem.totalCount);
}
}
}
});
// Clean cache periodically
QTimer *cleanupTimer = new QTimer(this);
connect(cleanupTimer, &QTimer::timeout, this, &DatabaseManager::cleanupCache);
cleanupTimer->start(60000); // Clean cache every minute
}
DatabaseManager::~DatabaseManager()
{
// 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
// 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::ImageItem> DatabaseManager::getAllImages()
QList<DatabaseManager::ImageItem> DatabaseManager::getAllImages(int offset, int limit)
{
QList<ImageItem> images;
@@ -101,6 +155,17 @@ QList<DatabaseManager::ImageItem> DatabaseManager::getAllImages()
return images;
}
// Check cache first
QPair<int, int> cacheKey(offset, limit);
QMutexLocker cacheLocker(&m_cacheMutex);
if (m_allImagesCache.contains(cacheKey)) {
// Use cached results if available and not expired
if (m_lastCacheUpdate.secsTo(QDateTime::currentDateTime()) < CACHE_LIFETIME_SECS) {
return m_allImagesCache[cacheKey];
}
}
cacheLocker.unlock();
// Verify database is still connected
if (!m_db.isOpen() && !m_db.open()) {
qDebug() << "Database connection lost and cannot be reopened:" << m_db.lastError().text();
@@ -112,80 +177,18 @@ QList<DatabaseManager::ImageItem> 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::ImageItem> DatabaseManager::searchImages(const QString &searchText)
{
QList<ImageItem> images;
if (!m_initialized) {
qDebug() << "Database not initialized.";
return images;
}
// Verify database is still connected
if (!m_db.isOpen() && !m_db.open()) {
qDebug() << "Database connection lost and cannot be reopened:" << m_db.lastError().text();
m_initialized = false;
return images;
}
// If search text is empty, return all images
if (searchText.isEmpty()) {
// Clear the search cache when empty search is performed
m_searchCache.clear();
return getAllImages();
}
// Check if we have a cached result for this search query
if (m_searchCache.contains(searchText)) {
return m_searchCache[searchText];
}
// Start transaction to speed up queries
m_db.transaction();
QSqlQuery query;
// Optimize the query based on length of search text
if (searchText.length() <= 3) {
// For short search terms, use a more targeted approach
query.prepare("SELECT full_path, ocr_text FROM ocr_results WHERE ocr_text LIKE :search");
query.bindValue(":search", "%" + searchText + "%");
} else {
// For longer search terms, use LIKE with a more specific pattern at start
// which can utilize indexes better if they exist
query.prepare("SELECT full_path, ocr_text FROM ocr_results WHERE ocr_text LIKE :search OR ocr_text LIKE :wordstart");
query.bindValue(":search", "%" + searchText + "%");
query.bindValue(":wordstart", "% " + searchText + "%");
}
if (!query.exec()) {
qDebug() << "Failed to search images:" << query.lastError().text();
m_db.rollback();
return images;
}
@@ -195,8 +198,9 @@ QList<DatabaseManager::ImageItem> DatabaseManager::searchImages(const QString &s
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::ImageItem> DatabaseManager::searchImages(const QString &s
m_db.commit();
// Cache the result for future queries
if (images.size() > 0) {
m_searchCache.insert(searchText, images);
// 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());
}
}
}
// 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<ImageItem>(), searchText, offset, limit, 0);
return;
}
// Verify database is still connected
if (!m_db.isOpen() && !m_db.open()) {
qDebug() << "Database connection lost and cannot be reopened:" << m_db.lastError().text();
m_initialized = false;
emit searchResultsReady(QList<ImageItem>(), searchText, offset, limit, 0);
return;
}
// If search text is empty, return all images
if (searchText.isEmpty()) {
// For empty search, return all images with pagination
QList<ImageItem> allImages = getAllImages(offset, limit);
int totalCount = getImageCount();
emit searchResultsReady(allImages, searchText, offset, limit, totalCount);
return;
}
// Check if we have a cached result for this search query
{
QMutexLocker locker(&m_cacheMutex);
QPair<int, int> cacheKey(offset, limit);
if (m_searchCache.contains(searchText) &&
m_searchCache[searchText].contains(cacheKey)) {
SearchCacheItem cacheItem = m_searchCache[searchText][cacheKey];
// Check if cache is still valid
if (cacheItem.timestamp.secsTo(QDateTime::currentDateTime()) < CACHE_LIFETIME_SECS) {
emit searchResultsReady(cacheItem.results, searchText, offset, limit, cacheItem.totalCount);
return;
}
}
}
// No delay needed since we're using typing inactivity timer in MainWindow
// Cancel any ongoing search before starting a new one
cancelSearch();
// Store the current search parameters safely
{
QMutexLocker locker(&m_searchMutex);
m_currentSearchText = searchText;
m_currentOffset = offset;
m_currentLimit = limit;
m_searchCancelled = false;
}
// The signal is now emitted before starting the thread to ensure UI responsiveness
// Start the search operation in a background thread
m_searchFuture = QtConcurrent::run([this, searchText, offset, limit]() {
performSearchInBackground(searchText, offset, limit);
});
// Show immediate feedback that search is starting
emit searchStarted(searchText);
m_searchWatcher.setFuture(m_searchFuture);
}
void DatabaseManager::cancelSearch()
{
// Set cancelled flag
QMutexLocker locker(&m_searchMutex);
m_searchCancelled = true;
m_currentSearchText.clear();
locker.unlock();
// Wait for any running search to complete
if (m_searchFuture.isRunning()) {
m_searchFuture.waitForFinished();
}
}
void DatabaseManager::performSearchInBackground(const QString &searchText, int offset, int limit)
{
QList<ImageItem> images;
// Get a thread-specific database connection
QSqlDatabase threadDb = getDatabaseConnection();
if (!threadDb.isOpen() && !threadDb.open()) {
qDebug() << "Thread database connection failed:" << threadDb.lastError().text();
return;
}
// Check if search was cancelled
{
QMutexLocker locker(&m_searchMutex);
if (m_searchCancelled || m_currentSearchText != searchText) {
return;
}
}
// First, get total count for pagination info
QSqlQuery countQuery(threadDb);
int totalCount = 0;
if (m_ftsEnabled) {
QString ftsQuery = prepareFTSQuery(searchText);
countQuery.prepare("SELECT COUNT(*) FROM ocr_results r "
"JOIN ocr_fts f ON r.id = f.rowid "
"WHERE ocr_fts MATCH :query");
countQuery.bindValue(":query", ftsQuery);
} else {
if (searchText.length() <= 3) {
countQuery.prepare("SELECT COUNT(*) FROM ocr_results WHERE ocr_text LIKE :search");
countQuery.bindValue(":search", "%" + searchText + "%");
} else {
countQuery.prepare("SELECT COUNT(*) FROM ocr_results WHERE ocr_text LIKE :search OR ocr_text LIKE :wordstart");
countQuery.bindValue(":search", "%" + searchText + "%");
countQuery.bindValue(":wordstart", "% " + searchText + "%");
}
}
if (countQuery.exec() && countQuery.next()) {
totalCount = countQuery.value(0).toInt();
} else {
qDebug() << "Failed to get count:" << countQuery.lastError().text();
totalCount = 0;
}
// Check if search was cancelled before main query
{
QMutexLocker locker(&m_searchMutex);
if (m_searchCancelled || m_currentSearchText != searchText) {
return;
}
}
// Start transaction to speed up query
threadDb.transaction();
QSqlQuery query(threadDb);
if (m_ftsEnabled) {
// Use FTS5 virtual table for much faster text search
QString ftsQuery = prepareFTSQuery(searchText);
QString queryStr = "SELECT r.id, r.full_path, r.ocr_text FROM ocr_results r "
"JOIN ocr_fts f ON r.id = f.rowid "
"WHERE ocr_fts MATCH :query "
"ORDER BY rank";
if (limit > 0) {
queryStr += " LIMIT :limit OFFSET :offset";
}
query.prepare(queryStr);
query.bindValue(":query", ftsQuery);
if (limit > 0) {
query.bindValue(":limit", limit);
query.bindValue(":offset", offset);
}
} else {
// Fallback to LIKE queries if FTS is not available
// Optimize the query based on length of search text
if (searchText.length() <= 3) {
// For short search terms, use a more targeted approach
QString queryStr = "SELECT id, full_path, ocr_text FROM ocr_results WHERE ocr_text LIKE :search "
"ORDER BY id";
if (limit > 0) {
queryStr += " LIMIT :limit OFFSET :offset";
}
query.prepare(queryStr);
query.bindValue(":search", "%" + searchText + "%");
if (limit > 0) {
query.bindValue(":limit", limit);
query.bindValue(":offset", offset);
}
} else {
// For longer search terms, use LIKE with a more specific pattern at start
QString queryStr = "SELECT id, full_path, ocr_text FROM ocr_results WHERE ocr_text LIKE :search OR ocr_text LIKE :wordstart "
"ORDER BY id";
if (limit > 0) {
queryStr += " LIMIT :limit OFFSET :offset";
}
query.prepare(queryStr);
query.bindValue(":search", "%" + searchText + "%");
query.bindValue(":wordstart", "% " + searchText + "%");
if (limit > 0) {
query.bindValue(":limit", limit);
query.bindValue(":offset", offset);
}
}
}
if (!query.exec()) {
qDebug() << "Failed to search images:" << query.lastError().text();
qDebug() << "Error details:" << query.lastError().databaseText();
threadDb.rollback();
// If FTS query failed, try fallback to LIKE
if (m_ftsEnabled) {
qDebug() << "Trying fallback to LIKE query...";
threadDb.transaction();
query.prepare("SELECT full_path, ocr_text FROM ocr_results WHERE ocr_text LIKE :search");
query.bindValue(":search", "%" + searchText + "%");
if (!query.exec()) {
qDebug() << "Fallback query also failed:" << query.lastError().text();
threadDb.rollback();
return;
}
} else {
return;
}
}
// Check if search was cancelled
{
QMutexLocker locker(&m_searchMutex);
if (m_searchCancelled || m_currentSearchText != searchText) {
threadDb.rollback();
return;
}
}
// Reserve space for results to avoid reallocations
images.reserve(query.size() > 0 ? query.size() : 100);
while (query.next()) {
// Periodically check if search was cancelled
if (query.at() % 20 == 0) {
QMutexLocker locker(&m_searchMutex);
if (m_searchCancelled || m_currentSearchText != searchText) {
threadDb.rollback();
return;
}
}
ImageItem item;
item.id = query.value(0).toInt();
item.filePath = query.value(1).toString();
item.ocrText = query.value(2).toString();
// Only add images that have a non-empty path
if (!item.filePath.isEmpty()) {
images.append(item);
}
}
threadDb.commit();
// Check if search was cancelled before storing results
{
QMutexLocker locker(&m_searchMutex);
if (m_searchCancelled || m_currentSearchText != searchText) {
return;
}
}
// Cache the result for future queries and emit signal with the results
QMutexLocker locker(&m_cacheMutex);
// Create cache item with results and metadata
SearchCacheItem cacheItem;
cacheItem.results = images;
cacheItem.totalCount = totalCount;
cacheItem.timestamp = QDateTime::currentDateTime();
// If this is the first query for this search text, create a new map
if (!m_searchCache.contains(searchText)) {
m_searchCache.insert(searchText, QMap<QPair<int, int>, SearchCacheItem>());
}
// Store results for this specific offset/limit combination
QPair<int, int> cacheKey(offset, limit);
m_searchCache[searchText].insert(cacheKey, cacheItem);
// Limit cache size to avoid memory issues
if (m_searchCache.size() > MAX_CACHE_SIZE) {
// Remove the oldest entry
if (!m_searchCache.isEmpty()) {
QString oldestKey = m_searchCache.firstKey();
m_searchCache.remove(oldestKey);
}
}
// Release mutex before emitting signal
locker.unlock();
// Emit signal with results, including pagination info
QMutexLocker searchLocker(&m_searchMutex);
if (!m_searchCancelled && m_currentSearchText == searchText) {
searchLocker.unlock();
emit searchResultsReady(images, searchText, offset, limit, totalCount);
}
}
void DatabaseManager::cleanupCache()
{
QMutexLocker locker(&m_cacheMutex);
// Get current time
QDateTime now = QDateTime::currentDateTime();
// Expire old search cache items
QMutableMapIterator<QString, QMap<QPair<int, int>, SearchCacheItem>> i(m_searchCache);
while (i.hasNext()) {
i.next();
QMutableMapIterator<QPair<int, int>, SearchCacheItem> j(i.value());
while (j.hasNext()) {
j.next();
if (j.value().timestamp.secsTo(now) > CACHE_LIFETIME_SECS) {
j.remove();
}
}
// If no more results for this search text, remove the entry
if (i.value().isEmpty()) {
i.remove();
}
}
// Expire old all-images cache
if (m_lastCacheUpdate.secsTo(now) > CACHE_LIFETIME_SECS) {
m_allImagesCache.clear();
m_cachedImageCount = -1; // Invalidate count cache
}
}
bool DatabaseManager::initializeFTS()
{
// Check if SQLite has FTS5 support
QSqlQuery query;
query.exec("SELECT sqlite_compileoption_used('ENABLE_FTS5')");
if (!query.next() || !query.value(0).toBool()) {
qDebug() << "FTS5 not available in this SQLite installation";
return false;
}
// Check if our FTS table already exists
query.exec("SELECT name FROM sqlite_master WHERE type='table' AND name='ocr_fts'");
if (!query.next()) {
// Create FTS5 virtual table
qDebug() << "Creating FTS5 virtual table...";
bool success = query.exec(
"CREATE VIRTUAL TABLE IF NOT EXISTS ocr_fts USING fts5("
"ocr_text, "
"content='ocr_results', "
"content_rowid='id', "
"tokenize='porter unicode61');"
);
if (!success) {
qDebug() << "Failed to create FTS5 table:" << query.lastError().text();
return false;
}
// Populate the FTS table from existing data
query.exec("BEGIN TRANSACTION;");
success = query.exec(
"INSERT INTO ocr_fts(rowid, ocr_text) "
"SELECT id, ocr_text FROM ocr_results;"
);
query.exec("COMMIT;");
if (!success) {
qDebug() << "Failed to populate FTS5 table:" << query.lastError().text();
return false;
}
// Create triggers to keep FTS table in sync with ocr_results
success = query.exec(
"CREATE TRIGGER IF NOT EXISTS ocr_fts_insert AFTER INSERT ON ocr_results BEGIN "
" INSERT INTO ocr_fts(rowid, ocr_text) VALUES (new.id, new.ocr_text); "
"END;"
);
if (!success) {
qDebug() << "Failed to create insert trigger:" << query.lastError().text();
return false;
}
success = query.exec(
"CREATE TRIGGER IF NOT EXISTS ocr_fts_delete AFTER DELETE ON ocr_results BEGIN "
" INSERT INTO ocr_fts(ocr_fts, rowid, ocr_text) VALUES('delete', old.id, old.ocr_text); "
"END;"
);
if (!success) {
qDebug() << "Failed to create delete trigger:" << query.lastError().text();
return false;
}
success = query.exec(
"CREATE TRIGGER IF NOT EXISTS ocr_fts_update AFTER UPDATE ON ocr_results BEGIN "
" INSERT INTO ocr_fts(ocr_fts, rowid, ocr_text) VALUES('delete', old.id, old.ocr_text); "
" INSERT INTO ocr_fts(rowid, ocr_text) VALUES (new.id, new.ocr_text); "
"END;"
);
if (!success) {
qDebug() << "Failed to create update trigger:" << query.lastError().text();
return false;
}
}
return true;
}
QString DatabaseManager::prepareFTSQuery(const QString &searchText)
{
// Split the search text into tokens
QStringList tokens = searchText.simplified().split(' ', Qt::SkipEmptyParts);
// For single word searches, search for the word as-is and with a wildcard
if (tokens.size() == 1) {
QString token = tokens.first();
// Use prefix search (words starting with the term)
return QString("%1* OR %1").arg(token);
}
// For multi-word searches
else {
// Build both exact phrase search and individual term search
QStringList tokenQueries;
// Add phrase match (higher relevance)
tokenQueries << QString("\"%1\"").arg(searchText);
// Add individual token matches with wildcards
for (const QString &token : tokens) {
if (token.length() > 2) { // Only use wildcards for tokens with 3+ chars
tokenQueries << QString("%1*").arg(token);
} else {
tokenQueries << token;
}
}
return tokenQueries.join(" OR ");
}
}
+89 -7
View File
@@ -11,6 +11,13 @@
#include <QPair>
#include <QThread>
#include <QMap>
#include <QMutex>
#include <QWaitCondition>
#include <QtConcurrent>
#include <QFuture>
#include <QFutureWatcher>
#include <QRegularExpression>
/**
* @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<ImageItem> getAllImages();
QList<ImageItem> getAllImages(int offset = 0, int limit = 0);
/**
* @brief Get total count of all images in the database
* @return Total number of images
*/
int getImageCount();
public slots:
/**
* @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<ImageItem> searchImages(const QString &searchText);
void searchImages(const QString &searchText, int offset = 0, int limit = 0);
/**
* @brief Cancel any ongoing search operations
*/
void cancelSearch();
signals:
/**
* @brief Signal emitted when search results are available
* @param results The list of image items matching the search
* @param searchText The original search text that produced these results
* @param offset The offset used for this result set
* @param limit The limit used for this result set
* @param totalCount The total number of results matching the search (regardless of pagination)
*/
void searchResultsReady(const QList<DatabaseManager::ImageItem> &results, const QString &searchText,
int offset, int limit, int totalCount);
/**
* @brief Signal emitted when search operation starts
* @param searchText The search text being processed
*/
void searchStarted(const QString &searchText);
private:
QSqlDatabase m_db;
bool m_initialized;
bool m_ftsEnabled; // Whether FTS5 is available and enabled
// Cache for search results to improve response time
QMap<QString, QList<ImageItem>> m_searchCache;
// Cache structure includes both results and total count
struct SearchCacheItem {
QList<ImageItem> results;
int totalCount;
QDateTime timestamp;
};
// Maximum number of cached search queries
static const int MAX_CACHE_SIZE = 50;
QMap<QString, QMap<QPair<int, int>, SearchCacheItem>> m_searchCache; // searchText -> {(offset, limit) -> results}
QMutex m_cacheMutex; // Mutex to protect cache access from multiple threads
// All images cache with pagination
QMap<QPair<int, int>, QList<ImageItem>> m_allImagesCache; // (offset, limit) -> results
int m_cachedImageCount; // Total image count cache
QDateTime m_lastCacheUpdate; // When the cache was last updated
// Search thread management
QFuture<void> m_searchFuture;
QFutureWatcher<void> m_searchWatcher;
QString m_currentSearchText;
int m_currentOffset;
int m_currentLimit;
bool m_searchCancelled;
QMutex m_searchMutex;
// Cache settings
static const int MAX_CACHE_SIZE = 50; // Maximum number of cached search queries
static const int DEFAULT_PAGE_SIZE = 100; // Default number of items per page
static const int CACHE_LIFETIME_SECS = 300; // Cache lifetime in seconds (5 minutes)
// Initialize FTS5 full-text search
bool initializeFTS();
// Prepare FTS query with proper syntax
QString prepareFTSQuery(const QString &searchText);
// Perform search operation in background thread
void performSearchInBackground(const QString &searchText, int offset, int limit);
// Clear expired cache items
void cleanupCache();
// Get a database connection for the current thread
QSqlDatabase getDatabaseConnection();
};
#endif // DATABASEMANAGER_H
+389 -10
View File
@@ -9,6 +9,9 @@
#include <QFrame>
#include <QHBoxLayout>
#include <QTimer>
#include <QScrollBar>
// 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<DatabaseManager::ImageItem> &images)
void ImageGallery::displayImages(const QList<DatabaseManager::ImageItem> &images, bool clearExisting)
{
// Clear existing thumbnails
// 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<DatabaseManager::ImageItem> &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<DatabaseManager::ImageItem> &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<DatabaseManager::ImageItem> &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);
}
// Add stretch to the bottom of the grid
m_gridLayout->setRowStretch(row + 1, 1);
m_loadingIndicator->setVisible(false);
}
// 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<DatabaseManager::ImageItem> 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<DatabaseManager::ImageItem> &results,
const QString &searchText, int offset, int limit,
int totalCount)
{
// Only update display if this is the result for the most recent search
if (searchText == m_lastSearchQuery) {
m_isLoading = false;
m_totalCount = totalCount;
// If this is the first page or a different search
bool isFirstPage = (offset == 0);
// Display images, clearing existing ones only if this is first page
displayImages(results, isFirstPage);
// Update pagination state
m_currentOffset = offset + results.size();
m_totalCount = totalCount;
m_hasMoreImages = m_currentOffset < totalCount;
// Always hide the loading indicator when search completes
m_loadingIndicator->hide();
// Update Load More button if we have more images
// Update Load More button state based on whether there are more images
if (m_hasMoreImages) {
// Update button text with current counts
m_loadMoreButton->setText(tr("Load More Images (%1 of %2)")
.arg(m_currentOffset)
.arg(totalCount));
// Add to layout at bottom of grid and ensure it's visible
int row = (m_thumbnails.size() / m_columnsCount);
m_gridLayout->removeWidget(m_loadMoreButton); // Remove from any previous location
m_gridLayout->addWidget(m_loadMoreButton, row + 1, 0, 1, m_columnsCount, Qt::AlignCenter);
m_loadMoreButton->setVisible(true);
m_loadMoreButton->raise(); // Ensure it's on top
qDebug() << "Search results - Added Load More button at row" << row + 1
<< "showing" << m_currentOffset << "of" << totalCount;
} else {
// Hide both indicators if no more images
m_loadingIndicator->setVisible(false);
m_loadMoreButton->setVisible(false);
}
}
}
// Method to append more images to the gallery
void ImageGallery::appendImages(const QList<DatabaseManager::ImageItem> &images)
{
// Call displayImages with clearExisting=false
displayImages(images, false);
}
// Handle search start event
void ImageGallery::handleSearchStarted(const QString &searchText)
{
// Show loading indicator or status message
// This is called when a search operation begins
// Set loading state
m_isLoading = true;
// Only show "Searching" indicator when search actually begins (not during typing)
if (!m_thumbnails.isEmpty() && !m_lastSearchQuery.isEmpty() && searchText != m_lastSearchQuery) {
// New search, keep thumbnails but show loading overlay
m_loadingIndicator->setText(tr("Searching..."));
m_loadingIndicator->show();
// Move loading indicator to be visible
m_scrollArea->verticalScrollBar()->setValue(0);
}
}
// Load more images when scrolled to bottom
void ImageGallery::loadMoreImages()
{
if (!m_dbManager || m_isLoading || !m_hasMoreImages) {
return;
}
m_isLoading = true;
// Hide the Load More button while loading
m_loadMoreButton->hide();
// Show loading indicator
m_loadingIndicator->setText(tr("Loading more images (%1 of %2)...")
.arg(m_currentOffset)
.arg(m_totalCount));
m_loadingIndicator->show();
// Position loading indicator at bottom of grid
int row = (m_thumbnails.size() / m_columnsCount);
m_gridLayout->addWidget(m_loadingIndicator, row + 1, 0, 1, m_columnsCount);
// Request next page from database
m_dbManager->searchImages(m_lastSearchQuery, m_currentOffset, m_pageSize);
}
// Handle scroll to bottom event
void ImageGallery::handleScrolledToBottom()
{
if (!m_isLoading && m_hasMoreImages) {
qDebug() << "Loading more images from handleScrolledToBottom";
loadMoreImages();
}
}
// Handle Load More button click
void ImageGallery::handleLoadMoreClicked()
{
if (!m_isLoading && m_hasMoreImages) {
qDebug() << "Loading more images from Load More button";
// Hide the button and show loading indicator during loading
m_loadMoreButton->hide();
m_loadingIndicator->setText(tr("Loading images..."));
m_loadingIndicator->show();
int row = (m_thumbnails.size() / m_columnsCount);
m_gridLayout->addWidget(m_loadingIndicator, row + 1, 0, 1, m_columnsCount);
// Request more images
loadMoreImages();
// Debug output
qDebug() << "Total thumbnails:" << m_thumbnails.size()
<< "Grid size:" << m_gridLayout->rowCount() << "x" << m_gridLayout->columnCount()
<< "Has more:" << m_hasMoreImages;
}
}
// Set page size for lazy loading
void ImageGallery::setPageSize(int pageSize)
{
if (pageSize > 0) {
m_pageSize = pageSize;
}
}
// Reset pagination state to initial values
void ImageGallery::resetPagination()
{
// Reset pagination state variables
m_currentOffset = 0;
m_isLoading = false;
m_hasMoreImages = false;
m_totalCount = 0;
// Hide loading indicators
if (m_loadingIndicator) {
m_loadingIndicator->hide();
}
// Hide "Load More" button until we know there are more images
if (m_loadMoreButton) {
m_loadMoreButton->hide();
}
}
// Override wheel event to detect end of scrolling
void ImageGallery::wheelEvent(QWheelEvent *event)
{
QWidget::wheelEvent(event);
// Don't check scroll position during the wheel event
// This will be handled by the eventFilter
}
void ImageGallery::updateSearchTextDisplay(const QString &searchText)
{
// Update the UI to reflect the current search text without performing a search
// This gives immediate feedback while typing, before the actual search happens
// Store the search text for tracking
m_lastSearchQuery = searchText;
// Keep existing thumbnails visible without distracting visual changes
// If we have a "no results" message showing, update it quietly
for (auto thumbnail : m_thumbnails) {
if (thumbnail->text().contains("No images found")) {
thumbnail->setText(tr("Search in progress..."));
}
}
// No typing indicator or popups while typing
// Just wait for the actual search to complete
}
void ImageGallery::handleThumbnailClicked(const QString &filePath)
{
// 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<QFrame*>("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<QFrame*>("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<QFrame*>("filenameOverlay");
if (overlay) {
overlay->setFixedWidth(thumbnail->width());
overlay->move(0, THUMBNAIL_HEIGHT - overlay->height());
}
}
});
// Update the layout again after a short delay to handle edge cases
QTimer::singleShot(10, this, [this](){
m_gridLayout->update();
+31 -1
View File
@@ -11,6 +11,7 @@
#include <QDebug>
#include <QPushButton>
#include <QResizeEvent>
#include <QPushButton>
#include "databasemanager.h"
// Global constants
@@ -46,18 +47,29 @@ public:
~ImageGallery();
void setDatabaseManager(DatabaseManager *dbManager);
void displayImages(const QList<DatabaseManager::ImageItem> &images);
void displayImages(const QList<DatabaseManager::ImageItem> &images, bool clearExisting = true);
void appendImages(const QList<DatabaseManager::ImageItem> &images);
void clearGallery();
void 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<DatabaseManager::ImageItem> &results, const QString &searchText,
int offset, int limit, int totalCount);
void handleSearchStarted(const QString &searchText);
void updateSearchTextDisplay(const QString &searchText); // Update UI with search text without performing search
void handleScrolledToBottom();
void handleLoadMoreClicked();
protected:
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<ImageThumbnail*> 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
+9
View File
@@ -1,4 +1,5 @@
#include <QApplication>
#include <QIcon>
#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();
+300 -45
View File
@@ -1,24 +1,47 @@
#include "mainwindow.h"
#include "settingsdialog.h"
#include <QIcon>
#include <QApplication>
#include <QScreen>
#include <QResizeEvent>
#include <QFileInfo>
#include <QMessageBox>
#include <QInputDialog>
#include <QMenuBar>
#include <QMenu>
#include <QAction>
#include <QFileDialog>
#include <QDesktopServices>
#include <QSettings>
#include <stdexcept>
// 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<DatabaseManager::ImageItem> &results, const QString &text,
int offset, int limit, int totalCount) {
if (text.isEmpty()) {
statusBar()->showMessage(tr("Showing all images (paginated) - %1 total").arg(totalCount));
} else {
statusBar()->showMessage(tr("Found %1 results for \"%2\" (showing %3 to %4)")
.arg(totalCount)
.arg(text)
.arg(offset - results.size() + 1)
.arg(offset), 3000);
}
});
}
displayAllImages();
// 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<DatabaseManager::ImageItem> allImages = m_dbManager->getAllImages();
// Get total image count first
int totalImageCount = m_dbManager->getImageCount();
// Get the first page of images with pagination
QList<DatabaseManager::ImageItem> allImages = m_dbManager->getAllImages(0, m_imagePreloadCount);
if (allImages.isEmpty()) {
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,39 +251,190 @@ 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 {
m_imageGallery->handleSearchTextChanged(currentText);
// Clear the gallery and prepare for new search with pagination
m_imageGallery->clearGallery();
// Directly pass the search to the image gallery
// The gallery will handle the threaded search operation with pagination
m_imageGallery->handleSearchTextChanged(m_lastSearchText);
updateStatusBar();
// 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<int, QProcess::ExitStatus>::of(&QProcess::finished),
process, &QProcess::deleteLater);
process->start(program, QStringList() << filePath);
}
}
}
void MainWindow::handleSettings()
{
SettingsDialog settingsDialog(this);
// Show the settings dialog
if (settingsDialog.exec() == QDialog::Accepted) {
// Save and apply new settings
m_databasePath = settingsDialog.getDatabasePath();
m_screenshotsDir = settingsDialog.getScreenshotsDir();
m_imagePreloadCount = settingsDialog.getImagePreloadCount();
// Apply the new settings
applySettings();
}
}
void MainWindow::loadSettings()
{
// Ensure config directory exists
QFileInfo configFile(CONFIG_FILE_PATH);
QDir configDir = configFile.absoluteDir();
if (!configDir.exists()) {
configDir.mkpath(".");
}
QSettings settings(CONFIG_FILE_PATH, QSettings::IniFormat);
// Load database path with fallback to default
m_databasePath = settings.value("databasePath", "/home/master/screenshot_ocr.db").toString();
// Load screenshots directory with fallback to home/Screenshots
m_screenshotsDir = settings.value("screenshotsDir", QDir::homePath() + "/Screenshots").toString();
// Load preload count
m_imagePreloadCount = settings.value("imagePreloadCount", DEFAULT_PRELOAD_COUNT).toInt();
}
void MainWindow::applySettings()
{
// Re-initialize database with new path
bool wasValid = m_hasValidDatabase;
m_hasValidDatabase = false;
// Try to initialize the database with the new path
initializeDatabase();
// Show appropriate message
if (!wasValid && m_hasValidDatabase) {
statusBar()->showMessage(tr("Successfully connected to database"), 3000);
} else if (wasValid && !m_hasValidDatabase) {
statusBar()->showMessage(tr("Lost connection to database"), 5000);
} else if (!wasValid && !m_hasValidDatabase) {
statusBar()->showMessage(tr("Could not connect to database"), 5000);
} else {
// Update page size in ImageGallery
if (m_imageGallery) {
m_imageGallery->setPageSize(m_imagePreloadCount);
}
// Refresh gallery with new database
displayAllImages();
// Ensure the status is updated with correct count
int totalImageCount = m_dbManager->getImageCount();
statusBar()->showMessage(tr("Database loaded with %1 total images").arg(totalImageCount), 3000);
}
}
@@ -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);
+20 -4
View File
@@ -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
+260
View File
@@ -0,0 +1,260 @@
#include "settingsdialog.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QFileInfo>
#include <QStandardPaths>
#include <QIntValidator>
// Define static constants
const QString SettingsDialog::DEFAULT_SCREENSHOTS_DIR = QDir::homePath() + "/Screenshots";
const QString SettingsDialog::DEFAULT_DATABASE_FILENAME = "screenshot_ocr.db";
const QString SettingsDialog::CONFIG_FILE_PATH = QDir::homePath() + "/.config/ScreenshotOCRGallery/settings.ini";
SettingsDialog::SettingsDialog(QWidget *parent)
: QDialog(parent)
{
setWindowTitle(tr("Settings"));
setMinimumWidth(500);
// Create UI elements
createLayout();
// Load saved settings
loadSettings();
// Connect signals and slots
connect(m_browseScreenshotsDirBtn, &QPushButton::clicked, this, &SettingsDialog::handleBrowseScreenshotsDir);
connect(m_browseDatabaseBtn, &QPushButton::clicked, this, &SettingsDialog::handleBrowseDatabase);
connect(m_buttonBox, &QDialogButtonBox::accepted, this, &SettingsDialog::handleAccepted);
connect(m_buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
}
SettingsDialog::~SettingsDialog()
{
// Qt will handle cleanup of UI elements through parent-child relationships
}
void SettingsDialog::createLayout()
{
QVBoxLayout *mainLayout = new QVBoxLayout(this);
QGridLayout *formLayout = new QGridLayout();
// Ensure config directory exists
QFileInfo configFile(CONFIG_FILE_PATH);
QDir configDir = configFile.absoluteDir();
if (!configDir.exists()) {
configDir.mkpath(".");
}
// Screenshots directory row
QLabel *screenshotsDirLabel = new QLabel(tr("Screenshots Directory:"), this);
m_screenshotsDirEdit = new QLineEdit(this);
m_browseScreenshotsDirBtn = new QPushButton(tr("..."), this);
m_browseScreenshotsDirBtn->setMaximumWidth(40);
formLayout->addWidget(screenshotsDirLabel, 0, 0);
formLayout->addWidget(m_screenshotsDirEdit, 0, 1);
formLayout->addWidget(m_browseScreenshotsDirBtn, 0, 2);
// Database path row
QLabel *databasePathLabel = new QLabel(tr("Database File Path:"), this);
m_databasePathEdit = new QLineEdit(this);
m_browseDatabaseBtn = new QPushButton(tr("..."), this);
m_browseDatabaseBtn->setMaximumWidth(40);
formLayout->addWidget(databasePathLabel, 1, 0);
formLayout->addWidget(m_databasePathEdit, 1, 1);
formLayout->addWidget(m_browseDatabaseBtn, 1, 2);
// Preload count row
QLabel *preloadCountLabel = new QLabel(tr("Images to pre-load:"), this);
m_imagePreloadCountEdit = new QLineEdit(this);
m_imagePreloadCountEdit->setValidator(new QIntValidator(1, 100, this));
formLayout->addWidget(preloadCountLabel, 2, 0);
formLayout->addWidget(m_imagePreloadCountEdit, 2, 1);
// Help text
QLabel *helpText = new QLabel(tr("Note: If no filename is provided for the database path, "
"'screenshot_ocr.db' will be used automatically."), this);
helpText->setWordWrap(true);
helpText->setStyleSheet("color: #666; font-size: 11px;");
// Button box
m_buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
// Add to main layout
mainLayout->addLayout(formLayout);
mainLayout->addWidget(helpText);
mainLayout->addSpacing(10);
mainLayout->addWidget(m_buttonBox);
setLayout(mainLayout);
}
void SettingsDialog::handleBrowseScreenshotsDir()
{
QString dir = QFileDialog::getExistingDirectory(
this, tr("Select Screenshots Directory"),
m_screenshotsDirEdit->text().isEmpty() ? QDir::homePath() : m_screenshotsDirEdit->text(),
QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks);
if (!dir.isEmpty()) {
m_screenshotsDirEdit->setText(dir);
}
}
void SettingsDialog::handleBrowseDatabase()
{
QString currentPath = m_databasePathEdit->text();
if (currentPath.isEmpty()) {
currentPath = QDir::homePath();
} else {
QFileInfo fileInfo(currentPath);
if (fileInfo.isFile()) {
currentPath = fileInfo.absolutePath();
}
}
QString filePath = QFileDialog::getSaveFileName(
this, tr("Select Database File"),
currentPath,
tr("SQLite Database (*.db);;All Files (*)"));
if (!filePath.isEmpty()) {
m_databasePathEdit->setText(filePath);
}
}
void SettingsDialog::handleAccepted()
{
// Validate settings
QDir screenshotsDir(m_screenshotsDirEdit->text());
if (!screenshotsDir.exists()) {
// Ask if we should create the directory
QMessageBox::StandardButton reply = QMessageBox::question(
this, tr("Directory Not Found"),
tr("The screenshots directory does not exist. Create it?"),
QMessageBox::Yes | QMessageBox::No);
if (reply == QMessageBox::Yes) {
if (!screenshotsDir.mkpath(".")) {
QMessageBox::warning(this, tr("Error"),
tr("Failed to create directory: %1").arg(screenshotsDir.path()));
return;
}
} else {
// User chose not to create directory
return;
}
}
// Ensure database path has a filename
m_databasePathEdit->setText(ensureDatabaseFilename(m_databasePathEdit->text()));
// Check if database directory exists
QFileInfo dbFileInfo(m_databasePathEdit->text());
QDir dbDir = dbFileInfo.absoluteDir();
if (!dbDir.exists()) {
QMessageBox::StandardButton reply = QMessageBox::question(
this, tr("Directory Not Found"),
tr("The database directory does not exist. Create it?"),
QMessageBox::Yes | QMessageBox::No);
if (reply == QMessageBox::Yes) {
if (!dbDir.mkpath(".")) {
QMessageBox::warning(this, tr("Error"),
tr("Failed to create directory: %1").arg(dbDir.path()));
return;
}
} else {
// User chose not to create directory
return;
}
}
// Save settings
saveSettings();
// Accept dialog
accept();
}
QString SettingsDialog::getScreenshotsDir() const
{
return m_screenshotsDirEdit->text();
}
QString SettingsDialog::getDatabasePath() const
{
return m_databasePathEdit->text();
}
void SettingsDialog::loadSettings()
{
QSettings settings(CONFIG_FILE_PATH, QSettings::IniFormat);
// Load screenshots directory
QString screenshotsDir = settings.value("screenshotsDir", DEFAULT_SCREENSHOTS_DIR).toString();
m_screenshotsDirEdit->setText(screenshotsDir);
// Load database path
QString databasePath = settings.value("databasePath", QDir::homePath() + "/" + DEFAULT_DATABASE_FILENAME).toString();
m_databasePathEdit->setText(databasePath);
// Load preload count
int preloadCount = settings.value("imagePreloadCount", DEFAULT_PRELOAD_COUNT).toInt();
m_imagePreloadCountEdit->setText(QString::number(preloadCount));
}
void SettingsDialog::saveSettings()
{
QSettings settings(CONFIG_FILE_PATH, QSettings::IniFormat);
// Save screenshots directory
settings.setValue("screenshotsDir", getScreenshotsDir());
// Save database path
settings.setValue("databasePath", getDatabasePath());
// Save preload count
settings.setValue("imagePreloadCount", getImagePreloadCount());
settings.sync();
}
int SettingsDialog::getImagePreloadCount() const
{
bool ok;
int count = m_imagePreloadCountEdit->text().toInt(&ok);
// Return the default if conversion fails or value is invalid
if (!ok || count < 1) {
return DEFAULT_PRELOAD_COUNT;
}
return count;
}
QString SettingsDialog::ensureDatabaseFilename(const QString &path)
{
if (path.isEmpty()) {
// If path is empty, use default in home directory
return QDir::homePath() + "/" + DEFAULT_DATABASE_FILENAME;
}
QFileInfo fileInfo(path);
if (fileInfo.isDir()) {
// If path is a directory, append default filename
QString dirPath = path;
// Ensure path ends with a separator
if (!dirPath.endsWith('/') && !dirPath.endsWith('\\')) {
dirPath += '/';
}
return dirPath + DEFAULT_DATABASE_FILENAME;
}
// If path already has a filename component, use it as is
return path;
}
+60
View File
@@ -0,0 +1,60 @@
#ifndef SETTINGSDIALOG_H
#define SETTINGSDIALOG_H
#include <QDialog>
#include <QLineEdit>
#include <QPushButton>
#include <QLabel>
#include <QGridLayout>
#include <QDialogButtonBox>
#include <QFileDialog>
#include <QSettings>
#include <QDir>
#include <QMessageBox>
class SettingsDialog : public QDialog
{
Q_OBJECT
public:
explicit SettingsDialog(QWidget *parent = nullptr);
~SettingsDialog();
// Get settings values
QString getScreenshotsDir() const;
QString getDatabasePath() const;
int getImagePreloadCount() const;
// Load settings from persistent storage
void loadSettings();
// Save settings to persistent storage
void saveSettings();
private slots:
// Button click handlers
void handleBrowseScreenshotsDir();
void handleBrowseDatabase();
void handleAccepted();
private:
// UI Elements
QLineEdit *m_screenshotsDirEdit;
QLineEdit *m_databasePathEdit;
QLineEdit *m_imagePreloadCountEdit;
QPushButton *m_browseScreenshotsDirBtn;
QPushButton *m_browseDatabaseBtn;
QDialogButtonBox *m_buttonBox;
// Helper methods
void createLayout();
QString ensureDatabaseFilename(const QString &path);
// Default settings
static const QString DEFAULT_SCREENSHOTS_DIR;
static const QString DEFAULT_DATABASE_FILENAME;
static const QString CONFIG_FILE_PATH;
static const int DEFAULT_PRELOAD_COUNT = 20;
};
#endif // SETTINGSDIALOG_H