helper scripts
This commit is contained in:
+28
-2
@@ -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")
|
||||
|
||||
Executable
+97
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# direct_rofi_ocr.sh - Direct script to search OCR'd screenshots with rofi
|
||||
# This script displays OCR data from SQLite and allows opening files with rofi
|
||||
|
||||
# Database path
|
||||
DB_PATH="/home/master/screenshot_ocr.db"
|
||||
|
||||
# Check dependencies
|
||||
check_deps() {
|
||||
local missing=0
|
||||
|
||||
if ! command -v rofi &> /dev/null; then
|
||||
echo "Error: rofi is not installed. Please install it with: sudo pacman -S rofi"
|
||||
missing=1
|
||||
fi
|
||||
|
||||
if ! command -v sqlite3 &> /dev/null; then
|
||||
echo "Error: sqlite3 is not installed. Please install it with: sudo pacman -S sqlite"
|
||||
missing=1
|
||||
fi
|
||||
|
||||
if ! [ -f "$DB_PATH" ]; then
|
||||
echo "Error: Database not found at $DB_PATH"
|
||||
echo "Please run the OCR script first to create and populate the database."
|
||||
missing=1
|
||||
fi
|
||||
|
||||
return $missing
|
||||
}
|
||||
|
||||
# Format OCR text (remove newlines, limit length)
|
||||
format_text() {
|
||||
local text="$1"
|
||||
text=$(echo "$text" | tr '\n' ' ' | tr -s ' ')
|
||||
|
||||
if [ ${#text} -gt 80 ]; then
|
||||
text="${text:0:80}..."
|
||||
fi
|
||||
|
||||
echo "$text"
|
||||
}
|
||||
|
||||
# Main function
|
||||
main() {
|
||||
# Check dependencies
|
||||
if ! check_deps; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create temporary file for mapping
|
||||
TEMP_FILE=$(mktemp)
|
||||
trap 'rm -f $TEMP_FILE' EXIT
|
||||
|
||||
# Extract data from database
|
||||
echo "Querying database..."
|
||||
sqlite3 "$DB_PATH" "SELECT filename, full_path, ocr_text FROM ocr_results" | \
|
||||
while IFS='|' read -r filename path text; do
|
||||
# Format the text for display
|
||||
formatted=$(format_text "$text")
|
||||
# Write to temp file: display_text|file_path
|
||||
echo "$filename | $formatted|$path" >> "$TEMP_FILE"
|
||||
done
|
||||
|
||||
# Check if we got any results
|
||||
if [ ! -s "$TEMP_FILE" ]; then
|
||||
echo "No OCR data found in database. Run ocr_screenshots.py first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Display in rofi
|
||||
echo "Opening rofi dialog..."
|
||||
selection=$(cat "$TEMP_FILE" | cut -d'|' -f1-2 | rofi -dmenu -i -p "Screenshot OCR" -width 80)
|
||||
|
||||
# Check if user made a selection
|
||||
if [ -z "$selection" ]; then
|
||||
echo "No selection made."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Find the corresponding path
|
||||
display_text=$(echo "$selection" | sed 's/|.*$//')
|
||||
path=$(grep -F "$display_text" "$TEMP_FILE" | cut -d'|' -f3)
|
||||
|
||||
# Open the file
|
||||
if [ -n "$path" ] && [ -f "$path" ]; then
|
||||
echo "Opening: $path"
|
||||
xdg-open "$path" &
|
||||
else
|
||||
echo "Error: Could not find file path for selection."
|
||||
echo "Selected: $display_text"
|
||||
echo "Path: $path"
|
||||
fi
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main
|
||||
Executable
+92
@@ -0,0 +1,92 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ocr_rofi.sh - Search OCR'd screenshots with rofi
|
||||
# This script displays OCR data from SQLite and allows opening files with rofi
|
||||
|
||||
# Database path
|
||||
DB_PATH="$HOME/screenshot_ocr.db"
|
||||
SCREENSHOTS_DIR="$HOME/Screenshots"
|
||||
MAX_TEXT_LENGTH=100
|
||||
|
||||
# Check dependencies
|
||||
if ! command -v rofi &> /dev/null; then
|
||||
echo "Error: rofi is not installed. Please install it with: sudo pacman -S rofi"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v sqlite3 &> /dev/null; then
|
||||
echo "Error: sqlite3 is not installed. Please install it with: sudo pacman -S sqlite"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$DB_PATH" ]; then
|
||||
echo "Error: Database not found at $DB_PATH"
|
||||
echo "Run the OCR script first to create and populate the database."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create temporary files
|
||||
ENTRIES_FILE=$(mktemp)
|
||||
PATHS_FILE=$(mktemp)
|
||||
trap "rm -f $ENTRIES_FILE $PATHS_FILE" EXIT
|
||||
|
||||
# Query the database and format for rofi
|
||||
echo "Preparing OCR data for search..."
|
||||
sqlite3 -separator "|" "$DB_PATH" "SELECT filename, ocr_text, full_path FROM ocr_results ORDER BY filename" | while IFS="|" read -r filename ocr_text path; do
|
||||
# Clean up text (remove newlines, limit length)
|
||||
clean_text=$(echo "$ocr_text" | tr '\n' ' ' | tr -s ' ')
|
||||
if [ ${#clean_text} -gt $MAX_TEXT_LENGTH ]; then
|
||||
clean_text="${clean_text:0:$MAX_TEXT_LENGTH}..."
|
||||
fi
|
||||
|
||||
# Save formatted entry for rofi
|
||||
echo "$filename | $clean_text" >> "$ENTRIES_FILE"
|
||||
|
||||
# Save path in corresponding line
|
||||
echo "$path" >> "$PATHS_FILE"
|
||||
done
|
||||
|
||||
# Count entries
|
||||
entry_count=$(wc -l < "$ENTRIES_FILE")
|
||||
if [ "$entry_count" -eq 0 ]; then
|
||||
echo "No OCR data found in database. Run ocr_screenshots.py first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Found $entry_count screenshots with OCR data."
|
||||
|
||||
# Display rofi menu
|
||||
selected_line=$(cat "$ENTRIES_FILE" | rofi -dmenu -i -p "Screenshot OCR" \
|
||||
-width 80 -lines 15 -font "mono 10")
|
||||
|
||||
# Exit if no selection
|
||||
if [ -z "$selected_line" ]; then
|
||||
echo "No selection made."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Get filename from selection
|
||||
selected_filename=$(echo "$selected_line" | cut -d '|' -f 1 | sed 's/ *$//')
|
||||
|
||||
# Find corresponding line number
|
||||
line_num=1
|
||||
while IFS= read -r line; do
|
||||
if [[ "$line" == "$selected_filename"* ]]; then
|
||||
break
|
||||
fi
|
||||
line_num=$((line_num + 1))
|
||||
done < "$ENTRIES_FILE"
|
||||
|
||||
# Get full path from paths file
|
||||
selected_path=$(sed "${line_num}q;d" "$PATHS_FILE")
|
||||
|
||||
# Open the file
|
||||
if [ -n "$selected_path" ] && [ -f "$selected_path" ]; then
|
||||
echo "Opening: $selected_path"
|
||||
xdg-open "$selected_path" &
|
||||
else
|
||||
echo "Error: Could not find file: $selected_path"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Executable
+169
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import glob
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
|
||||
# Configuration
|
||||
SCREENSHOTS_DIR = os.path.expanduser("~/Screenshots")
|
||||
DATABASE_PATH = os.path.expanduser("~/screenshot_ocr.db")
|
||||
|
||||
|
||||
def create_database():
|
||||
"""Create SQLite database and table if they don't exist."""
|
||||
conn = sqlite3.connect(DATABASE_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Create table for OCR results
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS ocr_results (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
filename TEXT UNIQUE,
|
||||
full_path TEXT,
|
||||
ocr_text TEXT,
|
||||
file_size INTEGER,
|
||||
created_date TEXT,
|
||||
ocr_date TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(f"Database initialized at {DATABASE_PATH}")
|
||||
|
||||
|
||||
def get_processed_files():
|
||||
"""Get a set of filenames that have already been processed."""
|
||||
conn = sqlite3.connect(DATABASE_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT filename FROM ocr_results")
|
||||
processed_files = {row[0] for row in cursor.fetchall()}
|
||||
|
||||
conn.close()
|
||||
return processed_files
|
||||
|
||||
|
||||
def perform_ocr(image_path):
|
||||
"""Perform OCR on an image file using tesseract."""
|
||||
try:
|
||||
# Create a temporary output file
|
||||
temp_output = f"/tmp/{os.path.basename(image_path)}.txt"
|
||||
temp_base = temp_output.replace(".txt", "")
|
||||
|
||||
# Run tesseract
|
||||
subprocess.run(
|
||||
["tesseract", image_path, temp_base],
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
|
||||
# Read OCR text from the output file
|
||||
with open(temp_output, "r", encoding="utf-8") as f:
|
||||
ocr_text = f.read().strip()
|
||||
|
||||
# Clean up temporary file
|
||||
os.remove(temp_output)
|
||||
|
||||
return ocr_text
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error running tesseract on {image_path}: {str(e)}")
|
||||
return ""
|
||||
except Exception as e:
|
||||
print(f"Error processing {image_path}: {str(e)}")
|
||||
return ""
|
||||
|
||||
|
||||
def add_to_database(filename, full_path, ocr_text, file_size, created_date):
|
||||
"""Add OCR result to the database."""
|
||||
conn = sqlite3.connect(DATABASE_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO ocr_results
|
||||
(filename, full_path, ocr_text, file_size, created_date, ocr_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
filename,
|
||||
full_path,
|
||||
ocr_text,
|
||||
file_size,
|
||||
created_date,
|
||||
datetime.now().isoformat(),
|
||||
),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
print(f"Added {filename} to database")
|
||||
except sqlite3.IntegrityError:
|
||||
print(f"File {filename} already exists in database")
|
||||
except Exception as e:
|
||||
print(f"Error adding {filename} to database: {str(e)}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function to process screenshot images."""
|
||||
print("Starting OCR process for screenshots...")
|
||||
|
||||
# Create database if it doesn't exist
|
||||
create_database()
|
||||
|
||||
# Get list of already processed files
|
||||
processed_files = get_processed_files()
|
||||
print(f"Found {len(processed_files)} already processed files")
|
||||
|
||||
# Get list of PNG and JPG files
|
||||
image_files = glob.glob(os.path.join(SCREENSHOTS_DIR, "*.png"))
|
||||
image_files.extend(glob.glob(os.path.join(SCREENSHOTS_DIR, "*.jpg")))
|
||||
image_files.extend(glob.glob(os.path.join(SCREENSHOTS_DIR, "*.jpeg")))
|
||||
print(f"Found {len(image_files)} image files")
|
||||
|
||||
# Process each image file
|
||||
processed_count = 0
|
||||
skipped_count = 0
|
||||
error_count = 0
|
||||
|
||||
for image_path in image_files:
|
||||
filename = os.path.basename(image_path)
|
||||
|
||||
# Skip if already processed
|
||||
if filename in processed_files:
|
||||
print(f"Skipping {filename} (already processed)")
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
print(f"Processing {filename}...")
|
||||
|
||||
# Get file information
|
||||
file_stats = os.stat(image_path)
|
||||
file_size = file_stats.st_size
|
||||
created_date = datetime.fromtimestamp(file_stats.st_mtime).isoformat()
|
||||
|
||||
# Perform OCR
|
||||
ocr_text = perform_ocr(image_path)
|
||||
|
||||
if ocr_text:
|
||||
# Add to database
|
||||
add_to_database(filename, image_path, ocr_text, file_size, created_date)
|
||||
processed_count += 1
|
||||
else:
|
||||
print(f"No OCR text extracted from {filename}")
|
||||
error_count += 1
|
||||
|
||||
print("\nOCR process completed:")
|
||||
print(f"- Processed: {processed_count}")
|
||||
print(f"- Skipped (already in database): {skipped_count}")
|
||||
print(f"- Errors: {error_count}")
|
||||
print(f"- Total files in database: {len(processed_files) + processed_count}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
#!/bin/bash
|
||||
|
||||
# OCR screenshot images and store results in SQLite database
|
||||
# This script is a wrapper for the ocr_screenshots.py Python script
|
||||
|
||||
# Set up variables
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PYTHON_SCRIPT="${SCRIPT_DIR}/ocr_screenshots.py"
|
||||
DATABASE_PATH="$HOME/screenshot_ocr.db"
|
||||
SCREENSHOTS_DIR="$HOME/Screenshots"
|
||||
|
||||
# Check if Python script exists
|
||||
if [ ! -f "$PYTHON_SCRIPT" ]; then
|
||||
echo "Error: Python script not found at $PYTHON_SCRIPT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if tesseract is installed
|
||||
if ! command -v tesseract &> /dev/null; then
|
||||
echo "Error: tesseract not installed. Please install it with:"
|
||||
echo " sudo pacman -S tesseract tesseract-data-eng"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if SQLite is installed
|
||||
if ! command -v sqlite3 &> /dev/null; then
|
||||
echo "Error: sqlite3 not installed. Please install it with:"
|
||||
echo " sudo pacman -S sqlite"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if screenshots directory exists
|
||||
if [ ! -d "$SCREENSHOTS_DIR" ]; then
|
||||
echo "Error: Screenshots directory not found at $SCREENSHOTS_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Starting OCR process for screenshots..."
|
||||
echo "Database path: $DATABASE_PATH"
|
||||
echo "Screenshots directory: $SCREENSHOTS_DIR"
|
||||
|
||||
# Run the Python script
|
||||
python "$PYTHON_SCRIPT"
|
||||
|
||||
# Check return code
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error: OCR process failed"
|
||||
exit 1
|
||||
else
|
||||
# Count entries in database
|
||||
if [ -f "$DATABASE_PATH" ]; then
|
||||
count=$(sqlite3 "$DATABASE_PATH" "SELECT COUNT(*) FROM ocr_results")
|
||||
echo "Database contains $count entries"
|
||||
fi
|
||||
echo "OCR process completed successfully"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Executable
+179
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# Configuration
|
||||
DB_PATH = os.path.expanduser("~/screenshot_ocr.db")
|
||||
SCREENSHOTS_DIR = os.path.expanduser("~/Screenshots")
|
||||
ROFI_PROMPT = "OCR Search"
|
||||
|
||||
|
||||
def check_dependencies():
|
||||
"""Check if required dependencies are installed."""
|
||||
# Check if rofi is installed
|
||||
try:
|
||||
subprocess.run(
|
||||
["which", "rofi"],
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
print(
|
||||
"Error: rofi is not installed. Please install it with: sudo pacman -S rofi"
|
||||
)
|
||||
return False
|
||||
|
||||
# Check if database exists
|
||||
if not os.path.exists(DB_PATH):
|
||||
print(f"Error: Database not found at {DB_PATH}")
|
||||
print("Run the OCR script first to create and populate the database.")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def format_text(text, max_length=100):
|
||||
"""Format OCR text for display in rofi."""
|
||||
# Replace newlines with spaces
|
||||
text = text.replace("\n", " ")
|
||||
|
||||
# Replace multiple spaces with a single space
|
||||
while " " in text:
|
||||
text = text.replace(" ", " ")
|
||||
|
||||
# Truncate if too long
|
||||
if len(text) > max_length:
|
||||
text = text[:max_length] + "..."
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def get_screenshot_data():
|
||||
"""Get data from the SQLite database."""
|
||||
try:
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get all records
|
||||
cursor.execute(
|
||||
"SELECT filename, ocr_text, full_path FROM ocr_results ORDER BY filename"
|
||||
)
|
||||
|
||||
results = []
|
||||
for filename, ocr_text, full_path in cursor.fetchall():
|
||||
formatted_text = format_text(ocr_text)
|
||||
# Format for display: filename | ocr_text
|
||||
display_text = f"{filename} | {formatted_text}"
|
||||
results.append((display_text, full_path))
|
||||
|
||||
conn.close()
|
||||
return results
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"Database error: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def show_rofi_menu(items):
|
||||
"""Display rofi menu with the given items and return selection."""
|
||||
if not items:
|
||||
print("No items to display.")
|
||||
return None, None
|
||||
|
||||
# Create temporary file with menu items
|
||||
with tempfile.NamedTemporaryFile(mode="w+", delete=False) as temp_file:
|
||||
for display_text, _ in items:
|
||||
temp_file.write(f"{display_text}\n")
|
||||
temp_file_path = temp_file.name
|
||||
|
||||
try:
|
||||
# Run rofi command
|
||||
cmd = [
|
||||
"rofi",
|
||||
"-dmenu",
|
||||
"-i", # Case-insensitive matching
|
||||
"-p",
|
||||
ROFI_PROMPT,
|
||||
"-width",
|
||||
"80",
|
||||
"-lines",
|
||||
"15",
|
||||
"-font",
|
||||
"mono 10",
|
||||
]
|
||||
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=open(temp_file_path, "r"),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
universal_newlines=True,
|
||||
)
|
||||
|
||||
stdout, stderr = process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
print(f"Rofi error: {stderr}")
|
||||
return None, None
|
||||
|
||||
selection = stdout.strip()
|
||||
if not selection:
|
||||
return None, None
|
||||
|
||||
# Find the matching item
|
||||
for i, (display_text, full_path) in enumerate(items):
|
||||
if display_text == selection:
|
||||
return display_text, full_path
|
||||
|
||||
return None, None
|
||||
|
||||
finally:
|
||||
# Clean up temporary file
|
||||
os.unlink(temp_file_path)
|
||||
|
||||
|
||||
def open_file(file_path):
|
||||
"""Open the selected file with the default application."""
|
||||
if os.path.exists(file_path):
|
||||
try:
|
||||
subprocess.Popen(
|
||||
["xdg-open", file_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||
)
|
||||
print(f"Opening: {file_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error opening file: {e}")
|
||||
return False
|
||||
else:
|
||||
print(f"Error: File not found: {file_path}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function."""
|
||||
if not check_dependencies():
|
||||
sys.exit(1)
|
||||
|
||||
# Get screenshot data
|
||||
items = get_screenshot_data()
|
||||
if not items:
|
||||
print("No OCR data found in the database.")
|
||||
sys.exit(1)
|
||||
|
||||
# Show rofi menu
|
||||
selection, file_path = show_rofi_menu(items)
|
||||
|
||||
# Open selected file
|
||||
if selection and file_path:
|
||||
open_file(file_path)
|
||||
else:
|
||||
print("No selection made.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+129
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# rofi_ocr_search.sh - Search and open OCR'd screenshot files using rofi
|
||||
# This script displays OCR'd screenshot data in rofi and allows opening selected files
|
||||
|
||||
# Constants
|
||||
DATABASE_PATH="$HOME/screenshot_ocr.db"
|
||||
SCREENSHOTS_DIR="$HOME/Screenshots"
|
||||
ROFI_PROMPT="Screenshot OCR"
|
||||
MAX_DISPLAY_LENGTH=100
|
||||
|
||||
# Check if required programs are installed
|
||||
check_dependencies() {
|
||||
local missing=0
|
||||
|
||||
if ! command -v rofi >/dev/null 2>&1; then
|
||||
echo "Error: rofi is not installed. Please install it with:"
|
||||
echo " sudo pacman -S rofi"
|
||||
missing=1
|
||||
fi
|
||||
|
||||
if ! command -v sqlite3 >/dev/null 2>&1; then
|
||||
echo "Error: sqlite3 is not installed. Please install it with:"
|
||||
echo " sudo pacman -S sqlite"
|
||||
missing=1
|
||||
fi
|
||||
|
||||
if ! command -v xdg-open >/dev/null 2>&1; then
|
||||
echo "Error: xdg-open is not installed. Please install it with:"
|
||||
echo " sudo pacman -S xdg-utils"
|
||||
missing=1
|
||||
fi
|
||||
|
||||
if [ ! -f "$DATABASE_PATH" ]; then
|
||||
echo "Error: Database not found at $DATABASE_PATH"
|
||||
echo "Run the OCR script first to create and populate the database."
|
||||
missing=1
|
||||
fi
|
||||
|
||||
if [ $missing -eq 1 ]; then
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Format OCR text for display in rofi
|
||||
format_ocr_text() {
|
||||
local text="$1"
|
||||
|
||||
# Replace newlines with spaces
|
||||
text="${text//$'\n'/ }"
|
||||
|
||||
# Remove multiple spaces
|
||||
text=$(echo "$text" | tr -s ' ')
|
||||
|
||||
# Truncate if too long
|
||||
if [ ${#text} -gt $MAX_DISPLAY_LENGTH ]; then
|
||||
text="${text:0:$MAX_DISPLAY_LENGTH}..."
|
||||
fi
|
||||
|
||||
echo "$text"
|
||||
}
|
||||
|
||||
# Get entries from database and format for rofi
|
||||
get_entries_for_rofi() {
|
||||
sqlite3 -separator '|' "$DATABASE_PATH" "
|
||||
SELECT filename, ocr_text, full_path
|
||||
FROM ocr_results
|
||||
ORDER BY filename
|
||||
" | while IFS='|' read -r filename ocr_text path; do
|
||||
formatted_text=$(format_ocr_text "$ocr_text")
|
||||
echo "$filename | $formatted_text"
|
||||
echo "$path" >> /tmp/ocr_paths.$$
|
||||
done
|
||||
}
|
||||
|
||||
# Main function
|
||||
main() {
|
||||
check_dependencies
|
||||
|
||||
# Create temporary file for paths
|
||||
rm -f /tmp/ocr_paths.$$ 2>/dev/null
|
||||
touch /tmp/ocr_paths.$$
|
||||
|
||||
# Get all entries and display in rofi
|
||||
selection=$(get_entries_for_rofi | rofi -dmenu -i -p "$ROFI_PROMPT" \
|
||||
-width 80 \
|
||||
-lines 15 \
|
||||
-font "mono 10" \
|
||||
-matching fuzzy)
|
||||
|
||||
# Exit if no selection made
|
||||
if [ -z "$selection" ]; then
|
||||
rm -f /tmp/ocr_paths.$$ 2>/dev/null
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract filename from selection
|
||||
filename=$(echo "$selection" | cut -d '|' -f1 | xargs)
|
||||
|
||||
# Find the corresponding line number
|
||||
line_number=1
|
||||
sqlite3 -separator '|' "$DATABASE_PATH" "
|
||||
SELECT filename
|
||||
FROM ocr_results
|
||||
ORDER BY filename
|
||||
" | while read -r db_filename; do
|
||||
if [ "$db_filename" = "$filename" ]; then
|
||||
break
|
||||
fi
|
||||
line_number=$((line_number + 1))
|
||||
done
|
||||
|
||||
# Get the full path from our temporary file
|
||||
file_path=$(sed -n "${line_number}p" /tmp/ocr_paths.$$)
|
||||
|
||||
# Open the file if path exists
|
||||
if [ -n "$file_path" ] && [ -f "$file_path" ]; then
|
||||
xdg-open "$file_path" &
|
||||
echo "Opening: $file_path"
|
||||
else
|
||||
echo "Error: Could not find file path for $filename"
|
||||
fi
|
||||
|
||||
# Clean up
|
||||
rm -f /tmp/ocr_paths.$$ 2>/dev/null
|
||||
}
|
||||
|
||||
# Run the main function
|
||||
main
|
||||
Binary file not shown.
Executable
+247
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
# Constants
|
||||
DATABASE_PATH = os.path.expanduser("~/screenshot_ocr.db")
|
||||
|
||||
|
||||
def parse_arguments():
|
||||
"""Parse command line arguments."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Search for text in OCR results from screenshots."
|
||||
)
|
||||
parser.add_argument(
|
||||
"query", help="Text to search for in the OCR results", nargs="*"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-i",
|
||||
"--case-insensitive",
|
||||
action="store_true",
|
||||
help="Perform case-insensitive search",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-e",
|
||||
"--exact",
|
||||
action="store_true",
|
||||
help="Match exact text only (default is substring match)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-l",
|
||||
"--limit",
|
||||
type=int,
|
||||
default=25,
|
||||
help="Limit number of results (default: 25, 0 for no limit)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-v",
|
||||
"--verbose",
|
||||
action="store_true",
|
||||
help="Show more details including file path and date",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-c", "--count", action="store_true", help="Only show the count of matches"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--list-all", action="store_true", help="List all files in the database"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-d", "--date", action="store_true", help="Sort results by date (newest first)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--open",
|
||||
action="store_true",
|
||||
help="Open the first matching file (requires xdg-open)",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def check_database():
|
||||
"""Check if the database exists and has the expected structure."""
|
||||
if not os.path.exists(DATABASE_PATH):
|
||||
print(f"Error: Database not found at {DATABASE_PATH}")
|
||||
print("Run the OCR script first to create and populate the database.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def search_ocr_database(args):
|
||||
"""Search the OCR database for the given query."""
|
||||
conn = sqlite3.connect(DATABASE_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Join all query words
|
||||
query_text = " ".join(args.query)
|
||||
|
||||
if args.list_all:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT filename, ocr_date, created_date, length(ocr_text), full_path
|
||||
FROM ocr_results
|
||||
ORDER BY filename ASC
|
||||
"""
|
||||
)
|
||||
|
||||
results = cursor.fetchall()
|
||||
conn.close()
|
||||
return results
|
||||
|
||||
# Construct the SQL query based on arguments
|
||||
if args.exact:
|
||||
if args.case_insensitive:
|
||||
sql_query = "SELECT * FROM ocr_results WHERE LOWER(ocr_text) = LOWER(?)"
|
||||
else:
|
||||
sql_query = "SELECT * FROM ocr_results WHERE ocr_text = ?"
|
||||
else:
|
||||
if args.case_insensitive:
|
||||
sql_query = "SELECT * FROM ocr_results WHERE LOWER(ocr_text) LIKE LOWER(?)"
|
||||
query_text = f"%{query_text}%"
|
||||
else:
|
||||
sql_query = "SELECT * FROM ocr_results WHERE ocr_text LIKE ?"
|
||||
query_text = f"%{query_text}%"
|
||||
|
||||
# Add ordering
|
||||
if args.date:
|
||||
sql_query += " ORDER BY created_date DESC"
|
||||
else:
|
||||
sql_query += " ORDER BY filename ASC"
|
||||
|
||||
# Add limit
|
||||
if args.limit > 0:
|
||||
sql_query += f" LIMIT {args.limit}"
|
||||
|
||||
# Execute the query
|
||||
cursor.execute(sql_query, (query_text,))
|
||||
results = cursor.fetchall()
|
||||
|
||||
# Close the database connection
|
||||
conn.close()
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def display_results(results, args):
|
||||
"""Display the search results."""
|
||||
if not results or len(results) == 0:
|
||||
print("No matches found.")
|
||||
return
|
||||
|
||||
if args.count:
|
||||
print(f"Found {len(results)} matches.")
|
||||
return
|
||||
|
||||
print(
|
||||
f"Found {len(results)} matches{' (showing first ' + str(args.limit) + ')' if args.limit > 0 and len(results) == args.limit else ''}:"
|
||||
)
|
||||
print("-" * 80)
|
||||
|
||||
# Handle list_all format differently
|
||||
if args.list_all:
|
||||
for filename, ocr_date, created_date, text_length, full_path in results:
|
||||
created_str = (
|
||||
created_date.split("T")[0] if "T" in created_date else created_date
|
||||
)
|
||||
if args.verbose:
|
||||
print(f"File: {filename}")
|
||||
print(f"Path: {full_path}")
|
||||
print(f"Created: {created_date}")
|
||||
print(f"OCR Date: {ocr_date}")
|
||||
print(f"Text Length: {text_length} chars")
|
||||
print("-" * 40)
|
||||
else:
|
||||
print(f"{filename} | {created_str} | {text_length} chars")
|
||||
return
|
||||
|
||||
# Display regular search results
|
||||
for row in results:
|
||||
id, filename, full_path, ocr_text, file_size, created_date, ocr_date = row
|
||||
|
||||
# Display the result
|
||||
if args.verbose:
|
||||
print(f"File: {filename}")
|
||||
print(f"Path: {full_path}")
|
||||
print(f"Created: {created_date}")
|
||||
print(f"OCR Date: {ocr_date}")
|
||||
print(f"Size: {file_size} bytes")
|
||||
print("Text:")
|
||||
print("-" * 40)
|
||||
print(ocr_text)
|
||||
print("-" * 80)
|
||||
else:
|
||||
print(f"[{filename}]")
|
||||
# Show just a snippet of text around the match if not exact
|
||||
if not args.exact and args.query:
|
||||
search_term = " ".join(args.query).lower()
|
||||
text_lower = ocr_text.lower()
|
||||
pos = text_lower.find(search_term)
|
||||
if pos >= 0:
|
||||
start = max(0, pos - 40)
|
||||
end = min(len(ocr_text), pos + len(search_term) + 40)
|
||||
# Find word boundaries
|
||||
if start > 0:
|
||||
while start > 0 and ocr_text[start].isalnum():
|
||||
start -= 1
|
||||
if end < len(ocr_text):
|
||||
while end < len(ocr_text) and ocr_text[end].isalnum():
|
||||
end += 1
|
||||
|
||||
snippet = ocr_text[start:end]
|
||||
if start > 0:
|
||||
snippet = "..." + snippet
|
||||
if end < len(ocr_text):
|
||||
snippet = snippet + "..."
|
||||
|
||||
print(snippet)
|
||||
else:
|
||||
# If can't find the term (which is odd), just show first bit
|
||||
print(ocr_text[:80] + "..." if len(ocr_text) > 80 else ocr_text)
|
||||
else:
|
||||
# Just show the first bit of text
|
||||
print(ocr_text[:80] + "..." if len(ocr_text) > 80 else ocr_text)
|
||||
print("-" * 40)
|
||||
|
||||
|
||||
def open_file(path):
|
||||
"""Open a file using the default application."""
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
subprocess.Popen(
|
||||
["xdg-open", path], stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||
)
|
||||
print(f"Opening: {path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error opening file: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function to run the OCR search."""
|
||||
args = parse_arguments()
|
||||
check_database()
|
||||
|
||||
if not args.list_all and len(args.query) == 0:
|
||||
print("Error: Please provide a search query or use --list-all")
|
||||
sys.exit(1)
|
||||
|
||||
results = search_ocr_database(args)
|
||||
|
||||
# Open the first matching file if requested
|
||||
if args.open and results and len(results) > 0:
|
||||
if args.list_all:
|
||||
open_file(results[0][4]) # full_path is at index 4 for list_all
|
||||
else:
|
||||
open_file(results[0][2]) # full_path is at index 2 for regular search
|
||||
|
||||
# Always display results unless we're only opening
|
||||
if not (args.open and not args.verbose):
|
||||
display_results(results, args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# simple_rofi_ocr.sh - A simplified script to search OCR'd screenshots with rofi
|
||||
|
||||
# Database location
|
||||
DB_PATH="$HOME/screenshot_ocr.db"
|
||||
|
||||
# Check if database exists
|
||||
if [ ! -f "$DB_PATH" ]; then
|
||||
echo "Error: Database not found at $DB_PATH"
|
||||
echo "Run the OCR script first to create and populate the database."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if rofi exists
|
||||
if ! command -v rofi &> /dev/null; then
|
||||
echo "Error: rofi is not installed. Please install it with:"
|
||||
echo " sudo pacman -S rofi"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if sqlite3 exists
|
||||
if ! command -v sqlite3 &> /dev/null; then
|
||||
echo "Error: sqlite3 is not installed. Please install it with:"
|
||||
echo " sudo pacman -S sqlite"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create a temporary file to store data
|
||||
TMP_FILE=$(mktemp)
|
||||
trap 'rm -f $TMP_FILE' EXIT
|
||||
|
||||
# Get data from database and format for rofi
|
||||
sqlite3 -separator '|' "$DB_PATH" "
|
||||
SELECT full_path, filename, ocr_text
|
||||
FROM ocr_results
|
||||
ORDER BY filename
|
||||
" > "$TMP_FILE"
|
||||
|
||||
# Process each line to format for rofi and create menu items
|
||||
menu_items=""
|
||||
while IFS='|' read -r path filename ocr_text; do
|
||||
# Clean up text for display (replace newlines, truncate)
|
||||
clean_text=$(echo "$ocr_text" | tr '\n' ' ' | sed 's/ / /g')
|
||||
if [ ${#clean_text} -gt 80 ]; then
|
||||
clean_text="${clean_text:0:80}..."
|
||||
fi
|
||||
|
||||
# Add to menu items
|
||||
menu_items+="$filename | $clean_text\n"
|
||||
done < "$TMP_FILE"
|
||||
|
||||
# Display rofi menu
|
||||
selection=$(echo -e "$menu_items" | rofi -dmenu -i -p "Screenshot OCR" -width 80 -lines 15 -font "mono 10")
|
||||
|
||||
# Exit if no selection
|
||||
if [ -z "$selection" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract filename from selection
|
||||
selected_filename=$(echo "$selection" | cut -d'|' -f1 | sed 's/ *$//')
|
||||
|
||||
# Find the path for the selected filename
|
||||
selected_path=$(grep -F "|$selected_filename|" "$TMP_FILE" | cut -d'|' -f1)
|
||||
|
||||
# Open the file if found
|
||||
if [ -n "$selected_path" ] && [ -f "$selected_path" ]; then
|
||||
echo "Opening: $selected_path"
|
||||
xdg-open "$selected_path" &
|
||||
else
|
||||
echo "Error: Could not find file: $selected_path"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -5,11 +5,21 @@ A Qt6-based image gallery application that allows you to search through OCR data
|
||||
## Features
|
||||
|
||||
- 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 |
@@ -0,0 +1,8 @@
|
||||
<!DOCTYPE RCC><RCC version="1.0">
|
||||
<qresource prefix="/">
|
||||
<file>icons/orcs-gallery-64.png</file>
|
||||
<file>icons/orcs-gallery-128.png</file>
|
||||
<file>icons/orcs-gallery-256.png</file>
|
||||
<file>icons/orcs-gallery-512.png</file>
|
||||
</qresource>
|
||||
</RCC>
|
||||
@@ -7,5 +7,5 @@ Exec=/home/master/screenshot-gallery/build/screenshot-gallery
|
||||
Terminal=false
|
||||
Categories=Graphics;Utility;Viewer;
|
||||
Keywords=screenshots;ocr;gallery;search;images;
|
||||
Icon=image-viewer
|
||||
Icon=orcs-gallery
|
||||
StartupNotify=true
|
||||
|
||||
+603
-92
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -0,0 +1,260 @@
|
||||
#include "settingsdialog.h"
|
||||
#include <QVBoxLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QFileInfo>
|
||||
#include <QStandardPaths>
|
||||
#include <QIntValidator>
|
||||
|
||||
// Define static constants
|
||||
const QString SettingsDialog::DEFAULT_SCREENSHOTS_DIR = QDir::homePath() + "/Screenshots";
|
||||
const QString SettingsDialog::DEFAULT_DATABASE_FILENAME = "screenshot_ocr.db";
|
||||
const QString SettingsDialog::CONFIG_FILE_PATH = QDir::homePath() + "/.config/ScreenshotOCRGallery/settings.ini";
|
||||
|
||||
SettingsDialog::SettingsDialog(QWidget *parent)
|
||||
: QDialog(parent)
|
||||
{
|
||||
setWindowTitle(tr("Settings"));
|
||||
setMinimumWidth(500);
|
||||
|
||||
// Create UI elements
|
||||
createLayout();
|
||||
|
||||
// Load saved settings
|
||||
loadSettings();
|
||||
|
||||
// Connect signals and slots
|
||||
connect(m_browseScreenshotsDirBtn, &QPushButton::clicked, this, &SettingsDialog::handleBrowseScreenshotsDir);
|
||||
connect(m_browseDatabaseBtn, &QPushButton::clicked, this, &SettingsDialog::handleBrowseDatabase);
|
||||
connect(m_buttonBox, &QDialogButtonBox::accepted, this, &SettingsDialog::handleAccepted);
|
||||
connect(m_buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||
}
|
||||
|
||||
SettingsDialog::~SettingsDialog()
|
||||
{
|
||||
// Qt will handle cleanup of UI elements through parent-child relationships
|
||||
}
|
||||
|
||||
void SettingsDialog::createLayout()
|
||||
{
|
||||
QVBoxLayout *mainLayout = new QVBoxLayout(this);
|
||||
QGridLayout *formLayout = new QGridLayout();
|
||||
|
||||
// Ensure config directory exists
|
||||
QFileInfo configFile(CONFIG_FILE_PATH);
|
||||
QDir configDir = configFile.absoluteDir();
|
||||
if (!configDir.exists()) {
|
||||
configDir.mkpath(".");
|
||||
}
|
||||
|
||||
// Screenshots directory row
|
||||
QLabel *screenshotsDirLabel = new QLabel(tr("Screenshots Directory:"), this);
|
||||
m_screenshotsDirEdit = new QLineEdit(this);
|
||||
m_browseScreenshotsDirBtn = new QPushButton(tr("..."), this);
|
||||
m_browseScreenshotsDirBtn->setMaximumWidth(40);
|
||||
|
||||
formLayout->addWidget(screenshotsDirLabel, 0, 0);
|
||||
formLayout->addWidget(m_screenshotsDirEdit, 0, 1);
|
||||
formLayout->addWidget(m_browseScreenshotsDirBtn, 0, 2);
|
||||
|
||||
// Database path row
|
||||
QLabel *databasePathLabel = new QLabel(tr("Database File Path:"), this);
|
||||
m_databasePathEdit = new QLineEdit(this);
|
||||
m_browseDatabaseBtn = new QPushButton(tr("..."), this);
|
||||
m_browseDatabaseBtn->setMaximumWidth(40);
|
||||
|
||||
formLayout->addWidget(databasePathLabel, 1, 0);
|
||||
formLayout->addWidget(m_databasePathEdit, 1, 1);
|
||||
formLayout->addWidget(m_browseDatabaseBtn, 1, 2);
|
||||
|
||||
// Preload count row
|
||||
QLabel *preloadCountLabel = new QLabel(tr("Images to pre-load:"), this);
|
||||
m_imagePreloadCountEdit = new QLineEdit(this);
|
||||
m_imagePreloadCountEdit->setValidator(new QIntValidator(1, 100, this));
|
||||
|
||||
formLayout->addWidget(preloadCountLabel, 2, 0);
|
||||
formLayout->addWidget(m_imagePreloadCountEdit, 2, 1);
|
||||
|
||||
// Help text
|
||||
QLabel *helpText = new QLabel(tr("Note: If no filename is provided for the database path, "
|
||||
"'screenshot_ocr.db' will be used automatically."), this);
|
||||
helpText->setWordWrap(true);
|
||||
helpText->setStyleSheet("color: #666; font-size: 11px;");
|
||||
|
||||
// Button box
|
||||
m_buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
|
||||
|
||||
// Add to main layout
|
||||
mainLayout->addLayout(formLayout);
|
||||
mainLayout->addWidget(helpText);
|
||||
mainLayout->addSpacing(10);
|
||||
mainLayout->addWidget(m_buttonBox);
|
||||
|
||||
setLayout(mainLayout);
|
||||
}
|
||||
|
||||
void SettingsDialog::handleBrowseScreenshotsDir()
|
||||
{
|
||||
QString dir = QFileDialog::getExistingDirectory(
|
||||
this, tr("Select Screenshots Directory"),
|
||||
m_screenshotsDirEdit->text().isEmpty() ? QDir::homePath() : m_screenshotsDirEdit->text(),
|
||||
QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks);
|
||||
|
||||
if (!dir.isEmpty()) {
|
||||
m_screenshotsDirEdit->setText(dir);
|
||||
}
|
||||
}
|
||||
|
||||
void SettingsDialog::handleBrowseDatabase()
|
||||
{
|
||||
QString currentPath = m_databasePathEdit->text();
|
||||
if (currentPath.isEmpty()) {
|
||||
currentPath = QDir::homePath();
|
||||
} else {
|
||||
QFileInfo fileInfo(currentPath);
|
||||
if (fileInfo.isFile()) {
|
||||
currentPath = fileInfo.absolutePath();
|
||||
}
|
||||
}
|
||||
|
||||
QString filePath = QFileDialog::getSaveFileName(
|
||||
this, tr("Select Database File"),
|
||||
currentPath,
|
||||
tr("SQLite Database (*.db);;All Files (*)"));
|
||||
|
||||
if (!filePath.isEmpty()) {
|
||||
m_databasePathEdit->setText(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
void SettingsDialog::handleAccepted()
|
||||
{
|
||||
// Validate settings
|
||||
QDir screenshotsDir(m_screenshotsDirEdit->text());
|
||||
if (!screenshotsDir.exists()) {
|
||||
// Ask if we should create the directory
|
||||
QMessageBox::StandardButton reply = QMessageBox::question(
|
||||
this, tr("Directory Not Found"),
|
||||
tr("The screenshots directory does not exist. Create it?"),
|
||||
QMessageBox::Yes | QMessageBox::No);
|
||||
|
||||
if (reply == QMessageBox::Yes) {
|
||||
if (!screenshotsDir.mkpath(".")) {
|
||||
QMessageBox::warning(this, tr("Error"),
|
||||
tr("Failed to create directory: %1").arg(screenshotsDir.path()));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// User chose not to create directory
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure database path has a filename
|
||||
m_databasePathEdit->setText(ensureDatabaseFilename(m_databasePathEdit->text()));
|
||||
|
||||
// Check if database directory exists
|
||||
QFileInfo dbFileInfo(m_databasePathEdit->text());
|
||||
QDir dbDir = dbFileInfo.absoluteDir();
|
||||
|
||||
if (!dbDir.exists()) {
|
||||
QMessageBox::StandardButton reply = QMessageBox::question(
|
||||
this, tr("Directory Not Found"),
|
||||
tr("The database directory does not exist. Create it?"),
|
||||
QMessageBox::Yes | QMessageBox::No);
|
||||
|
||||
if (reply == QMessageBox::Yes) {
|
||||
if (!dbDir.mkpath(".")) {
|
||||
QMessageBox::warning(this, tr("Error"),
|
||||
tr("Failed to create directory: %1").arg(dbDir.path()));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// User chose not to create directory
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Save settings
|
||||
saveSettings();
|
||||
|
||||
// Accept dialog
|
||||
accept();
|
||||
}
|
||||
|
||||
QString SettingsDialog::getScreenshotsDir() const
|
||||
{
|
||||
return m_screenshotsDirEdit->text();
|
||||
}
|
||||
|
||||
QString SettingsDialog::getDatabasePath() const
|
||||
{
|
||||
return m_databasePathEdit->text();
|
||||
}
|
||||
|
||||
void SettingsDialog::loadSettings()
|
||||
{
|
||||
QSettings settings(CONFIG_FILE_PATH, QSettings::IniFormat);
|
||||
|
||||
// Load screenshots directory
|
||||
QString screenshotsDir = settings.value("screenshotsDir", DEFAULT_SCREENSHOTS_DIR).toString();
|
||||
m_screenshotsDirEdit->setText(screenshotsDir);
|
||||
|
||||
// Load database path
|
||||
QString databasePath = settings.value("databasePath", QDir::homePath() + "/" + DEFAULT_DATABASE_FILENAME).toString();
|
||||
m_databasePathEdit->setText(databasePath);
|
||||
|
||||
// Load preload count
|
||||
int preloadCount = settings.value("imagePreloadCount", DEFAULT_PRELOAD_COUNT).toInt();
|
||||
m_imagePreloadCountEdit->setText(QString::number(preloadCount));
|
||||
}
|
||||
|
||||
void SettingsDialog::saveSettings()
|
||||
{
|
||||
QSettings settings(CONFIG_FILE_PATH, QSettings::IniFormat);
|
||||
|
||||
// Save screenshots directory
|
||||
settings.setValue("screenshotsDir", getScreenshotsDir());
|
||||
|
||||
// Save database path
|
||||
settings.setValue("databasePath", getDatabasePath());
|
||||
|
||||
// Save preload count
|
||||
settings.setValue("imagePreloadCount", getImagePreloadCount());
|
||||
|
||||
settings.sync();
|
||||
}
|
||||
|
||||
int SettingsDialog::getImagePreloadCount() const
|
||||
{
|
||||
bool ok;
|
||||
int count = m_imagePreloadCountEdit->text().toInt(&ok);
|
||||
|
||||
// Return the default if conversion fails or value is invalid
|
||||
if (!ok || count < 1) {
|
||||
return DEFAULT_PRELOAD_COUNT;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
QString SettingsDialog::ensureDatabaseFilename(const QString &path)
|
||||
{
|
||||
if (path.isEmpty()) {
|
||||
// If path is empty, use default in home directory
|
||||
return QDir::homePath() + "/" + DEFAULT_DATABASE_FILENAME;
|
||||
}
|
||||
|
||||
QFileInfo fileInfo(path);
|
||||
if (fileInfo.isDir()) {
|
||||
// If path is a directory, append default filename
|
||||
QString dirPath = path;
|
||||
// Ensure path ends with a separator
|
||||
if (!dirPath.endsWith('/') && !dirPath.endsWith('\\')) {
|
||||
dirPath += '/';
|
||||
}
|
||||
return dirPath + DEFAULT_DATABASE_FILENAME;
|
||||
}
|
||||
|
||||
// If path already has a filename component, use it as is
|
||||
return path;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
#ifndef SETTINGSDIALOG_H
|
||||
#define SETTINGSDIALOG_H
|
||||
|
||||
#include <QDialog>
|
||||
#include <QLineEdit>
|
||||
#include <QPushButton>
|
||||
#include <QLabel>
|
||||
#include <QGridLayout>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFileDialog>
|
||||
#include <QSettings>
|
||||
#include <QDir>
|
||||
#include <QMessageBox>
|
||||
|
||||
class SettingsDialog : public QDialog
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit SettingsDialog(QWidget *parent = nullptr);
|
||||
~SettingsDialog();
|
||||
|
||||
// Get settings values
|
||||
QString getScreenshotsDir() const;
|
||||
QString getDatabasePath() const;
|
||||
int getImagePreloadCount() const;
|
||||
|
||||
// Load settings from persistent storage
|
||||
void loadSettings();
|
||||
|
||||
// Save settings to persistent storage
|
||||
void saveSettings();
|
||||
|
||||
private slots:
|
||||
// Button click handlers
|
||||
void handleBrowseScreenshotsDir();
|
||||
void handleBrowseDatabase();
|
||||
void handleAccepted();
|
||||
|
||||
private:
|
||||
// UI Elements
|
||||
QLineEdit *m_screenshotsDirEdit;
|
||||
QLineEdit *m_databasePathEdit;
|
||||
QLineEdit *m_imagePreloadCountEdit;
|
||||
QPushButton *m_browseScreenshotsDirBtn;
|
||||
QPushButton *m_browseDatabaseBtn;
|
||||
QDialogButtonBox *m_buttonBox;
|
||||
|
||||
// Helper methods
|
||||
void createLayout();
|
||||
QString ensureDatabaseFilename(const QString &path);
|
||||
|
||||
// Default settings
|
||||
static const QString DEFAULT_SCREENSHOTS_DIR;
|
||||
static const QString DEFAULT_DATABASE_FILENAME;
|
||||
static const QString CONFIG_FILE_PATH;
|
||||
static const int DEFAULT_PRELOAD_COUNT = 20;
|
||||
};
|
||||
|
||||
#endif // SETTINGSDIALOG_H
|
||||
Reference in New Issue
Block a user