commit 412af2c8ec8e5138155307079e7176af27b895b6 Author: tcsenpai Date: Thu Aug 22 18:02:18 2024 +0200 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..602258f --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +static/screenshots/*.png +static/screenshots/*.jpg +static/screenshots/*.jpeg +static/screenshots/*.gif +static/screenshots/*.bmp +static/screenshots/*.tiff +static/screenshots/*.webp +screenshots.db +__pycache__ +.venv +.vscode +.idea +.DS_Store +.env +.env.local +.env.development +.env.development.local +.env.test +.env.test.local \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5b9f32d --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# Screenshot Timeline + +A work in progress privacy focused alternative to Microsoft Copilot and similar spyware. + +Screenshot Timeline is an automated screenshot capture and analysis tool that uses OCR to extract text from images and organize them with tags for easy searching and filtering. + +Thanks to Screenshot Timeline, you can now easily search through your screenshots and find specific ones based on the text they contain. + +## Features + +- Automated screenshot capture at customizable intervals +- OCR processing using Tesseract +- Tag generation based on extracted text +- Search functionality for finding specific screenshots +- Real-time updates of screenshot processing status +- Ability to edit tags for each screenshot +- Batch processing of unanalyzed screenshots +- Configuration options for OCR processing and screenshot management + +## Technology Stack + +- Backend: Python with Flask +- Frontend: HTML, CSS, JavaScript +- OCR: Tesseract +- Task Queue: Celery with Redis +- Database: SQLite +- Image Processing: OpenCV + +## Why Tesseract? + +We chose Tesseract for OCR processing due to its: + +1. Open-source nature and active community support +2. High accuracy in text recognition across various languages +3. Easy integration with Python through the pytesseract library +4. Flexibility in configuration for different use cases +5. Continuous improvements and updates + +## Installation and Setup + +1. Update and upgrade your system: +``` +sudo apt update && sudo apt upgrade -y +``` + +2. Install required system dependencies: +``` +sudo apt install -y python3 python3-pip redis-server spectacle tesseract-ocr libtesseract-dev +``` + +3. Clone the repository: +git clone https://github.com/tcsenpai/screenshot-timeline.git +cd screenshot-timeline + + +4. Create a virtual environment and activate it: +``` +python3 -m venv venv +source venv/bin/activate +``` + +5. Install Python dependencies: +``` +pip install -r requirements.txt +``` + +6. Start the app: +``` +./start_services.sh +``` + +7. Open your browser and go to http://localhost:5000 + + +## Configuration + +The application can be configured through the web interface. Options include: + +- Setting screenshot capture intervals +- Triggering OCR for all unprocessed images +- Deleting all screenshots and resetting the database + +## Contributing + + +We welcome contributions to the Screenshot Timeline project! If you'd like to contribute, please follow these steps: + +1. Fork the repository on GitHub. +2. Create a new branch with a descriptive name for your feature or bug fix. +3. Make your changes, ensuring you follow the project's coding style and conventions. +4. Add or update tests as necessary to cover your changes. +5. Ensure all tests pass by running the test suite. +6. Commit your changes with a clear and descriptive commit message. +7. Push your branch to your fork on GitHub. +8. Open a pull request against the main repository's `main` branch. +9. Provide a clear description of your changes in the pull request. + +Please note that by contributing to this project, you agree to license your contributions under the project's MIT License. For major changes or new features, please open an issue first to discuss the proposed changes. We strive to maintain a welcoming and inclusive community, so please adhere to our code of conduct in all interactions related to the project. + +## License + +This project is licensed under the MIT License. + +## Credits + +- Tesseract OCR: https://github.com/tesseract-ocr/tesseract +- Flask: https://flask.palletsprojects.com/ +- Celery: https://docs.celeryproject.org/ +- OpenCV: https://opencv.org/ +- Material Design Components: https://material.io/develop/web + +(Add any other libraries or resources used in the project) \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..eab0669 --- /dev/null +++ b/main.py @@ -0,0 +1,413 @@ +import time +import os +import threading +from datetime import datetime +from flask import Flask, render_template, request, jsonify, Response +import subprocess +import pytesseract +from PIL import Image +import logging +from celery import Celery, signals, group +import sqlite3 +import json +import nltk +from nltk.tokenize import word_tokenize +from nltk.corpus import stopwords +import ssl +import argparse +from itertools import islice +import cv2 +import numpy as np + +# Configuration +SCREENSHOT_INTERVAL = 5 * 60 # 5 minutes +SCREENSHOT_DIR = "static/screenshots" +DATABASE = "screenshots.db" + +# Ensure screenshot directory exists +os.makedirs(SCREENSHOT_DIR, exist_ok=True) + +# Set up logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Flask app +app = Flask(__name__) + +# Celery configuration +app.config['CELERY_BROKER_URL'] = os.environ.get('CELERY_BROKER_URL', 'redis://localhost:6379/0') +app.config['CELERY_RESULT_BACKEND'] = os.environ.get('CELERY_RESULT_BACKEND', 'redis://localhost:6379/0') +app.config['CELERYD_CONCURRENCY'] = 2 # Limit to 2 concurrent workers + +# Initialize Celery +celery = Celery(app.name, broker=app.config['CELERY_BROKER_URL']) +celery.conf.update(app.config) + +# Database initialization +def init_db(): + with sqlite3.connect(DATABASE) as conn: + conn.execute('''CREATE TABLE IF NOT EXISTS screenshots + (id INTEGER PRIMARY KEY AUTOINCREMENT, + filename TEXT NOT NULL, + timestamp TEXT NOT NULL, + ocr_text TEXT, + tags TEXT)''') + +init_db() + +def ensure_nltk_data(): + try: + _create_unverified_https_context = ssl._create_unverified_https_context + except AttributeError: + pass + else: + ssl._create_default_https_context = _create_unverified_https_context + + nltk.download('punkt', quiet=True) + nltk.download('punkt_tab', quiet=True) + nltk.download('stopwords', quiet=True) + +ensure_nltk_data() + +def get_existing_words(): + with sqlite3.connect(DATABASE) as conn: + cur = conn.cursor() + cur.execute("SELECT DISTINCT tags FROM screenshots WHERE tags IS NOT NULL") + all_tags = cur.fetchall() + existing_words = set() + for tags in all_tags: + if tags[0]: + existing_words.update(json.loads(tags[0])) + return existing_words + +def generate_tags(ocr_text): + tokens = word_tokenize(ocr_text.lower()) + stop_words = set(stopwords.words('english')) + existing_words = get_existing_words() + tags = [word for word in tokens if word.isalnum() and word not in stop_words and word in existing_words] + return list(set(tags))[:5] # Remove duplicates and limit to 5 tags + +# Global variable to store the OCR engine +ocr_engine = None + +def initialize_ocr_engine(): + global ocr_engine + ocr_engine = pytesseract + print("Tesseract OCR initialized successfully.") + +@signals.worker_process_init.connect +def init_worker(**kwargs): + global ocr_engine + initialize_ocr_engine() + +@celery.task +def process_screenshot(image_path): + try: + logger.info(f"Performing OCR on {image_path}") + ocr_text = perform_ocr(image_path) + tags = generate_tags(ocr_text) + logger.info(f"OCR completed for {image_path}") + + with sqlite3.connect(DATABASE) as conn: + conn.execute("UPDATE screenshots SET ocr_text = ?, tags = ? WHERE filename = ?", + (ocr_text, json.dumps(tags), image_path)) + conn.commit() + + return ocr_text + except Exception as e: + logger.error(f"Error performing OCR on {image_path}: {e}") + return "" + +def preprocess_image(image_path): + # Read the image + img = cv2.imread(image_path) + + # Convert to grayscale + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + + # Apply thresholding to preprocess the image + gray = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)[1] + + # Apply dilation and erosion to remove some noise + kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3,3)) + gray = cv2.dilate(gray, kernel, iterations=1) + gray = cv2.erode(gray, kernel, iterations=1) + + # Apply median blur to remove noise + gray = cv2.medianBlur(gray, 3) + + # Scale the image + gray = cv2.resize(gray, None, fx=2, fy=2, interpolation=cv2.INTER_CUBIC) + + return gray + +def fast_isolate_text_regions(img): + # Edge detection + edges = cv2.Canny(img, 100, 200) + + # Dilate edges to connect text regions + kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5,5)) + dilated = cv2.dilate(edges, kernel, iterations=3) + + # Find contours + contours, _ = cv2.findContours(dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + + # Create mask + mask = np.zeros(img.shape, dtype=np.uint8) + + for contour in contours: + x, y, w, h = cv2.boundingRect(contour) + area = w * h + aspect_ratio = w / float(h) + + # Filter contours based on area and aspect ratio + if 100 < area < 50000 and 0.1 < aspect_ratio < 10: + cv2.rectangle(mask, (x, y), (x + w, y + h), (255, 255, 255), -1) + + # Apply the mask to the original image + result = cv2.bitwise_and(img, mask) + + return result + +def perform_ocr(image_path): + # Preprocess the image + preprocessed = preprocess_image(image_path) + + # Isolate text regions + text_regions = fast_isolate_text_regions(preprocessed) + + # Save the preprocessed image temporarily + temp_file = f"temp_{os.getpid()}.png" + cv2.imwrite(temp_file, text_regions) + + try: + # Perform OCR on the preprocessed image + custom_config = r'--oem 3 --psm 6' + text = pytesseract.image_to_string(Image.open(temp_file), config=custom_config) + return text + finally: + # Clean up the temporary file + os.remove(temp_file) + +# Screenshot function +def take_screenshot(): + while True: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = os.path.abspath(f"{SCREENSHOT_DIR}/screenshot_{timestamp}.png") + try: + subprocess.run([ + "spectacle", + "-b", # background mode + "-n", # no notification + "-o", filename, # output file + "-f" # full screen + ], check=True) + logger.info(f"Screenshot saved: {filename}") + + # Store screenshot info in database + with sqlite3.connect(DATABASE) as conn: + conn.execute("INSERT INTO screenshots (filename, timestamp) VALUES (?, ?)", + (filename, timestamp)) + + # Trigger async OCR task + process_screenshot.delay(filename) + except subprocess.CalledProcessError as e: + logger.error(f"Error taking screenshot: {e}") + time.sleep(SCREENSHOT_INTERVAL) + +# Web routes +@app.route('/') +def index(): + with sqlite3.connect(DATABASE) as conn: + cur = conn.cursor() + cur.execute("SELECT filename, timestamp, ocr_text, tags FROM screenshots ORDER BY timestamp DESC") + screenshots = cur.fetchall() + screenshots = [{'filename': os.path.basename(s[0]), 'timestamp': s[1], 'formatted_timestamp': format_timestamp(s[1]), 'ocr_status': bool(s[2]), 'tags': json.loads(s[3]) if s[3] else []} for s in screenshots] + return render_template('index.html', screenshots=screenshots) + +@app.route('/search', methods=['POST']) +def search(): + query = request.form.get('query', '').lower() + with sqlite3.connect(DATABASE) as conn: + cur = conn.cursor() + cur.execute("SELECT filename, timestamp, ocr_text, tags FROM screenshots WHERE LOWER(ocr_text) LIKE ?", (f'%{query}%',)) + results = cur.fetchall() + return jsonify([{'filename': os.path.basename(r[0]), 'timestamp': r[1], 'formatted_timestamp': format_timestamp(r[1]), 'ocr_status': bool(r[2]), 'tags': json.loads(r[3]) if r[3] else []} for r in results]) + +def batch_process_screenshots(batch_size=5): + with sqlite3.connect(DATABASE) as conn: + cur = conn.cursor() + cur.execute("SELECT filename FROM screenshots WHERE ocr_text IS NULL") + screenshots = cur.fetchall() + + def chunks(data, size): + it = iter(data) + return iter(lambda: tuple(islice(it, size)), ()) + + for batch in chunks(screenshots, batch_size): + group(process_screenshot.s(screenshot[0]) for screenshot in batch)().get() + +@app.route('/ocr-all', methods=['POST']) +def ocr_all(): + batch_process_screenshots.delay() + return jsonify({"message": "OCR started for all unprocessed images in batches."}) + +@celery.task +def batch_process_screenshots(batch_size=5): + with sqlite3.connect(DATABASE) as conn: + cur = conn.cursor() + cur.execute("SELECT filename FROM screenshots WHERE ocr_text IS NULL") + screenshots = cur.fetchall() + + def chunks(data, size): + it = iter(data) + return iter(lambda: tuple(islice(it, size)), ()) + + for batch in chunks(screenshots, batch_size): + group(process_screenshot.s(screenshot[0]) for screenshot in batch)().get() + +@app.route('/delete-all', methods=['POST']) +def delete_all(): + with sqlite3.connect(DATABASE) as conn: + cur = conn.cursor() + cur.execute("SELECT filename FROM screenshots") + screenshots = cur.fetchall() + for screenshot in screenshots: + os.remove(screenshot[0]) + cur.execute("DELETE FROM screenshots") + return jsonify({"message": "All screenshots deleted."}) + +@app.route('/set-interval', methods=['POST']) +def set_interval(): + interval = request.form.get('interval', type=int) + if interval: + global SCREENSHOT_INTERVAL + SCREENSHOT_INTERVAL = interval + return jsonify({"message": f"Screenshot interval set to {interval} seconds."}) + return jsonify({"message": "Invalid interval."}) + +@app.route('/status-updates') +def status_updates(): + def generate(): + with sqlite3.connect(DATABASE) as conn: + cur = conn.cursor() + last_id = 0 + while True: + cur.execute("SELECT id, filename, timestamp, ocr_text, tags FROM screenshots WHERE id > ? ORDER BY id", (last_id,)) + results = cur.fetchall() + for row in results: + last_id = row[0] + status = "Analyzed" if row[3] else "Not yet analyzed" + data = { + 'id': row[0], + 'filename': os.path.basename(row[1]), + 'timestamp': row[2], + 'status': status, + 'is_new': last_id == row[0], + 'tags': json.loads(row[4]) if row[4] else [] + } + yield f"data: {json.dumps(data)}\n\n" + time.sleep(1) + + return Response(generate(), mimetype='text/event-stream') + +@app.route('/delete-all-and-reset-db', methods=['POST']) +def delete_all_and_reset_db(): + try: + # Delete all screenshot files + for filename in os.listdir(SCREENSHOT_DIR): + file_path = os.path.join(SCREENSHOT_DIR, filename) + if os.path.isfile(file_path): + os.unlink(file_path) + + # Reset the database + with sqlite3.connect(DATABASE) as conn: + conn.execute("DELETE FROM screenshots") + conn.execute("DELETE FROM sqlite_sequence WHERE name='screenshots'") + + return jsonify({"message": "All screenshots deleted and database reset successfully."}) + except Exception as e: + return jsonify({"message": f"An error occurred: {str(e)}"}), 500 + +@app.route('/filter-by-tag/') +def filter_by_tag(tag): + with sqlite3.connect(DATABASE) as conn: + cur = conn.cursor() + cur.execute("SELECT * FROM screenshots WHERE tags LIKE ?", (f'%"{tag}"%',)) + screenshots = cur.fetchall() + return jsonify([{ + 'id': s[0], + 'filename': s[1], + 'timestamp': s[2], + 'ocr_text': s[3], + 'tags': json.loads(s[4]) if s[4] else [] + } for s in screenshots]) + +@app.route('/get-all-tags') +def get_all_tags(): + with sqlite3.connect(DATABASE) as conn: + cur = conn.cursor() + cur.execute("SELECT DISTINCT tags FROM screenshots WHERE tags IS NOT NULL") + all_tags = cur.fetchall() + unique_tags = set() + for tags in all_tags: + if tags[0]: + unique_tags.update(json.loads(tags[0])) + return jsonify(list(unique_tags)) + +@app.route('/update_tags', methods=['POST']) +def update_tags(): + data = request.json + filename = data['filename'] + new_tags = data['tags'] + this_dir = os.path.dirname(os.path.abspath(__file__)) + filepath = os.path.join(this_dir, SCREENSHOT_DIR, filename) + print("Updating tags for:", filepath) + try: + with sqlite3.connect(DATABASE) as conn: + cur = conn.cursor() + cur.execute("UPDATE screenshots SET tags = ? WHERE filename = ?", + (json.dumps(new_tags), filepath)) + conn.commit() + return jsonify({"success": True, "message": "Tags updated successfully"}) + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 + +@app.route('/get_screenshot_info/') +def get_screenshot_info(filename): + with sqlite3.connect(DATABASE) as conn: + cur = conn.cursor() + this_dir = os.path.dirname(os.path.abspath(__file__)) + filepath = os.path.join(this_dir, SCREENSHOT_DIR, filename) + print("Getting info for:", filepath) + cur.execute("SELECT timestamp, ocr_text, tags FROM screenshots WHERE filename = ?", (filepath,)) + result = cur.fetchone() + + if result: + return jsonify({ + "timestamp": result[0], + "ocr_text": result[1], + "tags": json.loads(result[2]) if result[2] else [] + }) + else: + return jsonify({"error": "Screenshot not found"}), 404 + +def format_timestamp(timestamp): + try: + dt = datetime.strptime(timestamp, "%Y%m%d_%H%M%S") + return dt.strftime("%Y-%m-%d %H:%M:%S") + except ValueError: + return "Invalid Date" + +# Main function +def main(): + # Start screenshot thread + screenshot_thread = threading.Thread(target=take_screenshot, daemon=True) + screenshot_thread.start() + + app.run(debug=True, use_reloader=False) + +if __name__ == "__main__": + initialize_ocr_engine() + ensure_nltk_data() # Ensure NLTK data is available before starting the app + main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5a29cf6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +flask +pillow +pytesseract +nltk +celery +redis \ No newline at end of file diff --git a/start_services.sh b/start_services.sh new file mode 100755 index 0000000..d358fd9 --- /dev/null +++ b/start_services.sh @@ -0,0 +1,139 @@ +#!/bin/bash + +# Function to display help message +show_help() { + echo "Usage: $0 [-c] [-h]" + echo " -c Clean screenshots and reset database before starting" + echo " -h Display this help message" +} + +# Function to clean screenshots and reset database +clean_data() { + echo "Cleaning screenshots and resetting database..." + rm -f static/screenshots/* + rm -f screenshots.db + echo "Cleanup complete." +} + +# Function to stop all services +stop_services() { + echo "Stopping services..." + + # Stop Flask + if [ ! -z "$FLASK_PID" ]; then + kill -TERM "$FLASK_PID" 2>/dev/null + wait "$FLASK_PID" 2>/dev/null + fi + + # Stop Celery + if [ ! -z "$CELERY_PID" ]; then + kill -TERM "$CELERY_PID" 2>/dev/null + wait "$CELERY_PID" 2>/dev/null + fi + + # Stop Redis container + docker stop redis-server + docker rm redis-server + + echo "All services stopped." + exit 0 +} + +# Set up trap to call stop_services on script exit +trap stop_services EXIT INT TERM + +# Function to ensure NLTK data is downloaded +ensure_nltk_data() { + echo "Ensuring NLTK data is available..." + python - <&2 + show_help + exit 1 + ;; + esac +done +shift $((OPTIND -1)) + +# Ensure NLTK data is downloaded +ensure_nltk_data + +# Start Redis using Docker +echo "Starting Redis using Docker..." +docker run --name redis-server -p 6379:6379 -d redis + +# Wait for Redis to start +sleep 2 + +# Check if Redis container is running +if ! docker ps | grep -q redis-server; then + echo "Failed to start Redis container. Please check Docker and Redis image and try again." + exit 1 +fi + +echo "Redis server started successfully." + +# Start Celery worker +echo "Starting Celery worker..." +export CELERY_BROKER_URL="redis://localhost:6379/0" +export CELERY_RESULT_BACKEND="redis://localhost:6379/0" +celery -A main.celery worker --loglevel=info --concurrency=2 & +CELERY_PID=$! + +# Wait for Celery to start +sleep 5 + +# Check if Celery is running +if ! kill -0 $CELERY_PID 2>/dev/null; then + echo "Failed to start Celery worker. Please check Celery installation and try again." + exit 1 +fi + +echo "Celery worker started successfully." + +# Start Flask application +echo "Starting Flask application..." +python main.py & +FLASK_PID=$! + +# Wait for Flask to start +sleep 2 + +# Check if Flask is running +if ! kill -0 $FLASK_PID 2>/dev/null; then + echo "Failed to start Flask application. Please check Flask installation and try again." + exit 1 +fi + +echo "Flask application started successfully." + +echo "All services started. Press Ctrl+C to stop." + +# Wait for user interrupt +wait \ No newline at end of file diff --git a/static/css/styles.css b/static/css/styles.css new file mode 100644 index 0000000..720b8d8 --- /dev/null +++ b/static/css/styles.css @@ -0,0 +1,546 @@ +body { + margin: 0; + background-color: #121212; + color: rgba(255, 255, 255, 0.87); +} + +.content { + padding: 16px; +} + +.mdc-text-field { + width: 100%; + margin-bottom: 16px; +} + +.search-results { + margin-bottom: 16px; +} + +.screenshot-card { + background-color: #1e1e1e; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + transition: all 0.3s ease; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.screenshot-card:hover { + transform: translateY(-5px); + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15); +} + +.screenshot-image { + background-size: cover; + background-position: center; + position: relative; + cursor: pointer; +} + +.screenshot-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + opacity: 0; + transition: opacity 0.3s ease; +} + +.screenshot-image:hover .screenshot-overlay { + opacity: 1; +} + +.screenshot-overlay .material-icons { + color: white; + font-size: 48px; + pointer-events: none; +} + +.screenshot-timestamp { + padding: 16px; + margin: 0; +} + +#screenshot-dialog-image { + max-width: 100%; + height: auto; +} + +.mdc-dialog .mdc-dialog__surface { + background-color: #1e1e1e; + max-width: 90vw; + max-height: 90vh; + transition: opacity 0.3s ease-out, transform 0.3s ease-out; + opacity: 0; + transform: scale(0.8); +} + +.mdc-dialog--opening .mdc-dialog__surface { + opacity: 1; + transform: scale(1); +} + +.mdc-dialog--closing .mdc-dialog__surface { + opacity: 0; + transform: scale(0.8); +} + +.mdc-dialog .mdc-dialog__title { + color: rgba(255, 255, 255, 0.87); +} + +.mdc-top-app-bar { + background-color: #1e1e1e; +} + +.mdc-text-field--outlined:not(.mdc-text-field--disabled) .mdc-notched-outline__leading, +.mdc-text-field--outlined:not(.mdc-text-field--disabled) .mdc-notched-outline__notch, +.mdc-text-field--outlined:not(.mdc-text-field--disabled) .mdc-notched-outline__trailing { + border-color: rgba(255, 255, 255, 0.38); +} + +.mdc-text-field--outlined:not(.mdc-text-field--disabled):not(.mdc-text-field--focused):hover .mdc-notched-outline .mdc-notched-outline__leading, +.mdc-text-field--outlined:not(.mdc-text-field--disabled):not(.mdc-text-field--focused):hover .mdc-notched-outline .mdc-notched-outline__notch, +.mdc-text-field--outlined:not(.mdc-text-field--disabled):not(.mdc-text-field--focused):hover .mdc-notched-outline .mdc-notched-outline__trailing { + border-color: rgba(255, 255, 255, 0.87); +} + +.mdc-text-field:not(.mdc-text-field--disabled) .mdc-floating-label { + color: rgba(255, 255, 255, 0.6); +} + +.mdc-text-field--focused:not(.mdc-text-field--disabled) .mdc-floating-label { + color: #bb86fc; +} + +.mdc-text-field:not(.mdc-text-field--disabled) .mdc-text-field__input { + color: rgba(255, 255, 255, 0.87); +} + +.mdc-text-field--outlined:not(.mdc-text-field--disabled).mdc-text-field--focused .mdc-notched-outline__leading, +.mdc-text-field--outlined:not(.mdc-text-field--disabled).mdc-text-field--focused .mdc-notched-outline__notch, +.mdc-text-field--outlined:not(.mdc-text-field--disabled).mdc-text-field--focused .mdc-notched-outline__trailing { + border-color: #bb86fc; +} + +.mdc-text-field .mdc-text-field__icon--trailing { + color: white !important; + pointer-events: auto; + cursor: pointer; +} + +.mdc-circular-progress__determinate-circle, +.mdc-circular-progress__indeterminate-circle-graphic { + stroke: #bb86fc; +} + +.mdc-dialog__content { + display: flex; + justify-content: center; + align-items: center; +} + +#config-button { + position: fixed; + bottom: 16px; + right: 16px; +} + +#config-dialog .mdc-dialog__content { + display: flex; + flex-direction: column; + gap: 16px; +} + +.mdc-text-field__icon--trailing { + color: white !important; + cursor: pointer; +} + +.section-divider { + margin: 24px 0; + text-align: center; + position: relative; +} + +.section-divider hr { + border: none; + border-top: 1px solid rgba(255, 255, 255, 0.12); + margin: 0; +} + +.section-divider h2 { + background-color: #121212; + display: inline-block; + padding: 0 16px; + position: relative; + top: -12px; + margin: 0; +} + +.status-not-analyzed::before, +.status-analyzing::before, +.status-analyzed::before { + content: ''; + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 8px; +} + +.status-not-analyzed::before { + background-color: #f44336; +} + +.status-analyzing::before { + background-color: #ffc107; +} + +.status-analyzed::before { + background-color: #4caf50; +} + +#delete-all-and-db-button { + background-color: #f44336; + color: white; +} + +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgba(0,0,0,0.9); +} + +.modal-content { + margin: auto; + display: block; + width: 80%; + max-width: 700px; +} + +.close { + position: absolute; + top: 15px; + right: 35px; + color: #f1f1f1; + font-size: 40px; + font-weight: bold; + transition: 0.3s; + cursor: pointer; +} + +#screenshot-modal-image { + max-width: 100%; + height: auto; +} + +#config-modal .modal-content { + display: flex; + flex-direction: column; + gap: 16px; +} + +#config-modal .mdc-button { + align-self: flex-start; +} + +.screenshot-timeline { + display: flex; + flex-wrap: nowrap; + overflow-x: auto; + padding: 20px 0; + position: relative; +} + +.timeline-item { + flex: 0 0 auto; + width: 300px; + margin-right: 20px; + position: relative; + padding-bottom: 20px; +} + +.mdc-card__media.screenshot-image { + height: 0; + padding-top: 56.25%; /* 16:9 aspect ratio */ + background-size: cover; + background-position: center; + position: relative; +} + +.mdc-card__media.screenshot-image::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(to bottom, rgba(0,0,0,0) 70%, rgba(0,0,0,0.7) 100%); +} + +.mdc-card__content { + padding: 16px; +} + +.screenshot-timestamp { + margin: 0; + font-size: 0.9rem; + color: rgba(255, 255, 255, 0.87); +} + +.screenshot-status { + margin: 8px 0; + font-size: 0.8rem; + color: rgba(255, 255, 255, 0.6); +} + +.status-analyzed::before { + background-color: #4caf50; +} + +.status-not-analyzed::before { + background-color: #f44336; +} + +/* Scrollbar styling */ +.screenshot-timeline::-webkit-scrollbar { + height: 8px; +} + +.screenshot-timeline::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 4px; +} + +.screenshot-timeline::-webkit-scrollbar-thumb { + background: #888; + border-radius: 4px; +} + +.screenshot-timeline::-webkit-scrollbar-thumb:hover { + background: #555; +} + +.tag { + display: inline-block; + background-color: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.87); + padding: 2px 8px; + border-radius: 12px; + font-size: 0.75rem; +} + +.screenshot-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 8px; +} + +.search-and-filter { + display: flex; + align-items: stretch; + margin-bottom: 20px; +} + +.search-and-filter .mdc-layout-grid__cell { + display: flex; +} + +#search-field { + width: 100%; +} + +#tag-filter-select { + width: 100%; +} + +/* Ensure the height of the select matches the search field */ +.mdc-select--outlined { + height: 56px; +} + +.mdc-select__anchor { + height: 56px !important; +} + +/* Style for dark theme */ +.mdc-select:not(.mdc-select--disabled) .mdc-select__selected-text { + color: rgba(255, 255, 255, 0.87); +} + +.mdc-select:not(.mdc-select--disabled) .mdc-floating-label { + color: rgba(255, 255, 255, 0.6); +} + +.mdc-select:not(.mdc-select--disabled).mdc-select--focused .mdc-floating-label { + color: #bb86fc; +} + +.mdc-select:not(.mdc-select--disabled) .mdc-select__dropdown-icon { + fill: rgba(255, 255, 255, 0.5); +} + +.mdc-select:not(.mdc-select--disabled).mdc-select--focused .mdc-select__dropdown-icon { + fill: #bb86fc; +} + +.mdc-select:not(.mdc-select--disabled) .mdc-select__anchor { + background-color: transparent; +} + +.mdc-select:not(.mdc-select--disabled) .mdc-notched-outline__leading, +.mdc-select:not(.mdc-select--disabled) .mdc-notched-outline__notch, +.mdc-select:not(.mdc-select--disabled) .mdc-notched-outline__trailing { + border-color: rgba(255, 255, 255, 0.38); +} + +.mdc-select:not(.mdc-select--disabled).mdc-select--focused .mdc-notched-outline .mdc-notched-outline__leading, +.mdc-select:not(.mdc-select--disabled).mdc-select--focused .mdc-notched-outline .mdc-notched-outline__notch, +.mdc-select:not(.mdc-select--disabled).mdc-select--focused .mdc-notched-outline .mdc-notched-outline__trailing { + border-color: #bb86fc; +} + +.mdc-select__menu .mdc-list { + color: rgba(255, 255, 255, 0.87); + background-color: #1e1e1e; +} + +.mdc-select__menu .mdc-list-item--selected { + color: #bb86fc; +} + +.mdc-select__menu .mdc-list-item--selected::before, +.mdc-select__menu .mdc-list-item--selected::after { + background-color: #bb86fc; +} + +/* Adjust the layout grid to take full width */ +.mdc-layout-grid { + padding: 0; + width: 100%; +} + +/* Adjust the top app bar for the tag filter */ +.mdc-top-app-bar__section--align-end { + padding-right: 20px; +} + +#tag-filter-select { + width: 200px; + margin-left: 16px; +} + +/* Ensure the height of the select matches the app bar */ +.mdc-select--outlined { + height: 48px; +} + +.mdc-select__anchor { + height: 48px !important; +} + +/* Style for the search field */ +#search-field { + width: 100%; + margin-bottom: 20px; +} + +/* Modal styles */ +.modal { + display: none; + position: fixed; + z-index: 1000; + padding-top: 100px; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgba(0,0,0,0.9); +} + +.modal-content { + margin: auto; + display: block; + width: 80%; + max-width: 700px; +} + +.close { + position: absolute; + top: 15px; + right: 35px; + color: #f1f1f1; + font-size: 40px; + font-weight: bold; + transition: 0.3s; + cursor: pointer; +} + +#screenshot-modal-image { + max-width: 100%; + height: auto; +} + +#config-modal .modal-content { + display: flex; + flex-direction: column; + gap: 16px; +} + +#config-modal .mdc-button { + align-self: flex-start; +} + +#tags-input { + width: 100%; + padding: 10px; + margin: 10px 0; + background-color: #2e2e2e; + color: white; + border: 1px solid #444; + border-radius: 4px; +} + +#save-tags-button { + margin-top: 10px; +} + +.edit-tags-button { + margin-top: 10px; +} + +.tag-filter-container { + display: flex; + align-items: center; + margin-bottom: 20px; +} + +.tag-filter-label { + margin-right: 10px; + color: rgba(255, 255, 255, 0.87); + font-size: 1rem; + white-space: nowrap; +} + +#tag-filter-select { + width: 200px; +} + +.mdc-select__selected-text { + color: rgba(255, 255, 255, 0.87); +} \ No newline at end of file diff --git a/static/js/script.js b/static/js/script.js new file mode 100644 index 0000000..9f7eac1 --- /dev/null +++ b/static/js/script.js @@ -0,0 +1,449 @@ +document.addEventListener("DOMContentLoaded", function () { + // Initialize Material components + mdc.autoInit(); + + // Initialize text field + const textField = new mdc.textField.MDCTextField( + document.querySelector(".mdc-text-field") + ); + + // Initialize circular progress + const circularProgress = new mdc.circularProgress.MDCCircularProgress( + document.querySelector(".mdc-circular-progress") + ); + + function formatTimestamp(timestamp) { + if (!timestamp || typeof timestamp !== 'string' || timestamp.length < 15) { + return 'Invalid Date'; + } + try { + const date = new Date( + timestamp.slice(0, 4), + timestamp.slice(4, 6) - 1, + timestamp.slice(6, 8), + timestamp.slice(9, 11), + timestamp.slice(11, 13), + timestamp.slice(13, 15) + ); + return date.toLocaleString(); + } catch (error) { + console.error('Error formatting timestamp:', error); + return 'Invalid Date'; + } + } + + // Update timestamp display + function updateTimestamps() { + document.querySelectorAll('.screenshot-timestamp').forEach(function(element) { + const timestamp = element.dataset.timestamp; + if (timestamp) { + element.textContent = formatTimestamp(timestamp); + } + }); + } + + // Call updateTimestamps when the DOM is loaded + updateTimestamps(); + + // Search functionality + const searchField = new mdc.textField.MDCTextField(document.getElementById('search-field')); + const tagFilterSelect = new mdc.select.MDCSelect(document.getElementById('tag-filter-select')); + + const searchButton = document.querySelector(".mdc-text-field__icon--trailing"); + const searchInput = document.getElementById("search-input"); + + if (searchButton && searchInput) { + searchButton.addEventListener("click", performSearch); + searchInput.addEventListener("keypress", function (e) { + if (e.key === "Enter") { + e.preventDefault(); + performSearch(); + } + }); + } else { + console.warn('Search elements not found'); + } + + function performSearch() { + const query = document.getElementById('search-input').value; + circularProgress.determinate = false; + circularProgress.open(); + + $.post('/search', {query: query}, function(data) { + let resultsHtml = '
'; + data.forEach(function(screenshot) { + resultsHtml += ` +
+
+
+
+

${formatTimestamp(screenshot.timestamp)}

+

${getStatusText(screenshot.ocr_status)}

+
+ ${renderTags(screenshot.tags)} +
+ +
+
+
+ `; + }); + resultsHtml += '
'; + document.getElementById('search-results').innerHTML = resultsHtml; + + circularProgress.close(); + }); + } + + function getStatusClass(status) { + switch (status) { + case 'Not yet analyzed': + return 'status-not-analyzed'; + case 'Analyzing': + return 'status-analyzing'; + case 'Analyzed': + return 'status-analyzed'; + default: + return ''; + } + } + + function getStatusText(status) { + switch (status) { + case 'Not yet analyzed': + return 'Not yet analyzed'; + case 'Analyzing': + return 'Analyzing'; + case 'Analyzed': + return 'Analyzed'; + default: + return ''; + } + } + + function renderTags(tags) { + return tags.map(tag => `${tag}`).join(''); + } + + // Modal functionality + function openModal(modalId) { + const modal = document.getElementById(modalId); + modal.style.display = "block"; + // Trigger reflow + modal.offsetHeight; + modal.classList.add('show'); + } + + function closeModal(modalId) { + const modal = document.getElementById(modalId); + modal.classList.remove('show'); + setTimeout(() => { + modal.style.display = "none"; + }, 300); // Wait for the animation to finish + } + + // Close modal when clicking outside + window.onclick = function(event) { + if (event.target.classList.contains('modal')) { + closeModal(event.target.id); + } + } + + // Close buttons + document.querySelectorAll('.close').forEach(function(closeBtn) { + closeBtn.onclick = function() { + closeModal(this.closest('.modal').id); + } + }); + + // Screenshot modal + function openScreenshotModal(filename) { + const modal = document.getElementById('screenshot-modal'); + const modalImg = document.getElementById('screenshot-modal-image'); + modalImg.src = `/static/screenshots/${filename}`; + modal.style.display = "block"; + } + + document.addEventListener('click', function(e) { + const screenshotImage = e.target.closest('.screenshot-image'); + if (screenshotImage) { + const screenshotCard = screenshotImage.closest('.screenshot-card'); + const filename = screenshotCard.dataset.screenshot; + openScreenshotModal(filename); + } + }); + + const modal = document.getElementById('screenshot-modal'); + modal.onclick = function(event) { + if (event.target == modal) { + modal.style.display = "none"; + } + } + + // Config modal + const configButton = document.getElementById('config-button'); + if (configButton) { + configButton.addEventListener('click', function(e) { + e.preventDefault(); + openModal('config-modal'); + }); + } else { + console.error('Config button not found'); + } + + // Config modal button handlers + document.getElementById("ocr-all-button")?.addEventListener("click", function(e) { + e.preventDefault(); + $.post("/ocr-all", function(data) { + alert(data.message); + }); + }); + + document.getElementById("delete-all-button")?.addEventListener("click", function(e) { + e.preventDefault(); + if (confirm("Are you sure you want to delete all screenshots?")) { + $.post("/delete-all", function(data) { + alert(data.message); + location.reload(); + }); + } + }); + + document.getElementById("delete-all-and-db-button")?.addEventListener("click", function(e) { + e.preventDefault(); + if (confirm("Are you sure you want to delete all screenshots and reset the database? This action cannot be undone.")) { + $.post("/delete-all-and-reset-db", function(data) { + alert(data.message); + location.reload(); + }); + } + }); + + document.getElementById("save-interval-button")?.addEventListener("click", function(e) { + e.preventDefault(); + const interval = document.getElementById("interval-input").value; + $.post("/set-interval", { interval: interval }, function(data) { + alert(data.message); + }); + }); + + // Status updates using Server-Sent Events + const eventSource = new EventSource('/status-updates'); + eventSource.onmessage = function(event) { + try { + const data = JSON.parse(event.data); + if (data.is_new) { + // Add new screenshot to the timeline + addNewScreenshot(data); + } else { + // Update existing screenshot status + updateScreenshotStatus(data); + } + } catch (error) { + console.error('Error parsing SSE data:', error); + console.log('Raw event data:', event.data); + } + }; + + function addNewScreenshot(data) { + const screenshotTimeline = document.querySelector('.screenshot-timeline'); + if (!screenshotTimeline) { + console.warn('Screenshot timeline not found. New screenshot not added:', data); + return; + } + + // Check if the screenshot already exists + if (document.querySelector(`.screenshot-card[data-screenshot="${data.filename}"]`)) { + console.warn('Screenshot already exists:', data.filename); + return; + } + + const tagsHtml = renderTags(data.tags); + const newScreenshotHtml = ` +
+
+
+
+ zoom_in +
+
+
+

${formatTimestamp(data.timestamp)}

+

${data.status}

+
+ ${tagsHtml} +
+ +
+
+
+ `; + screenshotTimeline.insertAdjacentHTML('afterbegin', newScreenshotHtml); + } + + function updateScreenshotStatus(data) { + const card = document.querySelector(`.screenshot-card[data-screenshot="${data.filename}"]`); + if (card) { + const statusElement = card.querySelector('.screenshot-status'); + statusElement.textContent = data.status; + statusElement.className = `mdc-typography--body2 screenshot-status ${getStatusClass(data.status)}`; + + const tagsElement = card.querySelector('.screenshot-tags'); + tagsElement.innerHTML = renderTags(data.tags); + } + } + + function filterByTag(tag) { + fetch(`/filter-by-tag/${encodeURIComponent(tag)}`) + .then(response => response.json()) + .then(screenshots => { + const screenshotTimeline = document.querySelector('.screenshot-timeline'); + screenshotTimeline.innerHTML = ''; + screenshots.forEach(screenshot => { + addNewScreenshot({ + filename: screenshot.filename, + timestamp: screenshot.timestamp, + status: screenshot.ocr_text ? 'Analyzed' : 'Not yet analyzed', + tags: screenshot.tags + }); + }); + }); + } + + // Populate tag filter + function populateTagFilter() { + fetch('/get-all-tags') + .then(response => response.json()) + .then(tags => { + const tagFilterList = document.getElementById('tag-filter-list'); + const uniqueTags = [...new Set(tags)]; // Remove duplicates + + // Clear existing tags (except the "All" option) + while (tagFilterList.children.length > 1) { + tagFilterList.removeChild(tagFilterList.lastChild); + } + + uniqueTags.forEach(tag => { + const listItem = document.createElement('li'); + listItem.className = 'mdc-list-item'; + listItem.setAttribute('data-value', tag); + listItem.innerHTML = ` + + ${tag} + `; + tagFilterList.appendChild(listItem); + }); + + // Reinitialize MDC Select to reflect the new options + const tagFilterSelect = new mdc.select.MDCSelect(document.getElementById('tag-filter-select')); + tagFilterSelect.layout(); + + // Add event listener for tag selection + tagFilterSelect.listen('MDCSelect:change', () => { + const selectedTag = tagFilterSelect.value; + filterScreenshotsByTag(selectedTag); + }); + }) + .catch(error => { + console.error('Error fetching tags:', error); + }); + } + + function filterScreenshotsByTag(tag) { + const screenshots = document.querySelectorAll('.screenshot-card'); + screenshots.forEach(screenshot => { + const tags = Array.from(screenshot.querySelectorAll('.tag')).map(tagElement => tagElement.textContent); + if (tag === '' || tags.includes(tag)) { + screenshot.style.display = ''; + } else { + screenshot.style.display = 'none'; + } + }); + } + + // Call the function to populate tag filter + populateTagFilter(); + + // Initialize MDC components + const topAppBar = new mdc.topAppBar.MDCTopAppBar(document.querySelector('.mdc-top-app-bar')); + + // Function to open the edit tags modal + function openEditTagsModal(filename) { + fetch(`/get_screenshot_info/${filename}`) + .then(response => { + if (!response.ok) { + throw new Error('Screenshot info not found'); + } + return response.json(); + }) + .then(data => { + const modal = document.getElementById('edit-tags-modal'); + const tagsInput = document.getElementById('tags-input'); + const saveButton = document.getElementById('save-tags-button'); + + tagsInput.value = Array.isArray(data.tags) ? data.tags.join(', ') : ''; + modal.setAttribute('data-filename', filename); + + modal.style.display = 'block'; + tagsInput.focus(); + + saveButton.onclick = function() { + const newTags = tagsInput.value.split(',').map(tag => tag.trim()).filter(tag => tag); + updateTags(filename, newTags); + modal.style.display = 'none'; + }; + }) + .catch(error => { + console.error('Error fetching screenshot info:', error); + alert('Error fetching screenshot info. Please try again.'); + }); + } + + // Function to update tags + function updateTags(filename, newTags) { + fetch('/update_tags', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({filename: filename, tags: newTags}), + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to update tags'); + } + return response.json(); + }) + .then(data => { + if (data.success) { + const screenshotCard = document.querySelector(`.screenshot-card[data-screenshot="${filename}"]`); + const tagsContainer = screenshotCard.querySelector('.screenshot-tags'); + tagsContainer.innerHTML = newTags.map(tag => `${tag}`).join(''); + } else { + throw new Error(data.message || 'Failed to update tags'); + } + }) + .catch(error => { + console.error('Error updating tags:', error); + alert('Failed to update tags. Please try again.'); + }); + } + + // Add click event listener to screenshot cards + document.addEventListener('click', function(e) { + const editTagsButton = e.target.closest('.edit-tags-button'); + if (editTagsButton) { + const screenshotCard = editTagsButton.closest('.screenshot-card'); + const filename = screenshotCard.dataset.screenshot; + openEditTagsModal(filename); + } + }); + + // Close modal when clicking outside + window.onclick = function(event) { + const modal = document.getElementById('edit-tags-modal'); + if (event.target == modal) { + modal.style.display = "none"; + } + } +}); \ No newline at end of file diff --git a/static/screenshots/screenshots_will_be_added_here b/static/screenshots/screenshots_will_be_added_here new file mode 100644 index 0000000..e69de29 diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..dfdd2cc --- /dev/null +++ b/templates/index.html @@ -0,0 +1,196 @@ + + + + + + Screenshot Timeline + + + + + + + + +
+
+
+ Screenshot Timeline +
+
+
+ +
+
+ + + + + + + + + + + + +
+
+
    +
  • + + All +
  • + +
+
+
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+ +
+
+
+ search +
+
+
+ + + +
+ +
+
+

All Screenshots

+
+ +
+ {% for screenshot in screenshots %} +
+
+
+
+ zoom_in +
+
+
+

{{ screenshot.formatted_timestamp }}

+

+ {{ 'Analyzed' if screenshot.ocr_status else 'Not yet analyzed' }} +

+
+ {% for tag in screenshot.tags %} + {{ tag }} + {% endfor %} +
+
+
+ +
+
+
+ {% endfor %} +
+ + + + + + + + + + +
+
+
+ + + + + + + \ No newline at end of file