first commit

This commit is contained in:
tcsenpai 2024-08-22 18:02:18 +02:00
commit 412af2c8ec
9 changed files with 1880 additions and 0 deletions

19
.gitignore vendored Normal file
View File

@ -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

112
README.md Normal file
View File

@ -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)

413
main.py Normal file
View File

@ -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/<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/<filename>')
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()

6
requirements.txt Normal file
View File

@ -0,0 +1,6 @@
flask
pillow
pytesseract
nltk
celery
redis

139
start_services.sh Executable file
View File

@ -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 - <<EOF
import nltk
import ssl
try:
_create_unverified_https_context = ssl._create_unverified_context
except AttributeError:
pass
else:
ssl._create_default_https_context = _create_unverified_https_context
nltk.download('punkt', quiet=True)
nltk.download('stopwords', quiet=True)
print("NLTK data check complete.")
EOF
}
# Parse command line options
while getopts ":ch" opt; do
case ${opt} in
c )
clean_data
;;
h )
show_help
exit 0
;;
\? )
echo "Invalid option: $OPTARG" 1>&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

546
static/css/styles.css Normal file
View File

@ -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);
}

449
static/js/script.js Normal file
View File

@ -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 = '<div class="mdc-layout-grid__inner">';
data.forEach(function(screenshot) {
resultsHtml += `
<div class="mdc-layout-grid__cell mdc-layout-grid__cell--span-3">
<div class="mdc-card screenshot-card" data-screenshot="${screenshot.filename}">
<div class="mdc-card__media mdc-card__media--16-9 screenshot-image" style="background-image: url('/static/screenshots/${screenshot.filename}');"></div>
<div class="mdc-card__content">
<h2 class="mdc-typography--headline6 screenshot-timestamp" data-timestamp="${screenshot.timestamp}">${formatTimestamp(screenshot.timestamp)}</h2>
<p class="mdc-typography--body2 screenshot-status ${getStatusClass(screenshot.ocr_status)}">${getStatusText(screenshot.ocr_status)}</p>
<div class="screenshot-tags">
${renderTags(screenshot.tags)}
</div>
<button class="mdc-button edit-tags-button">Edit Tags</button>
</div>
</div>
</div>
`;
});
resultsHtml += '</div>';
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 => `<span class="tag">${tag}</span>`).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 = `
<div class="timeline-item">
<div class="mdc-card screenshot-card" data-screenshot="${data.filename}" data-timestamp="${data.timestamp}">
<div class="mdc-card__media mdc-card__media--16-9 screenshot-image" style="background-image: url('/static/screenshots/${data.filename}');">
<div class="screenshot-overlay">
<span class="material-icons">zoom_in</span>
</div>
</div>
<div class="mdc-card__content">
<h2 class="mdc-typography--headline6 screenshot-timestamp">${formatTimestamp(data.timestamp)}</h2>
<p class="mdc-typography--body2 screenshot-status ${getStatusClass(data.status)}">${data.status}</p>
<div class="screenshot-tags">
${tagsHtml}
</div>
<button class="mdc-button edit-tags-button">Edit Tags</button>
</div>
</div>
</div>
`;
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 = `
<span class="mdc-list-item__ripple"></span>
<span class="mdc-list-item__text">${tag}</span>
`;
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 => `<span class="tag">${tag}</span>`).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";
}
}
});

196
templates/index.html Normal file
View File

