mirror of
https://github.com/tcsenpai/screenshot-timeline.git
synced 2025-06-03 01:40:12 +00:00
first commit
This commit is contained in:
commit
412af2c8ec
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal 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
112
README.md
Normal 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
413
main.py
Normal 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
6
requirements.txt
Normal file
@ -0,0 +1,6 @@
|
||||
flask
|
||||
pillow
|
||||
pytesseract
|
||||
nltk
|
||||
celery
|
||||
redis
|
139
start_services.sh
Executable file
139
start_services.sh
Executable 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
546
static/css/styles.css
Normal 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
449
static/js/script.js
Normal 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";
|
||||
}
|
||||
}
|
||||
});
|
0
static/screenshots/screenshots_will_be_added_here
Normal file
0
static/screenshots/screenshots_will_be_added_here
Normal file
196
templates/index.html
Normal file
196
templates/index.html
Normal 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">×</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">×</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>
|
Loading…
x
Reference in New Issue
Block a user