@ -0,0 +1,196 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Screenshot Timeline</title>
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500" rel="stylesheet">
<link href="https://unpkg.com/material-components-web@latest/dist/material-components-web.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
<script src="https://unpkg.com/material-components-web@latest/dist/material-components-web.min.js"></script>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
</head>
<body class="mdc-typography">
<header class="mdc-top-app-bar">
<div class="mdc-top-app-bar__row">
<section class="mdc-top-app-bar__section mdc-top-app-bar__section--align-start">
<span class="mdc-top-app-bar__title">Screenshot Timeline</span>
</section>
<section class="mdc-top-app-bar__section mdc-top-app-bar__section--align-end">
<div class="tag-filter-container">
<label for="tag-filter-select" class="tag-filter-label">Filter tags:</label>
<div class="mdc-select mdc-select--outlined" id="tag-filter-select">
<div class="mdc-select__anchor">
<span class="mdc-select__ripple"></span>
<span class="mdc-select__selected-text"></span>
<span class="mdc-select__dropdown-icon">
<svg class="mdc-select__dropdown-icon-graphic" viewBox="7 10 10 5">
<polygon class="mdc-select__dropdown-icon-inactive" stroke="none" fill-rule="evenodd" points="7 10 12 15 17 10"></polygon>
<polygon class="mdc-select__dropdown-icon-active" stroke="none" fill-rule="evenodd" points="7 15 12 10 17 15"></polygon>
</svg>
</span>
<span class="mdc-notched-outline">
<span class="mdc-notched-outline__leading"></span>
<span class="mdc-notched-outline__trailing"></span>
</span>
</div>
<div class="mdc-select__menu mdc-menu mdc-menu-surface mdc-menu-surface--fullwidth">
<ul class="mdc-list" id="tag-filter-list">
<li class="mdc-list-item mdc-list-item--selected" data-value="" aria-selected="true">
<span class="mdc-list-item__ripple"></span>
<span class="mdc-list-item__text">All</span>
</li>
<!-- Tags will be dynamically added here -->
</ul>
</div>
</div>
</div>
</section>
</div>
</header>
<main class="mdc-top-app-bar--fixed-adjust">
<div class="content">
<div class="mdc-layout-grid">
<div class="mdc-layout-grid__inner">
<div class="mdc-layout-grid__cell mdc-layout-grid__cell--span-12">
<div class="mdc-text-field mdc-text-field--outlined mdc-text-field--with-trailing-icon" id="search-field">
<input type="text" id="search-input" class="mdc-text-field__input">
<div class="mdc-notched-outline">
<div class="mdc-notched-outline__leading"></div>
<div class="mdc-notched-outline__notch">
<label for="search-input" class="mdc-floating-label">Search</label>
</div>
<div class="mdc-notched-outline__trailing"></div>
</div>
<i class="material-icons mdc-text-field__icon mdc-text-field__icon--trailing" tabindex="0" role="button">search</i>
</div>
</div>
</div>
<div id="spinner" class="mdc-circular-progress" style="display:none;" role="progressbar" aria-label="Example Progress Bar" aria-valuemin="0" aria-valuemax="1">
<div class="mdc-circular-progress__determinate-container">
<svg class="mdc-circular-progress__determinate-circle-graphic" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<circle class="mdc-circular-progress__determinate-track" cx="24" cy="24" r="18" stroke-width="4"/>
<circle class="mdc-circular-progress__determinate-circle" cx="24" cy="24" r="18" stroke-dasharray="113.097" stroke-dashoffset="113.097" stroke-width="4"/>
</svg>
</div>
<div class="mdc-circular-progress__indeterminate-container">
<div class="mdc-circular-progress__spinner-layer">
<div class="mdc-circular-progress__circle-clipper mdc-circular-progress__circle-left">
<svg class="mdc-circular-progress__indeterminate-circle-graphic" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<circle cx="24" cy="24" r="18" stroke-dasharray="113.097" stroke-dashoffset="56.549" stroke-width="4"/>
</svg>
</div>
<div class="mdc-circular-progress__gap-patch">
<svg class="mdc-circular-progress__indeterminate-circle-graphic" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<circle cx="24" cy="24" r="18" stroke-dasharray="113.097" stroke-dashoffset="56.549" stroke-width="3.2"/>
</svg>
</div>
<div class="mdc-circular-progress__circle-clipper mdc-circular-progress__circle-right">
<svg class="mdc-circular-progress__indeterminate-circle-graphic" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<circle cx="24" cy="24" r="18" stroke-dasharray="113.097" stroke-dashoffset="56.549" stroke-width="4"/>
</svg>
</div>
</div>
</div>
</div>
<div id="search-results" class="search-results mdc-layout-grid"></div>
<div class="section-divider">
<hr class="mdc-list-divider">
<h2 class="mdc-typography--headline6">All Screenshots</h2>
</div>
<div class="screenshot-timeline">
{% for screenshot in screenshots %}
<div class="timeline-item">
<div class="mdc-card screenshot-card" data-screenshot="{{ screenshot.filename }}">
<div class="mdc-card__media mdc-card__media--16-9 screenshot-image" style="background-image: url('{{ url_for('static', filename='screenshots/' + screenshot.filename) }}');">
<div class="screenshot-overlay">
<span class="material-icons">zoom_in</span>
</div>
</div>
<div class="mdc-card__content">
<h2 class="mdc-typography--headline6 screenshot-timestamp" data-timestamp="{{ screenshot.timestamp }}">{{ screenshot.formatted_timestamp }}</h2>
<p class="mdc-typography--body2 screenshot-status {{ 'status-analyzed' if screenshot.ocr_status else 'status-not-analyzed' }}">
{{ 'Analyzed' if screenshot.ocr_status else 'Not yet analyzed' }}
</p>
<div class="screenshot-tags">
{% for tag in screenshot.tags %}
<span class="tag">{{ tag }}</span>
{% endfor %}
</div>
</div>
<div class="mdc-card__actions">
<button class="mdc-button mdc-card__action mdc-card__action--button edit-tags-button">
<span class="mdc-button__label">Edit Tags</span>
</button>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Screenshot Modal -->
<div id="screenshot-modal" class="modal">
<span class="close">&times;</span>
<img class="modal-content" id="screenshot-modal-image">
</div>
<!-- Config Modal -->
<div id="config-modal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<h2>Configuration</h2>
<button id="ocr-all-button" class="mdc-button mdc-button--raised">
<span class="mdc-button__label">OCR All Images</span>
</button>
<button id="delete-all-button" class="mdc-button mdc-button--raised">
<span class="mdc-button__label">Delete All Screenshots</span>
</button>
<button id="delete-all-and-db-button" class="mdc-button mdc-button--raised">
<span class="mdc-button__label">Delete All Screenshots and Reset Database</span>
</button>
<div class="mdc-text-field mdc-text-field--outlined">
<input type="number" id="interval-input" class="mdc-text-field__input" value="300">
<div class="mdc-notched-outline">
<div class="mdc-notched-outline__leading"></div>
<div class="mdc-notched-outline__notch">
<label for="interval-input" class="mdc-floating-label">Screenshot Interval (seconds)</label>
</div>
<div class="mdc-notched-outline__trailing"></div>
</div>
</div>
<button id="save-interval-button" class="mdc-button mdc-button--raised">
<span class="mdc-button__label">Save Interval</span>
</button>
</div>
</div>
<button id="config-button" class="mdc-fab" aria-label="Config">
<div class="mdc-fab__ripple"></div>
<span class="mdc-fab__icon material-icons">settings</span>
</button>
<script src="{{ url_for('static', filename='js/script.js') }}"></script>
</div>
</div>
</main>
<script src="{{ url_for('static', filename='js/script.js') }}"></script>
<!-- Add this modal HTML at the end of your body tag -->
<div id="edit-tags-modal" class="modal">
<div class="modal-content">
<h2>Edit Tags</h2>
<input type="text" id="tags-input" placeholder="Enter tags, separated by commas">
<button id="save-tags-button" class="mdc-button mdc-button--raised">
<span class="mdc-button__label">Save Tags</span>
</button>
</div>
</div>
</body>
</html>