diff --git a/app/__init__.py b/app/__init__.py deleted file mode 100644 index e188c78..0000000 --- a/app/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# This file can be empty, it just marks the directory as a Python package \ No newline at end of file diff --git a/app/browser.py b/app/browser.py deleted file mode 100644 index 3f7ca3b..0000000 --- a/app/browser.py +++ /dev/null @@ -1,18 +0,0 @@ -from datetime import datetime -from typing import List, Tuple -from browser_history import get_history, get_bookmarks -from urllib.parse import urlparse - -class BrowserHistoryCollector: - @staticmethod - def get_domain(url: str) -> str: - return urlparse(url).netloc - - def fetch_history(self) -> List[Tuple[datetime, str, str]]: - outputs = get_history() - # Returns list of tuples containing (datetime, url, title) - return [(entry[0], entry[1], entry[2]) for entry in outputs.histories] - - def fetch_bookmarks(self) -> List[Tuple[datetime, str, str, str]]: - outputs = get_bookmarks() - return outputs.bookmarks \ No newline at end of file diff --git a/app/config.py b/app/config.py deleted file mode 100644 index a51836b..0000000 --- a/app/config.py +++ /dev/null @@ -1,251 +0,0 @@ -import yaml -from pathlib import Path -from typing import Set -import fnmatch -import os -import logging - -logger = logging.getLogger(__name__) - -class Config: - def __init__(self): - self.config_path = Path(__file__).parent / "config.yaml" - self.load_config() - - def load_config(self): - if not self.config_path.exists(): - self.config = {"ignored_domains": []} - self.save_config() - else: - with open(self.config_path, 'r') as f: - self.config = yaml.safe_load(f) - - def save_config(self): - with open(self.config_path, 'w') as f: - yaml.dump(self.config, f) - - def is_domain_ignored(self, domain: str) -> bool: - """Check if a domain matches any of the ignored patterns""" - patterns = self.config.get('ignored_domains', []) - return any(fnmatch.fnmatch(domain.lower(), pattern.lower()) for pattern in patterns) - - def add_ignored_domain(self, pattern: str): - """Add a new domain pattern to the ignored list""" - if 'ignored_domains' not in self.config: - self.config['ignored_domains'] = [] - if pattern not in self.config['ignored_domains']: - self.config['ignored_domains'].append(pattern) - self.save_config() - - def remove_ignored_domain(self, pattern: str): - """Remove a domain pattern from the ignored list""" - if 'ignored_domains' in self.config: - self.config['ignored_domains'] = [ - p for p in self.config['ignored_domains'] if p != pattern - ] - self.save_config() - -class ReaderConfig: - def __init__(self): - self.excluded_patterns: Set[str] = set() - self._load_config() - - def _load_config(self): - config_path = Path("config/reader_config.yaml") - if not config_path.exists(): - print("Warning: reader_config.yaml not found, creating default config") - self._create_default_config(config_path) - - try: - with open(config_path, 'r') as f: - config = yaml.safe_load(f) - self.excluded_patterns = set(config.get('excluded_domains', [])) - except Exception as e: - print(f"Error loading config: {e}") - self.excluded_patterns = set() - - def _create_default_config(self, config_path: Path): - config_path.parent.mkdir(parents=True, exist_ok=True) - default_config = { - 'excluded_domains': [ - 'localhost', - '127.0.0.1', - '192.168.*.*', - '10.*.*.*' - ] - } - with open(config_path, 'w') as f: - yaml.safe_dump(default_config, f, default_flow_style=False) - - def is_domain_excluded(self, domain: str) -> bool: - """ - Check if a domain matches any exclusion pattern. - Supports glob-style wildcards (* and ?) - Examples: - - '*.example.com' matches any subdomain of example.com - - 'reddit-*.com' matches reddit-video.com, reddit-static.com, etc. - - '192.168.*.*' matches any IP in the 192.168.0.0/16 subnet - """ - domain = domain.lower() - - # Check each pattern - for pattern in self.excluded_patterns: - pattern = pattern.lower() - - # Handle IP address patterns specially - if any(c.isdigit() for c in pattern): - if self._match_ip_pattern(domain, pattern): - return True - - # Handle domain patterns - if fnmatch.fnmatch(domain, pattern): - return True - # Also check if the pattern matches when prepended with a dot - # This handles cases like 'example.com' matching 'subdomain.example.com' - if fnmatch.fnmatch(domain, f"*.{pattern}"): - return True - - return False - - def _match_ip_pattern(self, domain: str, pattern: str) -> bool: - """ - Special handling for IP address patterns. - Handles cases like '192.168.*.*' matching '192.168.1.1' - """ - # Skip if domain isn't IP-like - if not any(c.isdigit() for c in domain): - return False - - # Split into octets - domain_parts = domain.split('.') - pattern_parts = pattern.split('.') - - # Must have same number of parts - if len(domain_parts) != len(pattern_parts): - return False - - # Check each octet - for domain_part, pattern_part in zip(domain_parts, pattern_parts): - if pattern_part == '*': - continue - if domain_part != pattern_part: - return False - - return True - -DEFAULT_CONFIG_PATH = 'config/reader_config.yaml' -USER_CONFIG_DIR = os.path.expanduser("~/.config/browser-recall") -USER_CONFIG_PATH = os.path.join(USER_CONFIG_DIR, 'reader_config.yaml') - -class Config: - _instance = None - - def __new__(cls, *args, **kwargs): - if not cls._instance: - cls._instance = super(Config, cls).__new__(cls) - cls._instance._initialized = False - return cls._instance - - def __init__(self, config_path=None): - if self._initialized: - return - self._initialized = True - - self.config_path = self._determine_config_path(config_path) - self.config_data = self._load_config() - logger.info(f"Config initialized using: {self.config_path}") - # Pre-process excluded domains for faster lookup if needed, - # but direct iteration with fnmatch is often fine for moderate lists. - self.excluded_domains = self.config_data.get('excluded_domains', []) - # Ensure it's a list - if not isinstance(self.excluded_domains, list): - logger.warning(f"Excluded domains in config is not a list: {self.excluded_domains}. Ignoring.") - self.excluded_domains = [] - - - def _determine_config_path(self, provided_path): - """Determine the correct config path to use.""" - if provided_path and os.path.exists(provided_path): - return provided_path - if os.path.exists(USER_CONFIG_PATH): - return USER_CONFIG_PATH - if os.path.exists(DEFAULT_CONFIG_PATH): - return DEFAULT_CONFIG_PATH - logger.warning("No configuration file found at default or user locations. Using empty config.") - return None # Indicate no file was found - - def _load_config(self): - """Loads the YAML configuration file.""" - if not self.config_path: - return {} # Return empty dict if no config file path determined - - try: - with open(self.config_path, 'r') as f: - return yaml.safe_load(f) or {} # Return empty dict if file is empty - except FileNotFoundError: - logger.warning(f"Configuration file not found at {self.config_path}. Using default settings.") - return {} - except yaml.YAMLError as e: - logger.error(f"Error parsing configuration file {self.config_path}: {e}") - return {} # Return empty dict on parsing error - except Exception as e: - logger.error(f"Unexpected error loading configuration {self.config_path}: {e}") - return {} - - def get_config(self): - """Returns the loaded configuration data.""" - return self.config_data - - def reload_config(self): - """Reloads the configuration from the file.""" - logger.info(f"Reloading configuration from: {self.config_path}") - self.config_data = self._load_config() - self.excluded_domains = self.config_data.get('excluded_domains', []) - if not isinstance(self.excluded_domains, list): - logger.warning(f"Excluded domains in reloaded config is not a list: {self.excluded_domains}. Ignoring.") - self.excluded_domains = [] - logger.info("Configuration reloaded.") - - - def is_domain_ignored(self, domain: str) -> bool: - """ - Checks if a given domain matches any pattern in the excluded_domains list. - Supports exact matches and wildcard (*) matching using fnmatch. - """ - if not domain: # Ignore empty domains - return True - if not self.excluded_domains: # If list is empty, nothing is ignored - return False - - # Normalize domain to lowercase for case-insensitive comparison - domain_lower = domain.lower() - - for pattern in self.excluded_domains: - if not isinstance(pattern, str): # Skip non-string patterns - continue - - # Normalize pattern to lowercase - pattern_lower = pattern.lower() - - # Use fnmatch.fnmatch for wildcard support (*) - if fnmatch.fnmatch(domain_lower, pattern_lower): - # logger.debug(f"Domain '{domain}' ignored due to pattern '{pattern}'") - return True - return False - - # --- Add methods to get specific config values safely --- - @property - def history_update_interval_seconds(self) -> int: - """Gets the history update interval, defaulting to 300.""" - return self.config_data.get('history_update_interval_seconds', 300) - - @property - def markdown_update_interval_seconds(self) -> int: - """Gets the markdown update interval, defaulting to 300.""" - return self.config_data.get('markdown_update_interval_seconds', 300) - - # Add other specific getters as needed - # Example: - # @property - # def some_other_setting(self) -> str: - # return self.config_data.get('some_other_setting', 'default_value') \ No newline at end of file diff --git a/app/config.yaml b/app/config.yaml deleted file mode 100644 index f252f34..0000000 --- a/app/config.yaml +++ /dev/null @@ -1,13 +0,0 @@ -# Domains that should be ignored by the history tracker -# Supports wildcards (*) for pattern matching -ignored_domains: - - "192.168.*" # Ignore local network addresses - - "127.0.0.1" # Ignore localhost IP addresses - - "localhost" # Ignore localhost domains - - "172.*" - - "localhost:*" # Ignore all localhost ports - - "127.0.0.1:*" # Ignore all localhost IP ports - - "*.local" # Ignore .local domains - - "about:*" # Ignore about: URLs - - "chrome-extension://*" # Ignore Chrome extensions - - "chrome://*" # Ignore Chrome URLs diff --git a/app/database.py b/app/database.py deleted file mode 100644 index a77994a..0000000 --- a/app/database.py +++ /dev/null @@ -1,281 +0,0 @@ -from sqlalchemy import create_engine, Column, Integer, String, DateTime, Text, event, text -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import sessionmaker -from datetime import datetime -import sqlite3 - -SQLALCHEMY_DATABASE_URL = "sqlite:///./browser_history.db" - -# Create engine with custom configuration -engine = create_engine( - SQLALCHEMY_DATABASE_URL, - connect_args={ - "timeout": 30, # Connection timeout in seconds - "check_same_thread": False, # Allow multi-threaded access - }, - # Update pool configuration for better concurrency - pool_size=5, # Increase pool size to handle concurrent requests - max_overflow=10, # Allow some overflow connections - pool_timeout=30, # Connection timeout from pool - pool_recycle=3600, # Recycle connections every hour -) - -SessionLocal = sessionmaker( - autocommit=False, - autoflush=False, - bind=engine, - expire_on_commit=False # Prevent unnecessary reloads -) - -Base = declarative_base() - -@event.listens_for(engine, "connect") -def set_sqlite_pragma(dbapi_connection, connection_record): - """Configure SQLite for better performance""" - if isinstance(dbapi_connection, sqlite3.Connection): - cursor = dbapi_connection.cursor() - - # Enable WAL mode for better write performance and concurrency - cursor.execute("PRAGMA journal_mode=WAL") - - # Set page size to 4KB for better performance - cursor.execute("PRAGMA page_size=4096") - - # Set cache size to 32MB (-32000 pages * 4KB per page = ~32MB) - cursor.execute("PRAGMA cache_size=-32000") - - # Enable memory-mapped I/O for better performance - cursor.execute("PRAGMA mmap_size=268435456") # 256MB - - # Set synchronous mode to NORMAL for better write performance - cursor.execute("PRAGMA synchronous=NORMAL") - - # Enable foreign key support - cursor.execute("PRAGMA foreign_keys=ON") - - cursor.close() - -class HistoryEntry(Base): - __tablename__ = "history" - - id = Column(Integer, primary_key=True) - url = Column(String, index=True) # Add index for URL lookups - title = Column(String) - visit_time = Column(DateTime, index=True) # Add index for time-based queries - domain = Column(String, index=True) # Add index for domain filtering - markdown_content = Column(Text, nullable=True) - last_content_update = Column(DateTime, nullable=True) - - __table_args__ = ( - # Composite index for common query patterns - {'sqlite_with_rowid': True} # Ensure we have rowids for better performance - ) - -class Bookmark(Base): - __tablename__ = "bookmarks" - - id = Column(Integer, primary_key=True) - url = Column(String, index=True) - title = Column(String, nullable=True) - added_time = Column(DateTime, index=True) - folder = Column(String, index=True) - domain = Column(String, index=True) - - __table_args__ = ( - # Composite index for common query patterns - {'sqlite_with_rowid': True} # Ensure we have rowids for better performance - ) - -# Create tables -Base.metadata.create_all(bind=engine) - -# Initialize FTS tables for full-text search -def init_fts(): - """Initialize Full Text Search tables""" - conn = engine.raw_connection() - cursor = conn.cursor() - - # Create FTS table with content and title columns - cursor.execute(""" - CREATE VIRTUAL TABLE IF NOT EXISTS history_fts USING fts5( - title, - markdown_content, - domain, -- Add domain for filtering - visit_time UNINDEXED, -- Add visit_time but don't index it - content='history', - content_rowid='id', - tokenize='trigram' - ) - """) - - # Update triggers to include domain and visit_time - cursor.execute(""" - CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN - INSERT INTO history_fts(rowid, title, markdown_content, domain, visit_time) - VALUES (new.id, new.title, new.markdown_content, new.domain, new.visit_time); - END; - """) - - cursor.execute(""" - CREATE TRIGGER IF NOT EXISTS history_ad AFTER DELETE ON history BEGIN - INSERT INTO history_fts(history_fts, rowid, title, markdown_content, domain, visit_time) - VALUES('delete', old.id, old.title, old.markdown_content, old.domain, old.visit_time); - END; - """) - - cursor.execute(""" - CREATE TRIGGER IF NOT EXISTS history_au AFTER UPDATE ON history BEGIN - INSERT INTO history_fts(history_fts, rowid, title, markdown_content, domain, visit_time) - VALUES('delete', old.id, old.title, old.markdown_content, old.domain, old.visit_time); - INSERT INTO history_fts(rowid, title, markdown_content, domain, visit_time) - VALUES (new.id, new.title, new.markdown_content, new.domain, new.visit_time); - END; - """) - - conn.commit() - cursor.close() - conn.close() - -# Initialize FTS tables -init_fts() - -def reindex_fts(): - """Reindex the FTS tables""" - conn = engine.raw_connection() - cursor = conn.cursor() - cursor.execute("INSERT INTO history_fts(history_fts) VALUES('rebuild')") - conn.commit() - cursor.close() - conn.close() - -def get_db(): - """Get database session""" - db = SessionLocal() - try: - yield db - finally: - db.close() - -def get_last_processed_timestamp(source): - """ - Get last processed timestamp for a source (e.g., 'chrome_history', 'chrome_bookmarks') - """ - db = next(get_db()) - try: - result = db.execute( - text('SELECT last_timestamp FROM last_processed WHERE source = :source'), - {'source': source} - ).fetchone() - return result[0] if result else 0 - finally: - db.close() - -def update_last_processed_timestamp(source, timestamp): - """ - Update last processed timestamp for a source - """ - db = next(get_db()) - try: - db.execute( - text(''' - INSERT OR REPLACE INTO last_processed (source, last_timestamp) - VALUES (:source, :timestamp) - '''), - {'source': source, 'timestamp': timestamp} - ) - db.commit() - finally: - db.close() - -def create_tables(): - db = next(get_db()) - try: - db.execute( - text(''' - CREATE TABLE IF NOT EXISTS last_processed ( - source TEXT PRIMARY KEY, - last_timestamp INTEGER - ) - ''') - ) - db.commit() - finally: - db.close() - -def search_history(query, domain=None, start_date=None, end_date=None, db=None): - """ - Search history using FTS5 with proper ranking - """ - if db is None: - db = next(get_db()) - - try: - # Build the FTS query - fts_query = f'"{query}"' # Exact phrase - if domain: - fts_query += f' AND domain:"{domain}"' - - # Build date filter conditions - date_conditions = [] - params = {'query': query} - - if start_date: - date_conditions.append("visit_time >= :start_date") - params['start_date'] = start_date - if end_date: - date_conditions.append("visit_time <= :end_date") - params['end_date'] = end_date - - date_filter = f"AND {' AND '.join(date_conditions)}" if date_conditions else "" - - # Execute the search query - sql_query = f""" - SELECT - h.*, - bm25(history_fts) as rank, - highlight(history_fts, 0, '', '') as title_highlight, - highlight(history_fts, 1, '', '') as content_highlight - FROM history_fts - JOIN history h ON history_fts.rowid = h.id - WHERE history_fts MATCH :query - {date_filter} - ORDER BY rank, visit_time DESC - LIMIT 100 - """ - - results = db.execute(text(sql_query), params).fetchall() - return results - - except Exception as e: - print(f"Search error: {e}") - return [] - -def recreate_fts_tables(): - """Drop and recreate the FTS tables""" - conn = engine.raw_connection() - cursor = conn.cursor() - try: - # Drop existing FTS table and triggers - cursor.execute("DROP TRIGGER IF EXISTS history_ai") - cursor.execute("DROP TRIGGER IF EXISTS history_ad") - cursor.execute("DROP TRIGGER IF EXISTS history_au") - cursor.execute("DROP TABLE IF EXISTS history_fts") - - # Recreate FTS tables and triggers - init_fts() - - # Reindex all existing content - cursor.execute(""" - INSERT INTO history_fts(rowid, title, markdown_content, domain, visit_time) - SELECT id, title, markdown_content, domain, visit_time FROM history - """) - - conn.commit() - print("Successfully recreated FTS tables and reindexed content") - - except Exception as e: - conn.rollback() - print(f"Error recreating FTS tables: {e}") - finally: - cursor.close() - conn.close() \ No newline at end of file diff --git a/app/logging_config.py b/app/logging_config.py deleted file mode 100644 index 649de31..0000000 --- a/app/logging_config.py +++ /dev/null @@ -1,52 +0,0 @@ -import logging -import logging.handlers -import os -from datetime import datetime -from pathlib import Path - -# Create logs directory if it doesn't exist -LOGS_DIR = Path("logs") -LOGS_DIR.mkdir(exist_ok=True) - -# Create formatters -CONSOLE_FORMAT = '%(levelname)s: %(message)s' -FILE_FORMAT = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' - -def setup_logger(name: str) -> logging.Logger: - """ - Set up a logger with both file and console handlers - - Args: - name: The name of the logger (usually __name__) - - Returns: - logging.Logger: Configured logger instance - """ - logger = logging.getLogger(name) - logger.setLevel(logging.INFO) - - # Prevent adding handlers multiple times - if logger.handlers: - return logger - - # Console handler - console_handler = logging.StreamHandler() - console_handler.setLevel(logging.WARNING) - console_handler.setFormatter(logging.Formatter(CONSOLE_FORMAT)) - - # File handler - log_file = LOGS_DIR / f"{datetime.now().strftime('%Y-%m')}.log" - file_handler = logging.handlers.RotatingFileHandler( - log_file, - maxBytes=10*1024*1024, # 10MB - backupCount=5, - encoding='utf-8' - ) - file_handler.setLevel(logging.INFO) - file_handler.setFormatter(logging.Formatter(FILE_FORMAT)) - - # Add handlers - logger.addHandler(console_handler) - logger.addHandler(file_handler) - - return logger \ No newline at end of file diff --git a/app/main.py b/app/main.py deleted file mode 100644 index c0cafb1..0000000 --- a/app/main.py +++ /dev/null @@ -1,293 +0,0 @@ -from fastapi import FastAPI, Depends -from sqlalchemy.orm import Session -from datetime import datetime, timezone -from typing import Optional -import asyncio -from urllib.parse import urlparse -from fastapi.middleware.cors import CORSMiddleware -from fastapi.staticfiles import StaticFiles -import browser_history -from crawl4ai import AsyncWebCrawler - -# Local imports -from .logging_config import setup_logger -from .database import ( - get_db, - HistoryEntry, - get_last_processed_timestamp, - update_last_processed_timestamp, - create_tables, - engine, - # recreate_fts_tables # Keep if needed, but often done manually or via migration tool -) -from .config import Config - -# Import Routers -from .routers import history, bookmarks, config as api_config, websocket, ui - -logger = setup_logger(__name__) - -# --- Global Variables --- -# These are accessed by other modules (like websocket router) -# Consider using app state or dependency injection for cleaner management if complexity grows -config_manager = Config() # Renamed to avoid conflict with router import -crawler: Optional[AsyncWebCrawler] = None - -# Import scheduler *after* crawler is defined -from .scheduler import HistoryScheduler -scheduler: Optional[HistoryScheduler] = None # Now initialize scheduler variable - -# --- FastAPI App Initialization --- -app = FastAPI(title="Browser History Search API") - -# Add CORS middleware -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], # Adjust in production - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -# Mount static files and templates -app.mount("/static", StaticFiles(directory="app/static"), name="static") -# Note: Templates are used within the ui router now, no need for global instance here unless used elsewhere - -# --- Helper Function (Initial Sync) --- -def process_browser_history(): - """Fetches and stores new history entries from browser_history library (Initial Sync).""" - try: - logger.info("Starting browser history processing (initial sync)") - outputs = browser_history.get_history() - # browser_history returns platform specific History object, get histories list - history_list = [] - if hasattr(outputs, 'histories') and outputs.histories: - history_list = outputs.histories # List of (datetime, url, title) - else: - logger.warning("Could not retrieve histories list from browser_history output.") - return # Exit if no history list found - - logger.info(f"Found {len(history_list)} total history items from browser_history library") - - current_timestamp_dt = datetime.now(timezone.utc) - current_timestamp = int(current_timestamp_dt.timestamp()) # Use timezone-aware timestamp - source_key = "browser_history_sync" # Differentiate from scheduler source - last_timestamp = get_last_processed_timestamp(source_key) or 0 # Ensure it's 0 if None - - logger.info(f"Last processed timestamp for initial sync '{source_key}': {last_timestamp}") - - new_entries = [] - processed_urls_times = set() # Avoid duplicates within the batch - - for entry in history_list: - # Basic validation of entry structure - if not isinstance(entry, (tuple, list)) or len(entry) < 2: - logger.warning(f"Skipping malformed history entry: {entry}") - continue - timestamp, url = entry[0], entry[1] - title = entry[2] if len(entry) > 2 else "" # Handle optional title - - if not url or not timestamp: - logger.warning(f"Skipping entry with missing URL or timestamp: Title='{title}'") - continue - - # Ensure timestamp is datetime object - if not isinstance(timestamp, datetime): - logger.warning(f"Skipping entry with non-datetime timestamp ({type(timestamp)}): {url}") - continue - - # Normalize timestamp (Assume local if naive, convert to UTC) - if timestamp.tzinfo is None or timestamp.tzinfo.utcoffset(timestamp) is None: - try: - timestamp_aware = timestamp.astimezone() # Make aware using system local - except Exception as tz_err: - logger.warning(f"Could not determine local timezone for naive timestamp {timestamp}. Assuming UTC. Error: {tz_err}") - timestamp_aware = timestamp.replace(tzinfo=timezone.utc) # Fallback to UTC - else: - timestamp_aware = timestamp - timestamp_utc = timestamp_aware.astimezone(timezone.utc) - - - # Filter for only new entries based on normalized UTC timestamp - if timestamp_utc.timestamp() > last_timestamp: - entry_key = (url, timestamp_utc.timestamp()) - if entry_key in processed_urls_times: - continue # Skip duplicate within this batch - - new_entries.append((timestamp_utc, url, title)) - processed_urls_times.add(entry_key) - - logger.info(f"Found {len(new_entries)} new entries for initial sync after filtering") - - if new_entries: - added_count = 0 - skipped_ignored = 0 - # Use context manager for session - with next(get_db()) as db: - try: - for timestamp_utc, url, title in new_entries: - domain = urlparse(url).netloc - if config_manager.is_domain_ignored(domain): - # logger.debug(f"Skipping ignored domain during initial sync: {domain}") - skipped_ignored += 1 - continue - - # Optional: Check if entry already exists more robustly - # existing = db.query(HistoryEntry.id).filter(HistoryEntry.url == url, HistoryEntry.visit_time == timestamp_utc).first() - # if existing: - # continue - - history_entry = HistoryEntry( - url=url, - title=title or "", # Ensure title is not None - visit_time=timestamp_utc, - domain=domain - # Note: No markdown content here, only basic history - ) - db.add(history_entry) - added_count += 1 - - if added_count > 0: - db.commit() - logger.info(f"Committed {added_count} new history entries from initial sync.") - # Update the last processed timestamp only if successful commit - update_last_processed_timestamp(source_key, current_timestamp) - logger.info(f"Updated initial sync timestamp for '{source_key}' to {current_timestamp}") - else: - logger.info("No new unique entries to commit during initial sync.") - # Update timestamp even if nothing new added, to mark sync time - update_last_processed_timestamp(source_key, current_timestamp) - logger.info(f"Updated initial sync timestamp check for '{source_key}' to {current_timestamp}") - - - if skipped_ignored > 0: - logger.info(f"Skipped {skipped_ignored} entries due to ignored domains during initial sync.") - - except Exception as e: - logger.error(f"Error storing history item during initial sync: {str(e)}", exc_info=True) - db.rollback() - else: - logger.info("No new history entries found during initial sync.") - # Update timestamp even if nothing new found, to mark sync time - update_last_processed_timestamp(source_key, current_timestamp) - logger.info(f"Updated initial sync timestamp check for '{source_key}' to {current_timestamp}") - - - except ImportError: - logger.warning("`browser_history` library not found or import failed. Skipping initial sync.") - except Exception as e: - logger.error(f"Error processing browser history during initial sync: {str(e)}", exc_info=True) - - -# --- Startup and Shutdown Events --- -@app.on_event("startup") -async def startup_event(): - global crawler, scheduler # Allow modification of globals - logger.info("Starting application initialization...") - - try: - # 1. Ensure base tables exist - logger.info("Ensuring base tables exist...") - create_tables() - - # 2. Initialize the crawler - logger.info("Initializing AsyncWebCrawler...") - if crawler is None: - crawler = AsyncWebCrawler() - logger.info("AsyncWebCrawler initialized.") - - # 3. Initialize the Scheduler *after* the crawler - logger.info("Initializing HistoryScheduler...") - if scheduler is None: - scheduler = HistoryScheduler(crawler=crawler) # Pass crawler instance - logger.info("HistoryScheduler initialized.") - - # 4. Perform initial history sync from browser_history library - logger.info("Performing initial browser history sync...") - process_browser_history() # Sync history not processed before - - # 5. Perform initial bookmark sync (using scheduler's method) - # Run in background to avoid blocking startup if it takes long - logger.info("Starting initial bookmark sync task...") - asyncio.create_task(scheduler.update_bookmarks()) - - # 6. Start background tasks (scheduler for ongoing updates) - logger.info("Starting background history update task...") - asyncio.create_task(scheduler.update_history()) - - # --- Markdown Update Tasks --- - # 7a. Trigger ONE initial batch processing run in the background - logger.info("Starting initial markdown processing batch task...") - asyncio.create_task(scheduler._process_markdown_batch()) # Run one batch now - - # 7b. Start the PERIODIC background markdown update task - logger.info("Starting periodic background markdown update task...") - # Use the renamed method for the loop - asyncio.create_task(scheduler.update_missing_markdown_periodically()) - # --- End Markdown Update Tasks --- - - - logger.info("Application startup sequence initiated. Background tasks running.") - - except Exception as e: - logger.error(f"FATAL ERROR during application startup: {str(e)}", exc_info=True) - raise RuntimeError(f"Application startup failed: {e}") from e - - -@app.on_event("shutdown") -async def shutdown_event(): - global crawler, scheduler - logger.info("Starting application shutdown...") - - # Stop scheduler tasks gracefully if possible (implement cancellation in tasks if needed) - # For now, we just close resources - - # Close scheduler resources - if scheduler and hasattr(scheduler, 'close'): - try: - logger.info("Closing scheduler resources...") - await scheduler.close() # Call the scheduler's close method - except Exception as e: - logger.error(f"Error closing scheduler: {e}", exc_info=True) - - # Close crawler if needed (check crawl4ai docs for explicit close method) - # Based on previous code, seems no explicit close needed, but keep check just in case - if crawler and hasattr(crawler, 'aclose'): - try: - logger.info("Closing AsyncWebCrawler...") - # await crawler.aclose() # Example if an async close exists - except Exception as e: - logger.error(f"Error closing crawler: {e}", exc_info=True) - - - # Close database engine connections if necessary (usually handled automatically by SQLAlchemy) - # if engine and hasattr(engine, 'dispose'): # Check if using async engine that needs dispose - # await engine.dispose() - - logger.info("Application shutdown complete.") - - -# --- Include Routers --- -app.include_router(history.router) -app.include_router(bookmarks.router) -app.include_router(api_config.router) -app.include_router(websocket.router) -app.include_router(ui.router) - -# Optional: Add a root endpoint for health check or basic info -@app.get("/health", tags=["service"]) -async def health_check(): - # Extended health check could verify DB connection or task status - db_ok = False - try: - with next(get_db()) as db: - db.execute("SELECT 1") - db_ok = True - except Exception: - db_ok = False - - return { - "status": "ok", - "database_connection": "ok" if db_ok else "error", - # Add other checks as needed - } \ No newline at end of file diff --git a/app/page_info.py b/app/page_info.py deleted file mode 100644 index 5e5ae4b..0000000 --- a/app/page_info.py +++ /dev/null @@ -1,8 +0,0 @@ -from dataclasses import dataclass -from datetime import datetime - -@dataclass -class PageInfo: - url: str - html: str - timestamp: datetime \ No newline at end of file diff --git a/app/routers/bookmarks.py b/app/routers/bookmarks.py deleted file mode 100644 index 1b189d3..0000000 --- a/app/routers/bookmarks.py +++ /dev/null @@ -1,47 +0,0 @@ -from fastapi import APIRouter, Depends, Query, HTTPException -from sqlalchemy.orm import Session -from typing import List, Optional - -from ..database import get_db, Bookmark -from ..utils import serialize_bookmark -from ..logging_config import setup_logger - -logger = setup_logger(__name__) -router = APIRouter(prefix="/bookmarks", tags=["bookmarks"]) - -@router.get("/search") -async def search_bookmarks( - domain: Optional[str] = Query(None), - folder: Optional[str] = Query(None), - search_term: Optional[str] = Query(None), - db: Session = Depends(get_db) -): - """Search bookmarks with optimized queries""" - try: - # Build query efficiently - query = db.query(Bookmark) - - # Apply filters using index-optimized queries - if domain: - query = query.filter(Bookmark.domain == domain) - - if folder: - query = query.filter(Bookmark.folder == folder) - - if search_term: - # Use LIKE for title search (consider FTS for bookmarks if needed) - search_pattern = f"%{search_term}%" - query = query.filter(Bookmark.title.ilike(search_pattern)) - # Removed index hint as SQLAlchemy/SQLite usually handles this well with LIKE - - # Add ordering and limit for better performance - bookmarks = query.order_by(Bookmark.added_time.desc()).limit(1000).all() - - return [serialize_bookmark(bookmark) for bookmark in bookmarks] - - except Exception as e: - logger.error(f"Bookmark search error: {e}", exc_info=True) - raise HTTPException( - status_code=500, - detail={"message": "Bookmark search operation failed", "error": str(e)} - ) \ No newline at end of file diff --git a/app/routers/config.py b/app/routers/config.py deleted file mode 100644 index 9255ba4..0000000 --- a/app/routers/config.py +++ /dev/null @@ -1,43 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException -from typing import List - -from ..config import Config -from ..logging_config import setup_logger - -logger = setup_logger(__name__) -router = APIRouter(prefix="/config", tags=["config"]) - -# Assuming config is a singleton or easily accessible -# If not, you might need to use Depends or app state -config = Config() - -@router.get("/ignored-domains") -async def get_ignored_domains(): - """Get list of ignored domain patterns""" - try: - return {"ignored_domains": config.config.get('ignored_domains', [])} - except Exception as e: - logger.error(f"Error getting ignored domains: {e}", exc_info=True) - raise HTTPException(status_code=500, detail="Failed to retrieve ignored domains") - - -@router.post("/ignored-domains") -async def add_ignored_domain(pattern: str): - """Add a new domain pattern to ignored list""" - try: - config.add_ignored_domain(pattern) - return {"status": "success", "message": f"Added pattern: {pattern}"} - except Exception as e: - logger.error(f"Error adding ignored domain '{pattern}': {e}", exc_info=True) - raise HTTPException(status_code=500, detail="Failed to add ignored domain") - - -@router.delete("/ignored-domains/{pattern}") -async def remove_ignored_domain(pattern: str): - """Remove a domain pattern from ignored list""" - try: - config.remove_ignored_domain(pattern) - return {"status": "success", "message": f"Removed pattern: {pattern}"} - except Exception as e: - logger.error(f"Error removing ignored domain '{pattern}': {e}", exc_info=True) - raise HTTPException(status_code=500, detail="Failed to remove ignored domain") \ No newline at end of file diff --git a/app/routers/history.py b/app/routers/history.py deleted file mode 100644 index 280ccb6..0000000 --- a/app/routers/history.py +++ /dev/null @@ -1,132 +0,0 @@ -from fastapi import APIRouter, Depends, Query, HTTPException -from sqlalchemy.orm import Session -from sqlalchemy import text -from typing import List, Optional - -from ..database import get_db, HistoryEntry -from ..utils import serialize_history_entry -from ..logging_config import setup_logger - -logger = setup_logger(__name__) -router = APIRouter(prefix="/history", tags=["history"]) - -@router.get("/search") -async def search_history( - query: Optional[str] = Query(None), - domain: Optional[str] = Query(None), - start_date: Optional[str] = Query(None), - end_date: Optional[str] = Query(None), - include_content: bool = Query(False), - db: Session = Depends(get_db) -): - """Search history using FTS5""" - try: - if query: - # Build the FTS query - # Basic query sanitization/escaping might be needed depending on FTS syntax usage - # For simple term search, this is okay. For complex FTS syntax, more care is needed. - fts_conditions = [] - params = {} - - # Handle different query parts (title, content, domain) - # Example: "term1 title:term2 domain:example.com" - # This requires more sophisticated parsing. For now, assume simple query applies to title/content. - # A safer approach for user input: - sanitized_query = query.replace('"', '""') # Basic FTS escaping for quotes - fts_match_expr = f'(title : "{sanitized_query}"* OR markdown_content : "{sanitized_query}"*)' - params['fts_query'] = fts_match_expr - - if domain: - # Add domain filtering directly in FTS if possible and indexed - # Assuming 'domain' is an indexed column in FTS table - # params['fts_query'] += f' AND domain : "{domain}"' # Adjust FTS syntax if needed - # Or filter after FTS search if domain isn't in FTS index efficiently - pass # Domain filtering will be added later if needed - - # Build the SQL query - sql = """ - SELECT - h.*, - bm25(history_fts) as rank, - highlight(history_fts, 0, '', '') as title_highlight, - highlight(history_fts, 1, '', '') as content_highlight - FROM history_fts - JOIN history h ON history_fts.rowid = h.id - WHERE history_fts MATCH :fts_query - """ - - # Add domain filter as a regular WHERE clause if not in FTS MATCH - if domain: - sql += " AND h.domain = :domain" - params['domain'] = domain - - # Add date filters if provided - if start_date: - sql += " AND h.visit_time >= :start_date" - params['start_date'] = start_date - if end_date: - sql += " AND h.visit_time <= :end_date" - params['end_date'] = end_date - - sql += " ORDER BY rank DESC, h.visit_time DESC LIMIT 100" # Rank usually descends - - results = db.execute(text(sql), params).fetchall() - # Use the updated serializer that handles potential highlight/rank fields - return [serialize_history_entry(row, include_content) for row in results] - - else: - # Handle non-search queries (basic filtering) - query_builder = db.query(HistoryEntry) - - if domain: - query_builder = query_builder.filter(HistoryEntry.domain == domain) - if start_date: - query_builder = query_builder.filter(HistoryEntry.visit_time >= start_date) - if end_date: - query_builder = query_builder.filter(HistoryEntry.visit_time <= end_date) - - entries = query_builder.order_by(HistoryEntry.visit_time.desc()).limit(100).all() - return [serialize_history_entry(entry, include_content) for entry in entries] - - except Exception as e: - logger.error(f"Search error: {str(e)}", exc_info=True) - raise HTTPException( - status_code=500, - detail={"message": "Search operation failed", "error": str(e)} - ) - - -@router.get("/search/advanced") -async def advanced_history_search( - query: str = Query(..., description="Full-text search query with SQLite FTS5 syntax"), - include_content: bool = Query(False), - db: Session = Depends(get_db) -): - """Advanced full-text search using SQLite FTS5 features""" - try: - # Use raw SQL for advanced FTS query - # Add rank and highlights here as well - fts_query = """ - SELECT - h.*, - bm25(history_fts) as rank, - highlight(history_fts, 0, '', '') as title_highlight, - highlight(history_fts, 1, '', '') as content_highlight - FROM history_fts - JOIN history h ON history_fts.rowid = h.id - WHERE history_fts MATCH :query - ORDER BY rank DESC, h.visit_time DESC - LIMIT 1000 - """ - - results = db.execute(text(fts_query), {'query': query}).fetchall() - - # Use the updated serializer - return [serialize_history_entry(row, include_content) for row in results] - - except Exception as e: - logger.error(f"Advanced search error: {e}", exc_info=True) - raise HTTPException( - status_code=500, - detail={"message": "Advanced search operation failed", "error": str(e)} - ) \ No newline at end of file diff --git a/app/routers/ui.py b/app/routers/ui.py deleted file mode 100644 index a6a9291..0000000 --- a/app/routers/ui.py +++ /dev/null @@ -1,52 +0,0 @@ -from fastapi import APIRouter, Depends, Request -from fastapi.templating import Jinja2Templates -from sqlalchemy.orm import Session - -from ..database import get_db, HistoryEntry, Bookmark -from ..logging_config import setup_logger - -logger = setup_logger(__name__) -router = APIRouter(tags=["ui"]) -templates = Jinja2Templates(directory="app/templates") - -@router.get("/") -async def home(request: Request, db: Session = Depends(get_db)): - try: - # Get recent history entries - entries = db.query(HistoryEntry)\ - .order_by(HistoryEntry.visit_time.desc())\ - .limit(50)\ - .all() - return templates.TemplateResponse( - "index.html", - {"request": request, "entries": entries} - ) - except Exception as e: - logger.error(f"Error loading home page: {e}", exc_info=True) - # Optionally return an error template - return templates.TemplateResponse("error.html", {"request": request, "detail": "Could not load history"}) - - -@router.get("/search") -async def search_page(request: Request): - return templates.TemplateResponse( - "search.html", - {"request": request} - ) - - -@router.get("/bookmarks") -async def bookmarks_page(request: Request, db: Session = Depends(get_db)): - try: - bookmarks = db.query(Bookmark)\ - .order_by(Bookmark.added_time.desc())\ - .limit(50)\ - .all() - return templates.TemplateResponse( - "bookmarks.html", - {"request": request, "bookmarks": bookmarks} - ) - except Exception as e: - logger.error(f"Error loading bookmarks page: {e}", exc_info=True) - # Optionally return an error template - return templates.TemplateResponse("error.html", {"request": request, "detail": "Could not load bookmarks"}) \ No newline at end of file diff --git a/app/routers/websocket.py b/app/routers/websocket.py deleted file mode 100644 index 7d49baa..0000000 --- a/app/routers/websocket.py +++ /dev/null @@ -1,175 +0,0 @@ -import asyncio -from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, HTTPException -from sqlalchemy.orm import Session -from datetime import datetime, timezone, timedelta -from urllib.parse import urlparse -import iso8601 - -# Import necessary components from other modules -from .. import main as app_main # To access global crawler instance -from ..database import get_db, HistoryEntry -from ..config import Config -from ..logging_config import setup_logger - -logger = setup_logger(__name__) -router = APIRouter(tags=["websocket"]) -config = Config() # Assuming config is okay as a separate instance here - -@router.websocket("/ws") -async def websocket_endpoint(websocket: WebSocket, db: Session = Depends(get_db)): - # Access the global crawler instance from main.py - crawler = app_main.crawler - if not crawler: - logger.error("Crawler not initialized!") - await websocket.close(code=1011) # Internal Server Error - return - - logger.info("New WebSocket connection established") - await websocket.accept() - try: - while True: - data = await websocket.receive_json() - - # Validate incoming data structure (basic check) - if 'url' not in data or 'timestamp' not in data: - logger.warning("Received invalid WebSocket message format.") - await websocket.send_json({ - "status": "error", - "message": "Invalid message format. 'url' and 'timestamp' required." - }) - continue - - url = data['url'] - try: - timestamp = iso8601.parse_date(data['timestamp']) - except iso8601.ParseError: - logger.warning(f"Received invalid timestamp format: {data['timestamp']}") - await websocket.send_json({ - "status": "error", - "message": f"Invalid timestamp format: {data['timestamp']}" - }) - continue - - # Parse the URL and check if domain should be ignored - try: - domain = urlparse(url).netloc - if not domain: # Handle invalid URLs - raise ValueError("Could not parse domain from URL") - except ValueError as e: - logger.warning(f"Could not parse URL: {url}. Error: {e}") - await websocket.send_json({"status": "error", "message": f"Invalid URL: {url}"}) - continue - - if config.is_domain_ignored(domain): - logger.info(f"Ignoring domain: {domain} for URL: {url}") - await websocket.send_json({ - "status": "ignored", - "message": f"Domain {domain} is in ignore list" - }) - continue - - logger.info(f"Processing page via WebSocket: {url}") - - # Check if we already have a recent entry for this URL - # Make timestamp timezone-aware (assuming UTC if naive) - if timestamp.tzinfo is None: - timestamp = timestamp.replace(tzinfo=timezone.utc) - else: - timestamp = timestamp.astimezone(timezone.utc) - - recent_threshold = timestamp - timedelta(minutes=5) - existing_entry = db.query(HistoryEntry.id).filter( - HistoryEntry.url == url, - HistoryEntry.visit_time >= recent_threshold - ).first() # Only fetch ID for efficiency - - if existing_entry: - logger.info(f"Recent entry exists for URL: {url}") - await websocket.send_json({ - "status": "skipped", - "message": "Recent entry exists" - }) - continue - - # --- Start crawl4ai processing --- - logger.info(f"Processing page with crawl4ai: {url}") - markdown_content = None - title = '' - try: - # Use the global crawler instance - crawl_result = await crawler.arun(url=url) - if crawl_result: - markdown_content = crawl_result.markdown - # Attempt to get title from metadata, fallback to empty string - title = getattr(crawl_result.metadata, 'title', '') or '' # Ensure title is string - if not title: - logger.warning(f"Could not extract title for {url} using crawl4ai.") - logger.info(f"crawl4ai processing complete. Markdown length: {len(markdown_content) if markdown_content else 0}, Title: '{title}'") - else: - logger.warning(f"crawl4ai returned None for URL: {url}") - markdown_content = "" # Ensure it's not None - title = "" - - except Exception as crawl_error: - logger.error(f"crawl4ai failed for URL {url}: {crawl_error}", exc_info=True) - await websocket.send_json({ - "status": "error", - "message": f"Failed to crawl page content: {str(crawl_error)}" - }) - continue # Skip to next message - # --- End crawl4ai processing --- - - # Only proceed if we got some content or at least a title - if not title and not markdown_content: - logger.info(f"No title or content extracted by crawl4ai from: {url}") - await websocket.send_json({ - "status": "skipped", - "message": "No title or content extracted by crawl4ai" - }) - continue - - # Create history entry using data from crawl4ai - history_entry = HistoryEntry( - url=url, - title=title, # Use title from crawl4ai - visit_time=timestamp, # Use the parsed, timezone-aware timestamp - domain=domain, - markdown_content=markdown_content, # Use markdown from crawl4ai - last_content_update=datetime.now(timezone.utc) - ) - - logger.debug(f"Attempting to save entry for {url} with markdown length: {len(markdown_content) if markdown_content else 0}") - - db.add(history_entry) - try: - db.commit() - logger.info(f"Successfully saved entry for: {url}") - await websocket.send_json({ - "status": "success", - "message": f"Processed page: {url}" - }) - except Exception as e: - db.rollback() - logger.error(f"Error saving entry for {url}: {e}", exc_info=True) - await websocket.send_json({ - "status": "error", - "message": "Database error occurred while saving." - }) - - except WebSocketDisconnect: - logger.info("WebSocket client disconnected") - except Exception as e: - logger.error(f"Unhandled error in WebSocket handler: {e}", exc_info=True) - # Attempt to inform client before closing (might fail if connection is already broken) - try: - await websocket.send_json({ - "status": "error", - "message": "An internal server error occurred." - }) - except Exception: - pass # Ignore if sending fails - # Ensure connection is closed on server error - try: - await websocket.close(code=1011) # Internal Server Error - except Exception: - pass # Ignore if closing fails \ No newline at end of file diff --git a/app/scheduler.py b/app/scheduler.py deleted file mode 100644 index 877f31a..0000000 --- a/app/scheduler.py +++ /dev/null @@ -1,386 +0,0 @@ -from datetime import datetime, timedelta, timezone -import asyncio -from sqlalchemy import or_, update -from .database import HistoryEntry, Bookmark, get_last_processed_timestamp, update_last_processed_timestamp -from .browser import BrowserHistoryCollector -from .config import Config -from .database import get_db -import urllib.parse -import logging -from crawl4ai import AsyncWebCrawler -from typing import Optional - -logger = logging.getLogger(__name__) - -class HistoryScheduler: - def __init__(self, crawler: AsyncWebCrawler): - self.browser_collector = BrowserHistoryCollector() - self.last_history_update = None - self.content_update_interval = timedelta(hours=24) # Update content daily - self.config = Config() - self.db_lock = asyncio.Lock() - self.crawler = crawler - - def _normalize_datetime(self, dt: datetime) -> Optional[datetime]: - """Convert datetime to UTC if it has timezone, or make it timezone-aware (UTC) if it doesn't""" - if dt is None: - return None - - # If datetime is naive (no timezone), assume it's local and convert to UTC - if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None: - # Assume local timezone if naive, then convert to UTC - # This might need adjustment based on where the naive datetime originates - # If browser_history always returns naive UTC, use: dt.replace(tzinfo=timezone.utc) - # If browser_history returns naive local time: - dt = dt.astimezone() # Make timezone-aware using system's local timezone - return dt.astimezone(timezone.utc) # Convert to UTC - - # If datetime already has timezone, convert to UTC - return dt.astimezone(timezone.utc) - - async def update_bookmarks(self): - """Update bookmarks from browsers""" - try: - # Use timezone-aware current time - current_timestamp_dt = datetime.now(timezone.utc) - current_timestamp = int(current_timestamp_dt.timestamp()) - source_key = "browser_bookmarks" - # Ensure last_timestamp is 0 if None - last_timestamp = get_last_processed_timestamp(source_key) or 0 - - logger.info(f"Fetching bookmarks. Last processed timestamp (UTC epoch): {last_timestamp}") - bookmarks = self.browser_collector.fetch_bookmarks() - logger.info(f"Found {len(bookmarks)} total bookmarks") - - new_bookmarks = [] - skipped_ignored = 0 - processed_urls = set() # Avoid processing duplicate bookmark URLs within the same batch - - for added_time, url, title, folder in bookmarks: - if not url or url in processed_urls: # Skip empty or duplicate URLs in this batch - continue - - # Normalize timestamp *before* comparison - normalized_added_time = self._normalize_datetime(added_time) - if normalized_added_time is None: - logger.warning(f"Skipping bookmark with invalid timestamp: {url} - {title}") - continue - - # Compare timestamps after normalization - if normalized_added_time.timestamp() > last_timestamp: - domain = urllib.parse.urlparse(url).netloc - if self.config.is_domain_ignored(domain): - # logger.debug(f"Skipping ignored domain for bookmark: {domain}") - skipped_ignored += 1 - continue - - new_bookmarks.append((normalized_added_time, url, title, folder, domain)) - processed_urls.add(url) # Mark URL as processed for this batch - - logger.info(f"Found {len(new_bookmarks)} new bookmarks to process after filtering.") - if skipped_ignored > 0: - logger.info(f"Skipped {skipped_ignored} bookmarks due to ignored domains.") - - - if new_bookmarks: - async with self.db_lock: - # Use context manager for session - with next(get_db()) as db: - added_count = 0 - try: - for norm_added_time, url, title, folder, domain in new_bookmarks: - # Optional: Check if bookmark already exists (by URL) - # existing = db.query(Bookmark.id).filter(Bookmark.url == url).first() - # if existing: - # logger.debug(f"Bookmark already exists: {url}") - # continue - - bookmark = Bookmark( - url=url, - title=title or "", # Ensure title is not None - added_time=norm_added_time, - folder=folder or "", # Ensure folder is not None - domain=domain - ) - db.add(bookmark) - added_count += 1 - - if added_count > 0: - db.commit() - logger.info(f"Successfully committed {added_count} new bookmarks.") - # Update timestamp only if new bookmarks were added - update_last_processed_timestamp(source_key, current_timestamp) - logger.info(f"Updated last processed bookmark timestamp for '{source_key}' to {current_timestamp}") - else: - logger.info("No new unique bookmarks to add in this batch.") - # Optionally update timestamp even if no *new* bookmarks were added, - # to signify the check was performed up to 'current_timestamp'. - # update_last_processed_timestamp(source_key, current_timestamp) - # logger.info(f"Updated last processed bookmark timestamp check for '{source_key}' to {current_timestamp}") - - - except Exception as e: - logger.error(f"Error committing bookmarks: {str(e)}", exc_info=True) - db.rollback() - else: - logger.info("No new bookmarks found since last check.") - # Update timestamp to indicate the check was performed - update_last_processed_timestamp(source_key, current_timestamp) - logger.info(f"Updated last processed bookmark timestamp check for '{source_key}' to {current_timestamp}") - - - except Exception as e: - logger.error(f"Error updating bookmarks: {str(e)}", exc_info=True) - - - async def update_history(self): - """Background task to update history periodically""" - # Initial sleep to allow startup tasks (like initial sync) to potentially finish first - await asyncio.sleep(10) - while True: - try: - # Use timezone-aware current time - current_timestamp_dt = datetime.now(timezone.utc) - current_timestamp = int(current_timestamp_dt.timestamp()) - source_key = "browser_history_scheduler" # Use a different key than initial sync - # Ensure last_timestamp is 0 if None - last_timestamp = get_last_processed_timestamp(source_key) or 0 - - logger.info(f"Scheduler: Fetching history. Last processed timestamp (UTC epoch): {last_timestamp}") - history_entries = self.browser_collector.fetch_history() - logger.info(f"Scheduler: Found {len(history_entries)} total history entries from browser.") - - new_entries = [] - skipped_ignored = 0 - processed_urls_times = set() # Avoid duplicates within the batch (url, timestamp) - - for visit_time, url, title in history_entries: - # Basic validation - if not url or not visit_time: - logger.warning(f"Scheduler: Skipping entry with missing URL or timestamp: {title}") - continue - - # Normalize timestamp *before* comparison - normalized_visit_time = self._normalize_datetime(visit_time) - if normalized_visit_time is None: - logger.warning(f"Scheduler: Skipping history with invalid timestamp: {url} - {title}") - continue - - # Compare timestamps after normalization - if normalized_visit_time.timestamp() > last_timestamp: - entry_key = (url, normalized_visit_time.timestamp()) - if entry_key in processed_urls_times: - continue # Skip duplicate within this batch - - domain = urllib.parse.urlparse(url).netloc - if self.config.is_domain_ignored(domain): - # logger.debug(f"Scheduler: Skipping ignored domain: {domain}") - skipped_ignored += 1 - continue - - new_entries.append((normalized_visit_time, url, title, domain)) - processed_urls_times.add(entry_key) - - logger.info(f"Scheduler: Found {len(new_entries)} new history entries to process after filtering.") - if skipped_ignored > 0: - logger.info(f"Scheduler: Skipped {skipped_ignored} history entries due to ignored domains.") - - if new_entries: - async with self.db_lock: - # Use context manager for session - with next(get_db()) as db: - added_count = 0 - try: - for norm_visit_time, url, title, domain in new_entries: - # Optional: More robust check if entry already exists - # existing = db.query(HistoryEntry.id).filter( - # HistoryEntry.url == url, - # HistoryEntry.visit_time == norm_visit_time - # ).first() - # if existing: - # logger.debug(f"Scheduler: History entry already exists: {url} at {norm_visit_time}") - # continue - - history_entry = HistoryEntry( - url=url, - title=title or "", # Ensure title is not None - visit_time=norm_visit_time, - domain=domain - # markdown_content is initially NULL - ) - db.add(history_entry) - added_count += 1 - - if added_count > 0: - db.commit() - logger.info(f"Scheduler: Successfully committed {added_count} new history entries.") - # Update timestamp only if new entries were added - update_last_processed_timestamp(source_key, current_timestamp) - logger.info(f"Scheduler: Updated last processed history timestamp for '{source_key}' to {current_timestamp}") - else: - logger.info("Scheduler: No new unique history entries to add in this batch.") - # Optionally update timestamp even if no *new* entries were added - # update_last_processed_timestamp(source_key, current_timestamp) - # logger.info(f"Scheduler: Updated last processed history timestamp check for '{source_key}' to {current_timestamp}") - - except Exception as e: - logger.error(f"Scheduler: Error committing history: {str(e)}", exc_info=True) - db.rollback() - else: - logger.info("Scheduler: No new history entries found since last check.") - # Update timestamp to indicate the check was performed - update_last_processed_timestamp(source_key, current_timestamp) - logger.info(f"Scheduler: Updated last processed history timestamp check for '{source_key}' to {current_timestamp}") - - - except Exception as e: - logger.error(f"Scheduler: Error in update_history loop: {str(e)}", exc_info=True) - - # --- Access config value using property --- - try: - # Use direct attribute access via the @property - wait_time = self.config.history_update_interval_seconds - except Exception as config_err: - logger.error(f"Scheduler (History): Error accessing config for wait time, using default 300s. Error: {config_err}") - wait_time = 300 - # --- End Access --- - - logger.debug(f"Scheduler (History): Sleeping for {wait_time} seconds.") - await asyncio.sleep(wait_time) # Use the obtained wait_time - - async def _process_markdown_batch(self): - """Fetches and processes one batch (up to 10) of history entries needing markdown.""" - entries_to_process = [] - try: - # --- Query for entries (inside DB lock/session) --- - async with self.db_lock: - with next(get_db()) as db: - # Find up to 10 entries where markdown_content is NULL or empty string - entries_to_process = db.query(HistoryEntry).filter( - or_(HistoryEntry.markdown_content == None, HistoryEntry.markdown_content == '') - ).order_by(HistoryEntry.visit_time.asc()).limit(10).all() - - if entries_to_process: - logger.info(f"Markdown Processor: Found {len(entries_to_process)} entries to process in this batch.") - for entry in entries_to_process: - db.expunge(entry) # Detach before async operations - else: - logger.info("Markdown Processor: No history entries found needing markdown update in this batch.") - return # Nothing to do in this batch - - - # --- Crawling and Updating (outside the DB lock/session) --- - processed_count = 0 - skipped_ignored = 0 - for entry in entries_to_process: - markdown_content = None - crawl_success = False - should_update_db = False - - # --- ADD DOMAIN CHECK --- - try: - # +++ Add Debugging Lines +++ - logger.debug(f"Debugging urllib.parse type: {type(urllib.parse)}") - logger.debug(f"Is 'urlparse' in urllib.parse? {'urlparse' in dir(urllib.parse)}") - # +++ End Debugging Lines +++ - - domain = urllib.parse.urlparse(entry.url).netloc - if self.config.is_domain_ignored(domain): - logger.debug(f"Markdown Processor: Skipping ignored domain: {domain} for URL: {entry.url} (ID={entry.id})") - skipped_ignored += 1 - continue - except Exception as parse_err: - logger.warning(f"Markdown Processor: Error parsing URL to get domain: {entry.url} (ID={entry.id}). Type={type(parse_err).__name__} Error: {parse_err}. Skipping entry.") - continue - # --- END DOMAIN CHECK --- - - - try: - logger.info(f"Markdown Processor: Crawling URL: {entry.url} (ID={entry.id})") - if not self.crawler: - logger.error("Markdown Processor: Crawler not initialized!") - break # Stop processing this batch if crawler is missing - - result = await self.crawler.arun(url=entry.url) - - if result and result.markdown: - markdown_content = result.markdown - crawl_success = True - logger.info(f"Markdown Processor: Successfully crawled and got markdown for ID={entry.id}.") - else: - logger.warning(f"Markdown Processor: Crawling completed but no markdown content found for ID={entry.id}, URL={entry.url}") - markdown_content = "" # Mark as processed without content - crawl_success = True - - should_update_db = True - - except Exception as crawl_error: - logger.error(f"Markdown Processor: Error crawling URL {entry.url} (ID={entry.id}) Type={type(crawl_error).__name__}: {crawl_error}", exc_info=False) - should_update_db = False # Don't update DB on crawl error - - # --- Update DB for this specific entry --- - if should_update_db: - try: - async with self.db_lock: - with next(get_db()) as db_update: - stmt = ( - update(HistoryEntry) - .where(HistoryEntry.id == entry.id) - .values(markdown_content=markdown_content) - ) - result_proxy = db_update.execute(stmt) - if result_proxy.rowcount > 0: - db_update.commit() - # Adjust log message based on whether it was skipped or processed - if markdown_content == "" and crawl_success and not result.markdown: # Check if marked empty due to no content - logger.info(f"Markdown Processor: Marked entry as processed (no content found) for ID={entry.id}.") - elif crawl_success: - logger.info(f"Markdown Processor: Successfully updated markdown status for ID={entry.id}.") - - # Only increment processed_count if actual content was added or marked empty after crawl - if markdown_content is not None: # Includes actual markdown or empty string marker - processed_count += 1 - else: - logger.warning(f"Markdown Processor: Could not find entry ID={entry.id} to update markdown status (rowcount 0).") - db_update.rollback() - except Exception as db_update_error: - logger.error(f"Markdown Processor: Error updating database for ID={entry.id}: {db_update_error}", exc_info=True) - - log_suffix = f"Updated {processed_count}" - if skipped_ignored > 0: - log_suffix += f", Skipped {skipped_ignored} (ignored domain)" - log_suffix += f" out of {len(entries_to_process)} entries in this batch." - logger.info(f"Markdown Processor: Finished processing batch. {log_suffix}") - - - except Exception as e: - logger.error(f"Markdown Processor: Error processing markdown batch: {str(e)}", exc_info=True) - - - async def update_missing_markdown_periodically(self): - """Periodically triggers the processing of batches of history entries needing markdown.""" - # Initial slight delay to ensure startup tasks settle - await asyncio.sleep(15) - logger.info("Starting periodic markdown update task...") - while True: - await self._process_markdown_batch() # Process one batch - - # Wait before checking for the next batch - # --- Access config value using property --- - try: - # Use direct attribute access via the @property - wait_time = self.config.markdown_update_interval_seconds - except Exception as config_err: - logger.error(f"Periodic Markdown Updater: Error accessing config for wait time, using default 300s. Error: {config_err}") - wait_time = 300 - # --- End Access --- - - logger.debug(f"Periodic Markdown Updater: Sleeping for {wait_time} seconds before next batch.") - await asyncio.sleep(wait_time) - - async def close(self): - """Cleanup resources""" - logger.info("Closing scheduler resources...") - # Add any specific cleanup needed for BrowserHistoryCollector if necessary - # The crawler is managed and closed (if needed) in main.py's shutdown - pass \ No newline at end of file diff --git a/app/static/css/main.css b/app/static/css/main.css deleted file mode 100644 index d0bfde1..0000000 --- a/app/static/css/main.css +++ /dev/null @@ -1,209 +0,0 @@ -/* Custom styles can be added here */ -.active-nav-link { - border-color: #60a5fa; - color: #60a5fa; -} - -/* Add smooth transitions for hover effects */ -.hover\:border-primary { - transition: border-color 0.2s ease-in-out; -} - -/* Custom scrollbar styles */ -::-webkit-scrollbar { - width: 8px; -} - -::-webkit-scrollbar-track { - background: #1f2937; -} - -::-webkit-scrollbar-thumb { - background: #60a5fa; - border-radius: 4px; -} - -::-webkit-scrollbar-thumb:hover { - background: #3b82f6; -} - -/* Dark mode input styles */ -input[type="date"]::-webkit-calendar-picker-indicator { - filter: invert(1); - opacity: 0.5; -} - -/* Search result styles */ -.prose { - font-size: 0.875rem; - line-height: 1.5; -} - -.prose p { - margin-bottom: 0.75rem; -} - -.prose h1, -.prose h2, -.prose h3, -.prose h4 { - color: #f3f4f6; - font-weight: 600; - margin: 1.5rem 0 0.75rem 0; -} - -.prose a { - color: #60a5fa; - text-decoration: underline; -} - -.prose code { - background: #374151; - padding: 0.2em 0.4em; - border-radius: 0.25rem; - font-size: 0.875em; -} - -.prose pre { - background: #374151; - padding: 1rem; - border-radius: 0.375rem; - overflow-x: auto; - margin: 1rem 0; -} - -.prose pre code { - background: transparent; - padding: 0; -} - -.prose ul, -.prose ol { - padding-left: 1.5rem; - margin: 0.75rem 0; -} - -.prose li { - margin: 0.25rem 0; -} - -.prose blockquote { - border-left: 4px solid #4b5563; - padding-left: 1rem; - margin: 1rem 0; - color: #9ca3af; -} - -/* Search highlight styles */ -.highlight-search { - background-color: #60a5fa; - color: #111827; - padding: 0.1em 0.2em; - border-radius: 0.2em; - font-weight: 600; -} - -/* Preview text styles */ -.preview-text { - color: #9ca3af; - position: relative; -} - -.preview-text::after { - content: ''; - position: absolute; - bottom: 0; - left: 0; - right: 0; - height: 2rem; - background: linear-gradient(transparent, #1f2937); - pointer-events: none; - opacity: 0; - transition: opacity 0.2s; -} - -.preview-text.collapsed::after { - opacity: 1; -} - -.preview-text.collapsed { - max-height: 8rem; - overflow: hidden; -} - -/* Expand button styles */ -.expand-button { - color: #60a5fa; - font-size: 0.875rem; - padding: 0.25rem 0.5rem; - border-radius: 0.25rem; - cursor: pointer; - transition: all 0.2s; - border: 1px solid #374151; -} - -.expand-button:hover { - background-color: #374151; -} - -/* Form input styles */ -.form-input { - background-color: #1f2937; - border-color: #374151; - color: #f3f4f6; - transition: all 0.2s; -} - -.form-input:hover { - border-color: #4b5563; -} - -.form-input:focus { - border-color: #60a5fa; - box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.2); - outline: none; -} - -/* Form label styles */ -.form-label { - color: #9ca3af; - font-weight: 500; - margin-bottom: 0.5rem; -} - -/* Search button styles */ -.search-button { - background-color: #2563eb; - color: white; - font-weight: 500; - padding: 0.5rem 1.5rem; - border-radius: 0.375rem; - transition: all 0.2s; - border: 1px solid transparent; -} - -.search-button:hover { - background-color: #1d4ed8; -} - -.search-button:focus { - outline: none; - box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.5); -} - -.search-button:active { - background-color: #1e40af; -} - -/* Form grid layout */ -.form-grid { - display: grid; - gap: 1.5rem; - margin-bottom: 1.5rem; -} - -@media (min-width: 640px) { - .form-grid { - grid-template-columns: repeat(2, 1fr); - } -} \ No newline at end of file diff --git a/app/static/js/marked.min.js b/app/static/js/marked.min.js deleted file mode 100644 index fe0be4c..0000000 --- a/app/static/js/marked.min.js +++ /dev/null @@ -1,6 +0,0 @@ -/** - * marked v15.0.6 - a markdown parser - * Copyright (c) 2011-2025, Christopher Jeffrey. (MIT Licensed) - * https://github.com/markedjs/marked - */ -!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).marked={})}(this,(function(e){"use strict";function t(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}function n(t){e.defaults=t}e.defaults={async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null};const s={exec:()=>null};function r(e,t=""){let n="string"==typeof e?e:e.source;const s={replace:(e,t)=>{let r="string"==typeof t?t:t.source;return r=r.replace(i.caret,"$1"),n=n.replace(e,r),s},getRegex:()=>new RegExp(n,t)};return s}const i={codeRemoveIndent:/^(?: {1,4}| {0,3}\t)/gm,outputLinkReplace:/\\([\[\]])/g,indentCodeCompensation:/^(\s+)(?:```)/,beginningSpace:/^\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\n/g,tabCharGlobal:/\t/g,multipleSpaceGlobal:/\s+/g,blankLine:/^[ \t]*$/,doubleBlankLine:/\n[ \t]*\n[ \t]*$/,blockquoteStart:/^ {0,3}>/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] /,listReplaceTask:/^\[[ xX]\] +/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:e=>new RegExp(`^( {0,3}${e})((?:[\t ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`),hrRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}#`),htmlBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}<(?:[a-z].*>|!--)`,"i")},l=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,o=/(?:[*+-]|\d{1,9}[.)])/,a=r(/^(?!bull |blockCode|fences|blockquote|heading|html)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html))+?)\n {0,3}(=+|-+) *(?:\n+|$)/).replace(/bull/g,o).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).getRegex(),c=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,h=/(?!\s*\])(?:\\.|[^\[\]\\])+/,p=r(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",h).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),u=r(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,o).getRegex(),g="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",k=/|$))/,d=r("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$))","i").replace("comment",k).replace("tag",g).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),f=r(c).replace("hr",l).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",g).getRegex(),x={blockquote:r(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",f).getRegex(),code:/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,def:p,fences:/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,heading:/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,hr:l,html:d,lheading:a,list:u,newline:/^(?:[ \t]*(?:\n|$))+/,paragraph:f,table:s,text:/^[^\n]+/},b=r("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",l).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3}\t)[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",g).getRegex(),w={...x,table:b,paragraph:r(c).replace("hr",l).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",b).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",g).getRegex()},m={...x,html:r("^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))").replace("comment",k).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:s,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:r(c).replace("hr",l).replace("heading"," *#{1,6} *[^\n]").replace("lheading",a).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},y=/^( {2,}|\\)\n(?!\s*$)/,$=/[\p{P}\p{S}]/u,R=/[\s\p{P}\p{S}]/u,S=/[^\s\p{P}\p{S}]/u,T=r(/^((?![*_])punctSpace)/,"u").replace(/punctSpace/g,R).getRegex(),z=/(?!~)[\p{P}\p{S}]/u,A=/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,_=r(A,"u").replace(/punct/g,$).getRegex(),P=r(A,"u").replace(/punct/g,z).getRegex(),I="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",L=r(I,"gu").replace(/notPunctSpace/g,S).replace(/punctSpace/g,R).replace(/punct/g,$).getRegex(),B=r(I,"gu").replace(/notPunctSpace/g,/(?:[^\s\p{P}\p{S}]|~)/u).replace(/punctSpace/g,/(?!~)[\s\p{P}\p{S}]/u).replace(/punct/g,z).getRegex(),C=r("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,S).replace(/punctSpace/g,R).replace(/punct/g,$).getRegex(),E=r(/\\(punct)/,"gu").replace(/punct/g,$).getRegex(),q=r(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),Z=r(k).replace("(?:--\x3e|$)","--\x3e").getRegex(),v=r("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",Z).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),D=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,M=r(/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/).replace("label",D).replace("href",/<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),O=r(/^!?\[(label)\]\[(ref)\]/).replace("label",D).replace("ref",h).getRegex(),Q=r(/^!?\[(ref)\](?:\[\])?/).replace("ref",h).getRegex(),j={_backpedal:s,anyPunctuation:E,autolink:q,blockSkip:/\[[^[\]]*?\]\((?:\\.|[^\\\(\)]|\((?:\\.|[^\\\(\)])*\))*\)|`[^`]*?`|<[^<>]*?>/g,br:y,code:/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,del:s,emStrongLDelim:_,emStrongRDelimAst:L,emStrongRDelimUnd:C,escape:/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,link:M,nolink:Q,punctuation:T,reflink:O,reflinkSearch:r("reflink|nolink(?!\\()","g").replace("reflink",O).replace("nolink",Q).getRegex(),tag:v,text:/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},J=e=>U[e];function K(e,t){if(t){if(i.escapeTest.test(e))return e.replace(i.escapeReplace,J)}else if(i.escapeTestNoEncode.test(e))return e.replace(i.escapeReplaceNoEncode,J);return e}function V(e){try{e=encodeURI(e).replace(i.percentDecode,"%")}catch{return null}return e}function W(e,t){const n=e.replace(i.findPipe,((e,t,n)=>{let s=!1,r=t;for(;--r>=0&&"\\"===n[r];)s=!s;return s?"|":" |"})).split(i.splitPipe);let s=0;if(n[0].trim()||n.shift(),n.length>0&&!n.at(-1)?.trim()&&n.pop(),t)if(n.length>t)n.splice(t);else for(;n.length0)return{type:"space",raw:t[0]}}code(e){const t=this.rules.block.code.exec(e);if(t){const e=t[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?e:Y(e,"\n")}}}fences(e){const t=this.rules.block.fences.exec(e);if(t){const e=t[0],n=function(e,t,n){const s=e.match(n.other.indentCodeCompensation);if(null===s)return t;const r=s[1];return t.split("\n").map((e=>{const t=e.match(n.other.beginningSpace);if(null===t)return e;const[s]=t;return s.length>=r.length?e.slice(r.length):e})).join("\n")}(e,t[3]||"",this.rules);return{type:"code",raw:e,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:n}}}heading(e){const t=this.rules.block.heading.exec(e);if(t){let e=t[2].trim();if(this.rules.other.endingHash.test(e)){const t=Y(e,"#");this.options.pedantic?e=t.trim():t&&!this.rules.other.endingSpaceChar.test(t)||(e=t.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:e,tokens:this.lexer.inline(e)}}}hr(e){const t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:Y(t[0],"\n")}}blockquote(e){const t=this.rules.block.blockquote.exec(e);if(t){let e=Y(t[0],"\n").split("\n"),n="",s="";const r=[];for(;e.length>0;){let t=!1;const i=[];let l;for(l=0;l1,r={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");const i=this.rules.other.listItemRegex(n);let l=!1;for(;e;){let n=!1,s="",o="";if(!(t=i.exec(e)))break;if(this.rules.block.hr.test(e))break;s=t[0],e=e.substring(s.length);let a=t[2].split("\n",1)[0].replace(this.rules.other.listReplaceTabs,(e=>" ".repeat(3*e.length))),c=e.split("\n",1)[0],h=!a.trim(),p=0;if(this.options.pedantic?(p=2,o=a.trimStart()):h?p=t[1].length+1:(p=t[2].search(this.rules.other.nonSpaceChar),p=p>4?1:p,o=a.slice(p),p+=t[1].length),h&&this.rules.other.blankLine.test(c)&&(s+=c+"\n",e=e.substring(c.length+1),n=!0),!n){const t=this.rules.other.nextBulletRegex(p),n=this.rules.other.hrRegex(p),r=this.rules.other.fencesBeginRegex(p),i=this.rules.other.headingBeginRegex(p),l=this.rules.other.htmlBeginRegex(p);for(;e;){const u=e.split("\n",1)[0];let g;if(c=u,this.options.pedantic?(c=c.replace(this.rules.other.listReplaceNesting," "),g=c):g=c.replace(this.rules.other.tabCharGlobal," "),r.test(c))break;if(i.test(c))break;if(l.test(c))break;if(t.test(c))break;if(n.test(c))break;if(g.search(this.rules.other.nonSpaceChar)>=p||!c.trim())o+="\n"+g.slice(p);else{if(h)break;if(a.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4)break;if(r.test(a))break;if(i.test(a))break;if(n.test(a))break;o+="\n"+c}h||c.trim()||(h=!0),s+=u+"\n",e=e.substring(u.length+1),a=g.slice(p)}}r.loose||(l?r.loose=!0:this.rules.other.doubleBlankLine.test(s)&&(l=!0));let u,g=null;this.options.gfm&&(g=this.rules.other.listIsTask.exec(o),g&&(u="[ ] "!==g[0],o=o.replace(this.rules.other.listReplaceTask,""))),r.items.push({type:"list_item",raw:s,task:!!g,checked:u,loose:!1,text:o,tokens:[]}),r.raw+=s}const o=r.items.at(-1);if(!o)return;o.raw=o.raw.trimEnd(),o.text=o.text.trimEnd(),r.raw=r.raw.trimEnd();for(let e=0;e"space"===e.type)),n=t.length>0&&t.some((e=>this.rules.other.anyLine.test(e.raw)));r.loose=n}if(r.loose)for(let e=0;e({text:e,tokens:this.lexer.inline(e),header:!1,align:i.align[t]}))));return i}}lheading(e){const t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:"="===t[2].charAt(0)?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){const t=this.rules.block.paragraph.exec(e);if(t){const e="\n"===t[1].charAt(t[1].length-1)?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:e,tokens:this.lexer.inline(e)}}}text(e){const t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){const t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:t[1]}}tag(e){const t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&this.rules.other.startATag.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){const t=this.rules.inline.link.exec(e);if(t){const e=t[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(e)){if(!this.rules.other.endAngleBracket.test(e))return;const t=Y(e.slice(0,-1),"\\");if((e.length-t.length)%2==0)return}else{const e=function(e,t){if(-1===e.indexOf(t[1]))return-1;let n=0;for(let s=0;s-1){const n=(0===t[0].indexOf("!")?5:4)+t[1].length+e;t[2]=t[2].substring(0,e),t[0]=t[0].substring(0,n).trim(),t[3]=""}}let n=t[2],s="";if(this.options.pedantic){const e=this.rules.other.pedanticHrefTitle.exec(n);e&&(n=e[1],s=e[3])}else s=t[3]?t[3].slice(1,-1):"";return n=n.trim(),this.rules.other.startAngleBracket.test(n)&&(n=this.options.pedantic&&!this.rules.other.endAngleBracket.test(e)?n.slice(1):n.slice(1,-1)),ee(t,{href:n?n.replace(this.rules.inline.anyPunctuation,"$1"):n,title:s?s.replace(this.rules.inline.anyPunctuation,"$1"):s},t[0],this.lexer,this.rules)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){const e=t[(n[2]||n[1]).replace(this.rules.other.multipleSpaceGlobal," ").toLowerCase()];if(!e){const e=n[0].charAt(0);return{type:"text",raw:e,text:e}}return ee(n,e,n[0],this.lexer,this.rules)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrongLDelim.exec(e);if(!s)return;if(s[3]&&n.match(this.rules.other.unicodeAlphaNumeric))return;if(!(s[1]||s[2]||"")||!n||this.rules.inline.punctuation.exec(n)){const n=[...s[0]].length-1;let r,i,l=n,o=0;const a="*"===s[0][0]?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(a.lastIndex=0,t=t.slice(-1*e.length+n);null!=(s=a.exec(t));){if(r=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!r)continue;if(i=[...r].length,s[3]||s[4]){l+=i;continue}if((s[5]||s[6])&&n%3&&!((n+i)%3)){o+=i;continue}if(l-=i,l>0)continue;i=Math.min(i,i+l+o);const t=[...s[0]][0].length,a=e.slice(0,n+s.index+t+i);if(Math.min(n,i)%2){const e=a.slice(1,-1);return{type:"em",raw:a,text:e,tokens:this.lexer.inlineTokens(e)}}const c=a.slice(2,-2);return{type:"strong",raw:a,text:c,tokens:this.lexer.inlineTokens(c)}}}}codespan(e){const t=this.rules.inline.code.exec(e);if(t){let e=t[2].replace(this.rules.other.newLineCharGlobal," ");const n=this.rules.other.nonSpaceChar.test(e),s=this.rules.other.startingSpaceChar.test(e)&&this.rules.other.endingSpaceChar.test(e);return n&&s&&(e=e.substring(1,e.length-1)),{type:"codespan",raw:t[0],text:e}}}br(e){const t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){const t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){const t=this.rules.inline.autolink.exec(e);if(t){let e,n;return"@"===t[2]?(e=t[1],n="mailto:"+e):(e=t[1],n=e),{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let e,n;if("@"===t[2])e=t[0],n="mailto:"+e;else{let s;do{s=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??""}while(s!==t[0]);e=t[0],n="www."===t[1]?"http://"+t[0]:t[0]}return{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}inlineText(e){const t=this.rules.inline.text.exec(e);if(t){const e=this.lexer.state.inRawBlock;return{type:"text",raw:t[0],text:t[0],escaped:e}}}}class ne{tokens;options;state;tokenizer;inlineQueue;constructor(t){this.tokens=[],this.tokens.links=Object.create(null),this.options=t||e.defaults,this.options.tokenizer=this.options.tokenizer||new te,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};const n={other:i,block:X.normal,inline:F.normal};this.options.pedantic?(n.block=X.pedantic,n.inline=F.pedantic):this.options.gfm&&(n.block=X.gfm,this.options.breaks?n.inline=F.breaks:n.inline=F.gfm),this.tokenizer.rules=n}static get rules(){return{block:X,inline:F}}static lex(e,t){return new ne(t).lex(e)}static lexInline(e,t){return new ne(t).inlineTokens(e)}lex(e){e=e.replace(i.carriageReturn,"\n"),this.blockTokens(e,this.tokens);for(let e=0;e!!(s=n.call({lexer:this},e,t))&&(e=e.substring(s.raw.length),t.push(s),!0))))continue;if(s=this.tokenizer.space(e)){e=e.substring(s.raw.length);const n=t.at(-1);1===s.raw.length&&void 0!==n?n.raw+="\n":t.push(s);continue}if(s=this.tokenizer.code(e)){e=e.substring(s.raw.length);const n=t.at(-1);"paragraph"===n?.type||"text"===n?.type?(n.raw+="\n"+s.raw,n.text+="\n"+s.text,this.inlineQueue.at(-1).src=n.text):t.push(s);continue}if(s=this.tokenizer.fences(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.heading(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.hr(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.blockquote(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.list(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.html(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.def(e)){e=e.substring(s.raw.length);const n=t.at(-1);"paragraph"===n?.type||"text"===n?.type?(n.raw+="\n"+s.raw,n.text+="\n"+s.raw,this.inlineQueue.at(-1).src=n.text):this.tokens.links[s.tag]||(this.tokens.links[s.tag]={href:s.href,title:s.title});continue}if(s=this.tokenizer.table(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.lheading(e)){e=e.substring(s.raw.length),t.push(s);continue}let r=e;if(this.options.extensions?.startBlock){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startBlock.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(r=e.substring(0,t+1))}if(this.state.top&&(s=this.tokenizer.paragraph(r))){const i=t.at(-1);n&&"paragraph"===i?.type?(i.raw+="\n"+s.raw,i.text+="\n"+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=i.text):t.push(s),n=r.length!==e.length,e=e.substring(s.raw.length)}else if(s=this.tokenizer.text(e)){e=e.substring(s.raw.length);const n=t.at(-1);"text"===n?.type?(n.raw+="\n"+s.raw,n.text+="\n"+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=n.text):t.push(s)}else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let n=e,s=null;if(this.tokens.links){const e=Object.keys(this.tokens.links);if(e.length>0)for(;null!=(s=this.tokenizer.rules.inline.reflinkSearch.exec(n));)e.includes(s[0].slice(s[0].lastIndexOf("[")+1,-1))&&(n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;null!=(s=this.tokenizer.rules.inline.blockSkip.exec(n));)n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);for(;null!=(s=this.tokenizer.rules.inline.anyPunctuation.exec(n));)n=n.slice(0,s.index)+"++"+n.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);let r=!1,i="";for(;e;){let s;if(r||(i=""),r=!1,this.options.extensions?.inline?.some((n=>!!(s=n.call({lexer:this},e,t))&&(e=e.substring(s.raw.length),t.push(s),!0))))continue;if(s=this.tokenizer.escape(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.tag(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.link(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(s.raw.length);const n=t.at(-1);"text"===s.type&&"text"===n?.type?(n.raw+=s.raw,n.text+=s.text):t.push(s);continue}if(s=this.tokenizer.emStrong(e,n,i)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.codespan(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.br(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.del(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.autolink(e)){e=e.substring(s.raw.length),t.push(s);continue}if(!this.state.inLink&&(s=this.tokenizer.url(e))){e=e.substring(s.raw.length),t.push(s);continue}let l=e;if(this.options.extensions?.startInline){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startInline.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(l=e.substring(0,t+1))}if(s=this.tokenizer.inlineText(l)){e=e.substring(s.raw.length),"_"!==s.raw.slice(-1)&&(i=s.raw.slice(-1)),r=!0;const n=t.at(-1);"text"===n?.type?(n.raw+=s.raw,n.text+=s.text):t.push(s)}else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}return t}}class se{options;parser;constructor(t){this.options=t||e.defaults}space(e){return""}code({text:e,lang:t,escaped:n}){const s=(t||"").match(i.notSpaceStart)?.[0],r=e.replace(i.endingNewline,"")+"\n";return s?'
'+(n?r:K(r,!0))+"
\n":"
"+(n?r:K(r,!0))+"
\n"}blockquote({tokens:e}){return`
\n${this.parser.parse(e)}
\n`}html({text:e}){return e}heading({tokens:e,depth:t}){return`${this.parser.parseInline(e)}\n`}hr(e){return"
\n"}list(e){const t=e.ordered,n=e.start;let s="";for(let t=0;t\n"+s+"\n"}listitem(e){let t="";if(e.task){const n=this.checkbox({checked:!!e.checked});e.loose?"paragraph"===e.tokens[0]?.type?(e.tokens[0].text=n+" "+e.tokens[0].text,e.tokens[0].tokens&&e.tokens[0].tokens.length>0&&"text"===e.tokens[0].tokens[0].type&&(e.tokens[0].tokens[0].text=n+" "+K(e.tokens[0].tokens[0].text),e.tokens[0].tokens[0].escaped=!0)):e.tokens.unshift({type:"text",raw:n+" ",text:n+" ",escaped:!0}):t+=n+" "}return t+=this.parser.parse(e.tokens,!!e.loose),`
  • ${t}
  • \n`}checkbox({checked:e}){return"'}paragraph({tokens:e}){return`

    ${this.parser.parseInline(e)}

    \n`}table(e){let t="",n="";for(let t=0;t${s}`),"\n\n"+t+"\n"+s+"
    \n"}tablerow({text:e}){return`\n${e}\n`}tablecell(e){const t=this.parser.parseInline(e.tokens),n=e.header?"th":"td";return(e.align?`<${n} align="${e.align}">`:`<${n}>`)+t+`\n`}strong({tokens:e}){return`${this.parser.parseInline(e)}`}em({tokens:e}){return`${this.parser.parseInline(e)}`}codespan({text:e}){return`${K(e,!0)}`}br(e){return"
    "}del({tokens:e}){return`${this.parser.parseInline(e)}`}link({href:e,title:t,tokens:n}){const s=this.parser.parseInline(n),r=V(e);if(null===r)return s;let i='
    ",i}image({href:e,title:t,text:n}){const s=V(e);if(null===s)return K(n);let r=`${n}{const r=e[s].flat(1/0);n=n.concat(this.walkTokens(r,t))})):e.tokens&&(n=n.concat(this.walkTokens(e.tokens,t)))}}return n}use(...e){const t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach((e=>{const n={...e};if(n.async=this.defaults.async||n.async||!1,e.extensions&&(e.extensions.forEach((e=>{if(!e.name)throw new Error("extension name required");if("renderer"in e){const n=t.renderers[e.name];t.renderers[e.name]=n?function(...t){let s=e.renderer.apply(this,t);return!1===s&&(s=n.apply(this,t)),s}:e.renderer}if("tokenizer"in e){if(!e.level||"block"!==e.level&&"inline"!==e.level)throw new Error("extension level must be 'block' or 'inline'");const n=t[e.level];n?n.unshift(e.tokenizer):t[e.level]=[e.tokenizer],e.start&&("block"===e.level?t.startBlock?t.startBlock.push(e.start):t.startBlock=[e.start]:"inline"===e.level&&(t.startInline?t.startInline.push(e.start):t.startInline=[e.start]))}"childTokens"in e&&e.childTokens&&(t.childTokens[e.name]=e.childTokens)})),n.extensions=t),e.renderer){const t=this.defaults.renderer||new se(this.defaults);for(const n in e.renderer){if(!(n in t))throw new Error(`renderer '${n}' does not exist`);if(["options","parser"].includes(n))continue;const s=n,r=e.renderer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n||""}}n.renderer=t}if(e.tokenizer){const t=this.defaults.tokenizer||new te(this.defaults);for(const n in e.tokenizer){if(!(n in t))throw new Error(`tokenizer '${n}' does not exist`);if(["options","rules","lexer"].includes(n))continue;const s=n,r=e.tokenizer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.tokenizer=t}if(e.hooks){const t=this.defaults.hooks||new le;for(const n in e.hooks){if(!(n in t))throw new Error(`hook '${n}' does not exist`);if(["options","block"].includes(n))continue;const s=n,r=e.hooks[s],i=t[s];le.passThroughHooks.has(n)?t[s]=e=>{if(this.defaults.async)return Promise.resolve(r.call(t,e)).then((e=>i.call(t,e)));const n=r.call(t,e);return i.call(t,n)}:t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.hooks=t}if(e.walkTokens){const t=this.defaults.walkTokens,s=e.walkTokens;n.walkTokens=function(e){let n=[];return n.push(s.call(this,e)),t&&(n=n.concat(t.call(this,e))),n}}this.defaults={...this.defaults,...n}})),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return ne.lex(e,t??this.defaults)}parser(e,t){return ie.parse(e,t??this.defaults)}parseMarkdown(e){return(t,n)=>{const s={...n},r={...this.defaults,...s},i=this.onError(!!r.silent,!!r.async);if(!0===this.defaults.async&&!1===s.async)return i(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(null==t)return i(new Error("marked(): input parameter is undefined or null"));if("string"!=typeof t)return i(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(t)+", string expected"));r.hooks&&(r.hooks.options=r,r.hooks.block=e);const l=r.hooks?r.hooks.provideLexer():e?ne.lex:ne.lexInline,o=r.hooks?r.hooks.provideParser():e?ie.parse:ie.parseInline;if(r.async)return Promise.resolve(r.hooks?r.hooks.preprocess(t):t).then((e=>l(e,r))).then((e=>r.hooks?r.hooks.processAllTokens(e):e)).then((e=>r.walkTokens?Promise.all(this.walkTokens(e,r.walkTokens)).then((()=>e)):e)).then((e=>o(e,r))).then((e=>r.hooks?r.hooks.postprocess(e):e)).catch(i);try{r.hooks&&(t=r.hooks.preprocess(t));let e=l(t,r);r.hooks&&(e=r.hooks.processAllTokens(e)),r.walkTokens&&this.walkTokens(e,r.walkTokens);let n=o(e,r);return r.hooks&&(n=r.hooks.postprocess(n)),n}catch(e){return i(e)}}}onError(e,t){return n=>{if(n.message+="\nPlease report this to https://github.com/markedjs/marked.",e){const e="

    An error occurred:

    "+K(n.message+"",!0)+"
    ";return t?Promise.resolve(e):e}if(t)return Promise.reject(n);throw n}}}const ae=new oe;function ce(e,t){return ae.parse(e,t)}ce.options=ce.setOptions=function(e){return ae.setOptions(e),ce.defaults=ae.defaults,n(ce.defaults),ce},ce.getDefaults=t,ce.defaults=e.defaults,ce.use=function(...e){return ae.use(...e),ce.defaults=ae.defaults,n(ce.defaults),ce},ce.walkTokens=function(e,t){return ae.walkTokens(e,t)},ce.parseInline=ae.parseInline,ce.Parser=ie,ce.parser=ie.parse,ce.Renderer=se,ce.TextRenderer=re,ce.Lexer=ne,ce.lexer=ne.lex,ce.Tokenizer=te,ce.Hooks=le,ce.parse=ce;const he=ce.options,pe=ce.setOptions,ue=ce.use,ge=ce.walkTokens,ke=ce.parseInline,de=ce,fe=ie.parse,xe=ne.lex;e.Hooks=le,e.Lexer=ne,e.Marked=oe,e.Parser=ie,e.Renderer=se,e.TextRenderer=re,e.Tokenizer=te,e.getDefaults=t,e.lexer=xe,e.marked=ce,e.options=he,e.parse=de,e.parseInline=ke,e.parser=fe,e.setOptions=pe,e.use=ue,e.walkTokens=ge})); diff --git a/app/templates/base.html b/app/templates/base.html deleted file mode 100644 index a7ee4d2..0000000 --- a/app/templates/base.html +++ /dev/null @@ -1,64 +0,0 @@ - - - - - - - {% block title %}Browser History{% endblock %} - - - - - - -
    - -
    - {% block content %}{% endblock %} -
    - - - \ No newline at end of file diff --git a/app/templates/bookmarks.html b/app/templates/bookmarks.html deleted file mode 100644 index f33f989..0000000 --- a/app/templates/bookmarks.html +++ /dev/null @@ -1,32 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Browser History - Bookmarks{% endblock %} - -{% block content %} -
    -
    -

    Bookmarks

    -
    -
    - -
    -
    -{% endblock %} \ No newline at end of file diff --git a/app/templates/index.html b/app/templates/index.html deleted file mode 100644 index b0f5e9a..0000000 --- a/app/templates/index.html +++ /dev/null @@ -1,29 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Browser History - Home{% endblock %} - -{% block content %} -
    -
    -

    Recent History

    -
    -
    - -
    -
    -{% endblock %} \ No newline at end of file diff --git a/app/templates/search.html b/app/templates/search.html deleted file mode 100644 index 1aca9e5..0000000 --- a/app/templates/search.html +++ /dev/null @@ -1,154 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Browser History - Search{% endblock %} - -{% block content %} -
    -
    -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    -
    - -
    -
    - -
    -
    -
      - -
    -
    -
    -
    - - - - - -{% endblock %} \ No newline at end of file diff --git a/app/utils.py b/app/utils.py deleted file mode 100644 index 2072194..0000000 --- a/app/utils.py +++ /dev/null @@ -1,45 +0,0 @@ -from datetime import datetime -from .database import HistoryEntry, Bookmark - -def serialize_history_entry(entry, include_content: bool = False): - """Serialize a HistoryEntry object or raw SQL result to a dictionary""" - # Handle both ORM objects and raw SQL results - if hasattr(entry, '_mapping'): # Raw SQL result (from execute) - result = { - "id": entry.id, - "url": entry.url, - "title": entry.title, - "visit_time": entry.visit_time.isoformat() if isinstance(entry.visit_time, datetime) else entry.visit_time, - "domain": entry.domain, - # Add potential highlight fields if they exist - "title_highlight": getattr(entry, 'title_highlight', None), - "content_highlight": getattr(entry, 'content_highlight', None), - "rank": getattr(entry, 'rank', None) - } - if include_content: - # Ensure markdown_content exists before accessing - result["markdown_content"] = getattr(entry, 'markdown_content', None) - - else: # ORM object (from query) - result = { - "id": entry.id, - "url": entry.url, - "title": entry.title, - "visit_time": entry.visit_time.isoformat() if entry.visit_time else None, - "domain": entry.domain, - } - if include_content: - result["markdown_content"] = entry.markdown_content - - return result - -def serialize_bookmark(bookmark): - """Serialize a Bookmark object to a dictionary""" - return { - "id": bookmark.id, - "url": bookmark.url, - "title": bookmark.title, - "added_time": bookmark.added_time.isoformat() if bookmark.added_time else None, - "folder": bookmark.folder, - "domain": bookmark.domain, - } \ No newline at end of file diff --git a/app/websocket_server.py b/app/websocket_server.py deleted file mode 100644 index cf853ef..0000000 --- a/app/websocket_server.py +++ /dev/null @@ -1,33 +0,0 @@ -import asyncio -import websockets -import json -from page_info import PageInfo -from datetime import datetime - -async def handle_websocket(websocket, path): - try: - async for message in websocket: - data = json.loads(message) - page_info = PageInfo( - url=data['url'], - html=data['html'], - timestamp=datetime.fromisoformat(data['timestamp']) - ) - print(f"Received page content from: {page_info.url}") - # Here you can process the page_info object as needed - - except websockets.exceptions.ConnectionClosed: - print("Client disconnected") - except Exception as e: - print(f"Error handling message: {e}") - -async def start_server(): - server = await websockets.serve(handle_websocket, "localhost", 8765) - print("WebSocket server started on ws://localhost:8765") - await server.wait_closed() - -def run_server(): - asyncio.run(start_server()) - -if __name__ == "__main__": - run_server() \ No newline at end of file diff --git a/browser-recall.service b/browser-recall.service deleted file mode 100644 index 82c60d4..0000000 --- a/browser-recall.service +++ /dev/null @@ -1,14 +0,0 @@ -[Unit] -Description=Browser Recall Service -After=network.target - -[Service] -Type=simple -User=zetaphor -WorkingDirectory=/home/zetaphor/browser-recall -ExecStart=/usr/bin/python3 /home/zetaphor/Code/browser-recall/main.py -Restart=always -RestartSec=10 - -[Install] -WantedBy=multi-user.target \ No newline at end of file diff --git a/database.py b/database.py new file mode 100644 index 0000000..995fb72 --- /dev/null +++ b/database.py @@ -0,0 +1,110 @@ +import sqlite3 +from datetime import datetime +from typing import Optional, List, Dict +import threading + +class Database: + _instance = None + _lock = threading.Lock() + + def __new__(cls): + with cls._lock: + if cls._instance is None: + cls._instance = super(Database, cls).__new__(cls) + cls._instance._initialize_db() + return cls._instance + + def _initialize_db(self): + """Initialize the database connection and create tables if they don't exist.""" + self.conn = sqlite3.connect('history.db', check_same_thread=False) + self.conn.row_factory = sqlite3.Row + + try: + # Set WAL mode first, before any other operations + self.conn.execute('PRAGMA journal_mode=WAL') + + # Other performance and reliability optimizations + self.conn.execute('PRAGMA synchronous=NORMAL') # Balance between safety and speed + self.conn.execute('PRAGMA temp_store=MEMORY') # Store temp tables and indices in memory + self.conn.execute('PRAGMA cache_size=-64000') # Use 64MB of memory for page cache + self.conn.execute('PRAGMA foreign_keys=ON') # Enable foreign key constraints + except Exception as e: + print(f"Error setting database PRAGMA options: {e}") + # Optionally re-raise the exception if you want to halt execution + raise + + self.cursor = self.conn.cursor() + + # Create history table + self.cursor.execute(''' + CREATE TABLE IF NOT EXISTS history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT NOT NULL, + title TEXT NOT NULL, + content TEXT NOT NULL, + created TIMESTAMP NOT NULL, + updated TIMESTAMP NOT NULL + ) + ''') + self.conn.commit() + + def add_history(self, url: str, title: str, content: str) -> int: + """Add a new history entry.""" + now = datetime.utcnow() + with self._lock: + self.cursor.execute(''' + INSERT INTO history (url, title, content, created, updated) + VALUES (?, ?, ?, ?, ?) + ''', (url, title, content, now, now)) + self.conn.commit() + return self.cursor.lastrowid + + def get_history(self, limit: int = 100) -> List[Dict]: + """Get history entries, ordered by most recent first.""" + self.cursor.execute(''' + SELECT * FROM history + ORDER BY created DESC + LIMIT ? + ''', (limit,)) + return [dict(row) for row in self.cursor.fetchall()] + + def update_history(self, id: int, title: Optional[str] = None, + content: Optional[str] = None) -> bool: + """Update an existing history entry.""" + update_fields = [] + values = [] + + if title is not None: + update_fields.append("title = ?") + values.append(title) + if content is not None: + update_fields.append("content = ?") + values.append(content) + + if not update_fields: + return False + + update_fields.append("updated = ?") + values.append(datetime.utcnow()) + values.append(id) + + with self._lock: + self.cursor.execute(f''' + UPDATE history + SET {", ".join(update_fields)} + WHERE id = ? + ''', values) + self.conn.commit() + return self.cursor.rowcount > 0 + + def delete_history(self, id: int) -> bool: + """Delete a history entry.""" + with self._lock: + self.cursor.execute('DELETE FROM history WHERE id = ?', (id,)) + self.conn.commit() + return self.cursor.rowcount > 0 + + def __del__(self): + """Cleanup database connection.""" + if hasattr(self, 'conn'): + self.conn.close() \ No newline at end of file diff --git a/extension/background.js b/extension/background.js index 030187c..0b65877 100644 --- a/extension/background.js +++ b/extension/background.js @@ -47,13 +47,8 @@ class WebSocketClient { } tryReconnect() { - if (this.reconnectAttempts < this.maxReconnectAttempts) { - this.reconnectAttempts++; - console.log(`Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`); - setTimeout(() => this.connect(), 2000 * this.reconnectAttempts); - } else { - console.log('Max reconnection attempts reached'); - } + console.log(`Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`); + setTimeout(() => this.connect(), 2000 * this.reconnectAttempts); } sendMessage(data) { @@ -77,56 +72,10 @@ class WebSocketClient { const wsClient = new WebSocketClient(); -async function isContentScriptReady(tabId) { - try { - await browser.tabs.sendMessage(tabId, { type: "PING" }); - return true; - } catch (error) { - return false; - } -} - -async function waitForContentScript(tabId, maxAttempts = 10) { - console.log(`Waiting for content script in tab ${tabId}`); - for (let i = 0; i < maxAttempts; i++) { - if (await isContentScriptReady(tabId)) { - console.log(`Content script ready in tab ${tabId}`); - return true; - } - console.log(`Attempt ${i + 1}: Content script not ready, waiting...`); - await new Promise(resolve => setTimeout(resolve, 500)); - } - console.log(`Content script not ready after ${maxAttempts} attempts`); - return false; -} - -async function sendMessageToTab(tabId) { - try { - console.log(`Checking content script status for tab ${tabId}`); - if (await waitForContentScript(tabId)) { - console.log(`Sending GET_PAGE_CONTENT message to tab ${tabId}`); - await browser.tabs.sendMessage(tabId, { - type: "GET_PAGE_CONTENT" - }); - console.log(`Successfully sent message to tab ${tabId}`); - } - } catch (error) { - console.error(`Error sending message to tab ${tabId}:`, error); - } -} - // Listen for messages from content scripts browser.runtime.onMessage.addListener((message, sender) => { - if (message.type === "SEND_PAGE_CONTENT") { - console.log('Received page content from tab:', sender.tab.id); + if (message.type === "SEND_PAGE_URL") { + console.log('Received page url from tab:', sender.tab.id); wsClient.sendMessage(message.data); } -}); - -browser.webNavigation.onCompleted.addListener(async (details) => { - console.log("Navigation completed", details); - if (details.frameId === 0) { - console.log(`Main frame navigation detected for tab ${details.tabId}`); - await sendMessageToTab(details.tabId); - } }); \ No newline at end of file diff --git a/extension/content.js b/extension/content.js index cb8da49..03c8e03 100644 --- a/extension/content.js +++ b/extension/content.js @@ -3,12 +3,11 @@ console.log("Content script starting initialization..."); function sendPageContent() { const pageContent = { url: window.location.href, - html: document.documentElement.outerHTML, timestamp: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z') }; browser.runtime.sendMessage({ - type: "SEND_PAGE_CONTENT", + type: "SEND_PAGE_URL", data: pageContent }); } @@ -19,10 +18,6 @@ browser.runtime.onMessage.addListener((message, sender, sendResponse) => { return Promise.resolve({ status: "ready" }); } - if (message.type === "GET_PAGE_CONTENT") { - sendPageContent(); - } - return true; }); diff --git a/logger.py b/logger.py new file mode 100644 index 0000000..6a1e0c3 --- /dev/null +++ b/logger.py @@ -0,0 +1,36 @@ +import logging +from datetime import datetime +from typing import Optional + +class Logger: + _instance: Optional['Logger'] = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialize() + return cls._instance + + def _initialize(self): + # Configure logging + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler(f'logs/main_{datetime.now().strftime("%Y%m%d")}.log'), + logging.StreamHandler() + ] + ) + self.logger = logging.getLogger(__name__) + + def info(self, message: str): + self.logger.info(message) + + def error(self, message: str): + self.logger.error(message) + + def warning(self, message: str): + self.logger.warning(message) + + def debug(self, message: str): + self.logger.debug(message) \ No newline at end of file diff --git a/main.py b/main.py index 59c8c6d..3e947a1 100644 --- a/main.py +++ b/main.py @@ -1,15 +1,64 @@ +from fastapi import FastAPI, WebSocket import uvicorn +from logger import Logger import os -import sys +from database import Database +from crawl4ai import AsyncWebCrawler -# Add the app directory to the Python path -sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +# Create logs directory if it doesn't exist +os.makedirs('logs', exist_ok=True) + +app = FastAPI() +logger = Logger() + +db = Database() + +@app.websocket("/ws") +async def websocket_endpoint(websocket: WebSocket): + await websocket.accept() + logger.info("New WebSocket connection established") + + # Create crawler instance outside the loop for reuse + async with AsyncWebCrawler() as crawler: + try: + while True: + data = await websocket.receive_json() + + # Crawl the URL to get title and content + try: + result = await crawler.arun(url=data["url"]) + # Get the first result from the container and access metadata + crawl_result = result[0] + title = crawl_result.metadata.get('title') or data["url"].split("/")[-1] + content = crawl_result.markdown + logger.info(f"Crawling result: {result}") + except Exception as crawl_error: + logger.error(f"Crawling error for {data['url']}: {str(crawl_error)}") + title = data["url"].split("/")[-1] + content = str(data) + + # Store received data with crawled information + db.add_history( + url=data["url"], + title=title, + content=content + ) + + logger.info(f"Processed URL: {data['url']} - {title}") + await websocket.send_json({ + "status": "received", + "data": { + "url": data["url"], + "title": title, + "timestamp": data["timestamp"] + } + }) + except Exception as e: + logger.error(f"WebSocket error: {str(e)}") + await websocket.close() + finally: + logger.info("WebSocket connection closed") if __name__ == "__main__": - # Run the FastAPI application using uvicorn - uvicorn.run( - "app.main:app", - host="0.0.0.0", # Allows external access - port=8523, - reload=True # Enable auto-reload during development - ) \ No newline at end of file + logger.info("Starting WebSocket server...") + uvicorn.run(app, host="0.0.0.0", port=8523) diff --git a/pyproject.toml b/pyproject.toml index 61342a7..a31ac86 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,14 +7,6 @@ requires-python = ">=3.10.16" dependencies = [ "crawl4ai", "fastapi", - "sqlalchemy", - "uvicorn", - "pytz", - "aiofiles", - "websockets", - "pyyaml", - "browser-history", - "pydantic", - "pydantic-settings", - "iso8601", + "uvicorn[standard]", + "crawl4ai", ] diff --git a/run-browser-recall.fish b/run-browser-recall.fish index 9817fdd..769e6e6 100755 --- a/run-browser-recall.fish +++ b/run-browser-recall.fish @@ -4,7 +4,7 @@ cd (dirname (status filename)) # Activate the virtual environment and run main.py silently -vf activate general +source ./venv/bin/activate python main.py > /dev/null 2>&1 & # Print a simple confirmation message diff --git a/uv.lock b/uv.lock index 022ddc8..e3d6f9b 100644 --- a/uv.lock +++ b/uv.lock @@ -180,48 +180,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/49/6abb616eb3cbab6a7cca303dc02fdf3836de2e0b834bf966a7f5271a34d8/beautifulsoup4-4.13.3-py3-none-any.whl", hash = "sha256:99045d7d3f08f91f0d656bc9b7efbae189426cd913d830294a15eefa0ea4df16", size = 186015 }, ] -[[package]] -name = "browser-history" -version = "0.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/60/22/76d11c62a41f6deec0c176632dc0942f4cf250512c5fb8313af252fa7841/browser_history-0.4.1.tar.gz", hash = "sha256:21f2dd03127f835062ebd4852aeff80dc4afee25676eb08245c23fe94539aa5b", size = 30439 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/61/1b/127acd362c3a2ca23d2f9e4e46813e9fb7f36668d13e349b04b8bb5bd04d/browser_history-0.4.1-py3-none-any.whl", hash = "sha256:4fad5e59121f2d2e7e55db8a9ca70a8a5a53791a2f2dc354feeb348c1422b1cb", size = 21848 }, -] - [[package]] name = "browser-recall" version = "0.1.0" source = { virtual = "." } dependencies = [ - { name = "aiofiles" }, - { name = "browser-history" }, { name = "crawl4ai" }, { name = "fastapi" }, - { name = "iso8601" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "pytz" }, - { name = "pyyaml" }, - { name = "sqlalchemy" }, - { name = "uvicorn" }, - { name = "websockets" }, + { name = "uvicorn", extra = ["standard"] }, ] [package.metadata] requires-dist = [ - { name = "aiofiles" }, - { name = "browser-history" }, { name = "crawl4ai" }, { name = "fastapi" }, - { name = "iso8601" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "pytz" }, - { name = "pyyaml" }, - { name = "sqlalchemy" }, - { name = "uvicorn" }, - { name = "websockets" }, + { name = "uvicorn", extras = ["standard"] }, ] [[package]] @@ -714,6 +687,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/8d/f052b1e336bb2c1fc7ed1aaed898aa570c0b61a09707b108979d9fc6e308/httpcore-1.0.8-py3-none-any.whl", hash = "sha256:5254cf149bcb5f75e9d1b2b9f729ea4a4b883d1ad7379fc632b727cec23674be", size = 78732 }, ] +[[package]] +name = "httptools" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/9a/ce5e1f7e131522e6d3426e8e7a490b3a01f39a6696602e1c4f33f9e94277/httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c", size = 240639 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/6f/972f8eb0ea7d98a1c6be436e2142d51ad2a64ee18e02b0e7ff1f62171ab1/httptools-0.6.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3c73ce323711a6ffb0d247dcd5a550b8babf0f757e86a52558fe5b86d6fefcc0", size = 198780 }, + { url = "https://files.pythonhosted.org/packages/6a/b0/17c672b4bc5c7ba7f201eada4e96c71d0a59fbc185e60e42580093a86f21/httptools-0.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:345c288418f0944a6fe67be8e6afa9262b18c7626c3ef3c28adc5eabc06a68da", size = 103297 }, + { url = "https://files.pythonhosted.org/packages/92/5e/b4a826fe91971a0b68e8c2bd4e7db3e7519882f5a8ccdb1194be2b3ab98f/httptools-0.6.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:deee0e3343f98ee8047e9f4c5bc7cedbf69f5734454a94c38ee829fb2d5fa3c1", size = 443130 }, + { url = "https://files.pythonhosted.org/packages/b0/51/ce61e531e40289a681a463e1258fa1e05e0be54540e40d91d065a264cd8f/httptools-0.6.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca80b7485c76f768a3bc83ea58373f8db7b015551117375e4918e2aa77ea9b50", size = 442148 }, + { url = "https://files.pythonhosted.org/packages/ea/9e/270b7d767849b0c96f275c695d27ca76c30671f8eb8cc1bab6ced5c5e1d0/httptools-0.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:90d96a385fa941283ebd231464045187a31ad932ebfa541be8edf5b3c2328959", size = 415949 }, + { url = "https://files.pythonhosted.org/packages/81/86/ced96e3179c48c6f656354e106934e65c8963d48b69be78f355797f0e1b3/httptools-0.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:59e724f8b332319e2875efd360e61ac07f33b492889284a3e05e6d13746876f4", size = 417591 }, + { url = "https://files.pythonhosted.org/packages/75/73/187a3f620ed3175364ddb56847d7a608a6fc42d551e133197098c0143eca/httptools-0.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:c26f313951f6e26147833fc923f78f95604bbec812a43e5ee37f26dc9e5a686c", size = 88344 }, + { url = "https://files.pythonhosted.org/packages/7b/26/bb526d4d14c2774fe07113ca1db7255737ffbb119315839af2065abfdac3/httptools-0.6.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f47f8ed67cc0ff862b84a1189831d1d33c963fb3ce1ee0c65d3b0cbe7b711069", size = 199029 }, + { url = "https://files.pythonhosted.org/packages/a6/17/3e0d3e9b901c732987a45f4f94d4e2c62b89a041d93db89eafb262afd8d5/httptools-0.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0614154d5454c21b6410fdf5262b4a3ddb0f53f1e1721cfd59d55f32138c578a", size = 103492 }, + { url = "https://files.pythonhosted.org/packages/b7/24/0fe235d7b69c42423c7698d086d4db96475f9b50b6ad26a718ef27a0bce6/httptools-0.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8787367fbdfccae38e35abf7641dafc5310310a5987b689f4c32cc8cc3ee975", size = 462891 }, + { url = "https://files.pythonhosted.org/packages/b1/2f/205d1f2a190b72da6ffb5f41a3736c26d6fa7871101212b15e9b5cd8f61d/httptools-0.6.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40b0f7fe4fd38e6a507bdb751db0379df1e99120c65fbdc8ee6c1d044897a636", size = 459788 }, + { url = "https://files.pythonhosted.org/packages/6e/4c/d09ce0eff09057a206a74575ae8f1e1e2f0364d20e2442224f9e6612c8b9/httptools-0.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40a5ec98d3f49904b9fe36827dcf1aadfef3b89e2bd05b0e35e94f97c2b14721", size = 433214 }, + { url = "https://files.pythonhosted.org/packages/3e/d2/84c9e23edbccc4a4c6f96a1b8d99dfd2350289e94f00e9ccc7aadde26fb5/httptools-0.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dacdd3d10ea1b4ca9df97a0a303cbacafc04b5cd375fa98732678151643d4988", size = 434120 }, + { url = "https://files.pythonhosted.org/packages/d0/46/4d8e7ba9581416de1c425b8264e2cadd201eb709ec1584c381f3e98f51c1/httptools-0.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:288cd628406cc53f9a541cfaf06041b4c71d751856bab45e3702191f931ccd17", size = 88565 }, + { url = "https://files.pythonhosted.org/packages/bb/0e/d0b71465c66b9185f90a091ab36389a7352985fe857e352801c39d6127c8/httptools-0.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df017d6c780287d5c80601dafa31f17bddb170232d85c066604d8558683711a2", size = 200683 }, + { url = "https://files.pythonhosted.org/packages/e2/b8/412a9bb28d0a8988de3296e01efa0bd62068b33856cdda47fe1b5e890954/httptools-0.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:85071a1e8c2d051b507161f6c3e26155b5c790e4e28d7f236422dbacc2a9cc44", size = 104337 }, + { url = "https://files.pythonhosted.org/packages/9b/01/6fb20be3196ffdc8eeec4e653bc2a275eca7f36634c86302242c4fbb2760/httptools-0.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69422b7f458c5af875922cdb5bd586cc1f1033295aa9ff63ee196a87519ac8e1", size = 508796 }, + { url = "https://files.pythonhosted.org/packages/f7/d8/b644c44acc1368938317d76ac991c9bba1166311880bcc0ac297cb9d6bd7/httptools-0.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16e603a3bff50db08cd578d54f07032ca1631450ceb972c2f834c2b860c28ea2", size = 510837 }, + { url = "https://files.pythonhosted.org/packages/52/d8/254d16a31d543073a0e57f1c329ca7378d8924e7e292eda72d0064987486/httptools-0.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec4f178901fa1834d4a060320d2f3abc5c9e39766953d038f1458cb885f47e81", size = 485289 }, + { url = "https://files.pythonhosted.org/packages/5f/3c/4aee161b4b7a971660b8be71a92c24d6c64372c1ab3ae7f366b3680df20f/httptools-0.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb89ecf8b290f2e293325c646a211ff1c2493222798bb80a530c5e7502494f", size = 489779 }, + { url = "https://files.pythonhosted.org/packages/12/b7/5cae71a8868e555f3f67a50ee7f673ce36eac970f029c0c5e9d584352961/httptools-0.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:db78cb9ca56b59b016e64b6031eda5653be0589dba2b1b43453f6e8b405a0970", size = 88634 }, + { url = "https://files.pythonhosted.org/packages/94/a3/9fe9ad23fd35f7de6b91eeb60848986058bd8b5a5c1e256f5860a160cc3e/httptools-0.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ade273d7e767d5fae13fa637f4d53b6e961fb7fd93c7797562663f0171c26660", size = 197214 }, + { url = "https://files.pythonhosted.org/packages/ea/d9/82d5e68bab783b632023f2fa31db20bebb4e89dfc4d2293945fd68484ee4/httptools-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:856f4bc0478ae143bad54a4242fccb1f3f86a6e1be5548fecfd4102061b3a083", size = 102431 }, + { url = "https://files.pythonhosted.org/packages/96/c1/cb499655cbdbfb57b577734fde02f6fa0bbc3fe9fb4d87b742b512908dff/httptools-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:322d20ea9cdd1fa98bd6a74b77e2ec5b818abdc3d36695ab402a0de8ef2865a3", size = 473121 }, + { url = "https://files.pythonhosted.org/packages/af/71/ee32fd358f8a3bb199b03261f10921716990808a675d8160b5383487a317/httptools-0.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d87b29bd4486c0093fc64dea80231f7c7f7eb4dc70ae394d70a495ab8436071", size = 473805 }, + { url = "https://files.pythonhosted.org/packages/8a/0a/0d4df132bfca1507114198b766f1737d57580c9ad1cf93c1ff673e3387be/httptools-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:342dd6946aa6bda4b8f18c734576106b8a31f2fe31492881a9a160ec84ff4bd5", size = 448858 }, + { url = "https://files.pythonhosted.org/packages/1e/6a/787004fdef2cabea27bad1073bf6a33f2437b4dbd3b6fb4a9d71172b1c7c/httptools-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b36913ba52008249223042dca46e69967985fb4051951f94357ea681e1f5dc0", size = 452042 }, + { url = "https://files.pythonhosted.org/packages/4d/dc/7decab5c404d1d2cdc1bb330b1bf70e83d6af0396fd4fc76fc60c0d522bf/httptools-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:28908df1b9bb8187393d5b5db91435ccc9c8e891657f9cbb42a2541b44c82fc8", size = 87682 }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -777,15 +786,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/9d/0fb148dc4d6fa4a7dd1d8378168d9b4cd8d4560a6fbf6f0121c5fc34eb68/importlib_metadata-8.6.1-py3-none-any.whl", hash = "sha256:02a89390c1e15fdfdc0d7c6b25cb3e62650d0494005c97d6f148bf5b9787525e", size = 26971 }, ] -[[package]] -name = "iso8601" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b9/f3/ef59cee614d5e0accf6fd0cbba025b93b272e626ca89fb70a3e9187c5d15/iso8601-2.1.0.tar.gz", hash = "sha256:6b1d3829ee8921c4301998c909f7829fa9ed3cbdac0d3b16af2d743aed1ba8df", size = 6522 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/0c/f37b6a241f0759b7653ffa7213889d89ad49a2b76eb2ddf3b57b2738c347/iso8601-2.1.0-py3-none-any.whl", hash = "sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242", size = 7545 }, -] - [[package]] name = "jinja2" version = "3.1.6" @@ -1570,19 +1570,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/6f/5596dc418f2e292ffc661d21931ab34591952e2843e7168ea5a52591f6ff/pydantic_core-2.33.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f995719707e0e29f0f41a8aa3bcea6e761a36c9136104d3189eafb83f5cec5e5", size = 2080951 }, ] -[[package]] -name = "pydantic-settings" -version = "2.8.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/88/82/c79424d7d8c29b994fb01d277da57b0a9b09cc03c3ff875f9bd8a86b2145/pydantic_settings-2.8.1.tar.gz", hash = "sha256:d5c663dfbe9db9d5e1c646b2e161da12f0d734d422ee56f567d0ea2cee4e8585", size = 83550 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/53/a64f03044927dc47aafe029c42a5b7aabc38dfb813475e0e1bf71c4a59d0/pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c", size = 30839 }, -] - [[package]] name = "pyee" version = "12.1.1" @@ -1632,15 +1619,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256 }, ] -[[package]] -name = "pytz" -version = "2025.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225 }, -] - [[package]] name = "pyyaml" version = "6.0.2" @@ -1932,51 +1910,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/c2/fe97d779f3ef3b15f05c94a2f1e3d21732574ed441687474db9d342a7315/soupsieve-2.6-py3-none-any.whl", hash = "sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9", size = 36186 }, ] -[[package]] -name = "sqlalchemy" -version = "2.0.40" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/68/c3/3f2bfa5e4dcd9938405fe2fab5b6ab94a9248a4f9536ea2fd497da20525f/sqlalchemy-2.0.40.tar.gz", hash = "sha256:d827099289c64589418ebbcaead0145cd19f4e3e8a93919a0100247af245fa00", size = 9664299 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/87/fa/8e8fd93684b04e65816be864bebf0000fe1602e5452d006f9acc5db14ce5/sqlalchemy-2.0.40-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f1ea21bef99c703f44444ad29c2c1b6bd55d202750b6de8e06a955380f4725d7", size = 2112843 }, - { url = "https://files.pythonhosted.org/packages/ba/87/06992f78a9ce545dfd1fea3dd99262bec5221f6f9d2d2066c3e94662529f/sqlalchemy-2.0.40-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:afe63b208153f3a7a2d1a5b9df452b0673082588933e54e7c8aac457cf35e758", size = 2104032 }, - { url = "https://files.pythonhosted.org/packages/92/ee/57dc77282e8be22d686bd4681825299aa1069bbe090564868ea270ed5214/sqlalchemy-2.0.40-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a8aae085ea549a1eddbc9298b113cffb75e514eadbb542133dd2b99b5fb3b6af", size = 3086406 }, - { url = "https://files.pythonhosted.org/packages/94/3f/ceb9ab214b2e42d2e74a9209b3a2f2f073504eee16cddd2df81feeb67c2f/sqlalchemy-2.0.40-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ea9181284754d37db15156eb7be09c86e16e50fbe77610e9e7bee09291771a1", size = 3094652 }, - { url = "https://files.pythonhosted.org/packages/00/0a/3401232a5b6d91a2df16c1dc39c6504c54575744c2faafa1e5a50de96621/sqlalchemy-2.0.40-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5434223b795be5c5ef8244e5ac98056e290d3a99bdcc539b916e282b160dda00", size = 3050503 }, - { url = "https://files.pythonhosted.org/packages/93/c2/ea7171415ab131397f71a2673645c2fe29ebe9a93063d458eb89e42bf051/sqlalchemy-2.0.40-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:15d08d5ef1b779af6a0909b97be6c1fd4298057504eb6461be88bd1696cb438e", size = 3076011 }, - { url = "https://files.pythonhosted.org/packages/3d/ee/d8e229280d621bed8c51eebf1dd413aa09ca89e309b1fff40d881dd149af/sqlalchemy-2.0.40-cp310-cp310-win32.whl", hash = "sha256:cd2f75598ae70bcfca9117d9e51a3b06fe29edd972fdd7fd57cc97b4dbf3b08a", size = 2085136 }, - { url = "https://files.pythonhosted.org/packages/60/7f/ea1086136bc648cd4713a1e01869f7fc31979d67b3a8f973f5d9ab8de7e1/sqlalchemy-2.0.40-cp310-cp310-win_amd64.whl", hash = "sha256:2cbafc8d39ff1abdfdda96435f38fab141892dc759a2165947d1a8fffa7ef596", size = 2109421 }, - { url = "https://files.pythonhosted.org/packages/77/7e/55044a9ec48c3249bb38d5faae93f09579c35e862bb318ebd1ed7a1994a5/sqlalchemy-2.0.40-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f6bacab7514de6146a1976bc56e1545bee247242fab030b89e5f70336fc0003e", size = 2114025 }, - { url = "https://files.pythonhosted.org/packages/77/0f/dcf7bba95f847aec72f638750747b12d37914f71c8cc7c133cf326ab945c/sqlalchemy-2.0.40-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5654d1ac34e922b6c5711631f2da497d3a7bffd6f9f87ac23b35feea56098011", size = 2104419 }, - { url = "https://files.pythonhosted.org/packages/75/70/c86a5c20715e4fe903dde4c2fd44fc7e7a0d5fb52c1b954d98526f65a3ea/sqlalchemy-2.0.40-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35904d63412db21088739510216e9349e335f142ce4a04b69e2528020ee19ed4", size = 3222720 }, - { url = "https://files.pythonhosted.org/packages/12/cf/b891a8c1d0c27ce9163361664c2128c7a57de3f35000ea5202eb3a2917b7/sqlalchemy-2.0.40-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c7a80ed86d6aaacb8160a1caef6680d4ddd03c944d985aecee940d168c411d1", size = 3222682 }, - { url = "https://files.pythonhosted.org/packages/15/3f/7709d8c8266953d945435a96b7f425ae4172a336963756b58e996fbef7f3/sqlalchemy-2.0.40-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:519624685a51525ddaa7d8ba8265a1540442a2ec71476f0e75241eb8263d6f51", size = 3159542 }, - { url = "https://files.pythonhosted.org/packages/85/7e/717eaabaf0f80a0132dc2032ea8f745b7a0914451c984821a7c8737fb75a/sqlalchemy-2.0.40-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2ee5f9999a5b0e9689bed96e60ee53c3384f1a05c2dd8068cc2e8361b0df5b7a", size = 3179864 }, - { url = "https://files.pythonhosted.org/packages/e4/cc/03eb5dfcdb575cbecd2bd82487b9848f250a4b6ecfb4707e834b4ce4ec07/sqlalchemy-2.0.40-cp311-cp311-win32.whl", hash = "sha256:c0cae71e20e3c02c52f6b9e9722bca70e4a90a466d59477822739dc31ac18b4b", size = 2084675 }, - { url = "https://files.pythonhosted.org/packages/9a/48/440946bf9dc4dc231f4f31ef0d316f7135bf41d4b86aaba0c0655150d370/sqlalchemy-2.0.40-cp311-cp311-win_amd64.whl", hash = "sha256:574aea2c54d8f1dd1699449f332c7d9b71c339e04ae50163a3eb5ce4c4325ee4", size = 2110099 }, - { url = "https://files.pythonhosted.org/packages/92/06/552c1f92e880b57d8b92ce6619bd569b25cead492389b1d84904b55989d8/sqlalchemy-2.0.40-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9d3b31d0a1c44b74d3ae27a3de422dfccd2b8f0b75e51ecb2faa2bf65ab1ba0d", size = 2112620 }, - { url = "https://files.pythonhosted.org/packages/01/72/a5bc6e76c34cebc071f758161dbe1453de8815ae6e662393910d3be6d70d/sqlalchemy-2.0.40-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37f7a0f506cf78c80450ed1e816978643d3969f99c4ac6b01104a6fe95c5490a", size = 2103004 }, - { url = "https://files.pythonhosted.org/packages/bf/fd/0e96c8e6767618ed1a06e4d7a167fe13734c2f8113c4cb704443e6783038/sqlalchemy-2.0.40-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bb933a650323e476a2e4fbef8997a10d0003d4da996aad3fd7873e962fdde4d", size = 3252440 }, - { url = "https://files.pythonhosted.org/packages/cd/6a/eb82e45b15a64266a2917a6833b51a334ea3c1991728fd905bfccbf5cf63/sqlalchemy-2.0.40-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6959738971b4745eea16f818a2cd086fb35081383b078272c35ece2b07012716", size = 3263277 }, - { url = "https://files.pythonhosted.org/packages/45/97/ebe41ab4530f50af99e3995ebd4e0204bf1b0dc0930f32250dde19c389fe/sqlalchemy-2.0.40-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:110179728e442dae85dd39591beb74072ae4ad55a44eda2acc6ec98ead80d5f2", size = 3198591 }, - { url = "https://files.pythonhosted.org/packages/e6/1c/a569c1b2b2f5ac20ba6846a1321a2bf52e9a4061001f282bf1c5528dcd69/sqlalchemy-2.0.40-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e8040680eaacdce4d635f12c55c714f3d4c7f57da2bc47a01229d115bd319191", size = 3225199 }, - { url = "https://files.pythonhosted.org/packages/8f/91/87cc71a6b10065ca0209d19a4bb575378abda6085e72fa0b61ffb2201b84/sqlalchemy-2.0.40-cp312-cp312-win32.whl", hash = "sha256:650490653b110905c10adac69408380688cefc1f536a137d0d69aca1069dc1d1", size = 2082959 }, - { url = "https://files.pythonhosted.org/packages/2a/9f/14c511cda174aa1ad9b0e42b64ff5a71db35d08b0d80dc044dae958921e5/sqlalchemy-2.0.40-cp312-cp312-win_amd64.whl", hash = "sha256:2be94d75ee06548d2fc591a3513422b873490efb124048f50556369a834853b0", size = 2108526 }, - { url = "https://files.pythonhosted.org/packages/8c/18/4e3a86cc0232377bc48c373a9ba6a1b3fb79ba32dbb4eda0b357f5a2c59d/sqlalchemy-2.0.40-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:915866fd50dd868fdcc18d61d8258db1bf9ed7fbd6dfec960ba43365952f3b01", size = 2107887 }, - { url = "https://files.pythonhosted.org/packages/cb/60/9fa692b1d2ffc4cbd5f47753731fd332afed30137115d862d6e9a1e962c7/sqlalchemy-2.0.40-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4a4c5a2905a9ccdc67a8963e24abd2f7afcd4348829412483695c59e0af9a705", size = 2098367 }, - { url = "https://files.pythonhosted.org/packages/4c/9f/84b78357ca641714a439eb3fbbddb17297dacfa05d951dbf24f28d7b5c08/sqlalchemy-2.0.40-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55028d7a3ebdf7ace492fab9895cbc5270153f75442a0472d8516e03159ab364", size = 3184806 }, - { url = "https://files.pythonhosted.org/packages/4b/7d/e06164161b6bfce04c01bfa01518a20cccbd4100d5c951e5a7422189191a/sqlalchemy-2.0.40-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6cfedff6878b0e0d1d0a50666a817ecd85051d12d56b43d9d425455e608b5ba0", size = 3198131 }, - { url = "https://files.pythonhosted.org/packages/6d/51/354af20da42d7ec7b5c9de99edafbb7663a1d75686d1999ceb2c15811302/sqlalchemy-2.0.40-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bb19e30fdae77d357ce92192a3504579abe48a66877f476880238a962e5b96db", size = 3131364 }, - { url = "https://files.pythonhosted.org/packages/7a/2f/48a41ff4e6e10549d83fcc551ab85c268bde7c03cf77afb36303c6594d11/sqlalchemy-2.0.40-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:16d325ea898f74b26ffcd1cf8c593b0beed8714f0317df2bed0d8d1de05a8f26", size = 3159482 }, - { url = "https://files.pythonhosted.org/packages/33/ac/e5e0a807163652a35be878c0ad5cfd8b1d29605edcadfb5df3c512cdf9f3/sqlalchemy-2.0.40-cp313-cp313-win32.whl", hash = "sha256:a669cbe5be3c63f75bcbee0b266779706f1a54bcb1000f302685b87d1b8c1500", size = 2080704 }, - { url = "https://files.pythonhosted.org/packages/1c/cb/f38c61f7f2fd4d10494c1c135ff6a6ddb63508d0b47bccccd93670637309/sqlalchemy-2.0.40-cp313-cp313-win_amd64.whl", hash = "sha256:641ee2e0834812d657862f3a7de95e0048bdcb6c55496f39c6fa3d435f6ac6ad", size = 2104564 }, - { url = "https://files.pythonhosted.org/packages/d1/7c/5fc8e802e7506fe8b55a03a2e1dab156eae205c91bee46305755e086d2e2/sqlalchemy-2.0.40-py3-none-any.whl", hash = "sha256:32587e2e1e359276957e6fe5dad089758bc042a971a8a09ae8ecf7a8fe23d07a", size = 1903894 }, -] - [[package]] name = "starlette" version = "0.46.1" @@ -2119,6 +2052,114 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315 }, ] +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3", size = 2492741 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/76/44a55515e8c9505aa1420aebacf4dd82552e5e15691654894e90d0bd051a/uvloop-0.21.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ec7e6b09a6fdded42403182ab6b832b71f4edaf7f37a9a0e371a01db5f0cb45f", size = 1442019 }, + { url = "https://files.pythonhosted.org/packages/35/5a/62d5800358a78cc25c8a6c72ef8b10851bdb8cca22e14d9c74167b7f86da/uvloop-0.21.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:196274f2adb9689a289ad7d65700d37df0c0930fd8e4e743fa4834e850d7719d", size = 801898 }, + { url = "https://files.pythonhosted.org/packages/f3/96/63695e0ebd7da6c741ccd4489b5947394435e198a1382349c17b1146bb97/uvloop-0.21.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f38b2e090258d051d68a5b14d1da7203a3c3677321cf32a95a6f4db4dd8b6f26", size = 3827735 }, + { url = "https://files.pythonhosted.org/packages/61/e0/f0f8ec84979068ffae132c58c79af1de9cceeb664076beea86d941af1a30/uvloop-0.21.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87c43e0f13022b998eb9b973b5e97200c8b90823454d4bc06ab33829e09fb9bb", size = 3825126 }, + { url = "https://files.pythonhosted.org/packages/bf/fe/5e94a977d058a54a19df95f12f7161ab6e323ad49f4dabc28822eb2df7ea/uvloop-0.21.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:10d66943def5fcb6e7b37310eb6b5639fd2ccbc38df1177262b0640c3ca68c1f", size = 3705789 }, + { url = "https://files.pythonhosted.org/packages/26/dd/c7179618e46092a77e036650c1f056041a028a35c4d76945089fcfc38af8/uvloop-0.21.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:67dd654b8ca23aed0a8e99010b4c34aca62f4b7fce88f39d452ed7622c94845c", size = 3800523 }, + { url = "https://files.pythonhosted.org/packages/57/a7/4cf0334105c1160dd6819f3297f8700fda7fc30ab4f61fbf3e725acbc7cc/uvloop-0.21.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c0f3fa6200b3108919f8bdabb9a7f87f20e7097ea3c543754cabc7d717d95cf8", size = 1447410 }, + { url = "https://files.pythonhosted.org/packages/8c/7c/1517b0bbc2dbe784b563d6ab54f2ef88c890fdad77232c98ed490aa07132/uvloop-0.21.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0878c2640cf341b269b7e128b1a5fed890adc4455513ca710d77d5e93aa6d6a0", size = 805476 }, + { url = "https://files.pythonhosted.org/packages/ee/ea/0bfae1aceb82a503f358d8d2fa126ca9dbdb2ba9c7866974faec1cb5875c/uvloop-0.21.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9fb766bb57b7388745d8bcc53a359b116b8a04c83a2288069809d2b3466c37e", size = 3960855 }, + { url = "https://files.pythonhosted.org/packages/8a/ca/0864176a649838b838f36d44bf31c451597ab363b60dc9e09c9630619d41/uvloop-0.21.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a375441696e2eda1c43c44ccb66e04d61ceeffcd76e4929e527b7fa401b90fb", size = 3973185 }, + { url = "https://files.pythonhosted.org/packages/30/bf/08ad29979a936d63787ba47a540de2132169f140d54aa25bc8c3df3e67f4/uvloop-0.21.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:baa0e6291d91649c6ba4ed4b2f982f9fa165b5bbd50a9e203c416a2797bab3c6", size = 3820256 }, + { url = "https://files.pythonhosted.org/packages/da/e2/5cf6ef37e3daf2f06e651aae5ea108ad30df3cb269102678b61ebf1fdf42/uvloop-0.21.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4509360fcc4c3bd2c70d87573ad472de40c13387f5fda8cb58350a1d7475e58d", size = 3937323 }, + { url = "https://files.pythonhosted.org/packages/8c/4c/03f93178830dc7ce8b4cdee1d36770d2f5ebb6f3d37d354e061eefc73545/uvloop-0.21.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:359ec2c888397b9e592a889c4d72ba3d6befba8b2bb01743f72fffbde663b59c", size = 1471284 }, + { url = "https://files.pythonhosted.org/packages/43/3e/92c03f4d05e50f09251bd8b2b2b584a2a7f8fe600008bcc4523337abe676/uvloop-0.21.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7089d2dc73179ce5ac255bdf37c236a9f914b264825fdaacaded6990a7fb4c2", size = 821349 }, + { url = "https://files.pythonhosted.org/packages/a6/ef/a02ec5da49909dbbfb1fd205a9a1ac4e88ea92dcae885e7c961847cd51e2/uvloop-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa4dcdbd9ae0a372f2167a207cd98c9f9a1ea1188a8a526431eef2f8116cc8d", size = 4580089 }, + { url = "https://files.pythonhosted.org/packages/06/a7/b4e6a19925c900be9f98bec0a75e6e8f79bb53bdeb891916609ab3958967/uvloop-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86975dca1c773a2c9864f4c52c5a55631038e387b47eaf56210f873887b6c8dc", size = 4693770 }, + { url = "https://files.pythonhosted.org/packages/ce/0c/f07435a18a4b94ce6bd0677d8319cd3de61f3a9eeb1e5f8ab4e8b5edfcb3/uvloop-0.21.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:461d9ae6660fbbafedd07559c6a2e57cd553b34b0065b6550685f6653a98c1cb", size = 4451321 }, + { url = "https://files.pythonhosted.org/packages/8f/eb/f7032be105877bcf924709c97b1bf3b90255b4ec251f9340cef912559f28/uvloop-0.21.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:183aef7c8730e54c9a3ee3227464daed66e37ba13040bb3f350bc2ddc040f22f", size = 4659022 }, + { url = "https://files.pythonhosted.org/packages/3f/8d/2cbef610ca21539f0f36e2b34da49302029e7c9f09acef0b1c3b5839412b/uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281", size = 1468123 }, + { url = "https://files.pythonhosted.org/packages/93/0d/b0038d5a469f94ed8f2b2fce2434a18396d8fbfb5da85a0a9781ebbdec14/uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af", size = 819325 }, + { url = "https://files.pythonhosted.org/packages/50/94/0a687f39e78c4c1e02e3272c6b2ccdb4e0085fda3b8352fecd0410ccf915/uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6", size = 4582806 }, + { url = "https://files.pythonhosted.org/packages/d2/19/f5b78616566ea68edd42aacaf645adbf71fbd83fc52281fba555dc27e3f1/uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816", size = 4701068 }, + { url = "https://files.pythonhosted.org/packages/47/57/66f061ee118f413cd22a656de622925097170b9380b30091b78ea0c6ea75/uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc", size = 4454428 }, + { url = "https://files.pythonhosted.org/packages/63/9a/0962b05b308494e3202d3f794a6e85abe471fe3cafdbcf95c2e8c713aabd/uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553", size = 4660018 }, +] + +[[package]] +name = "watchfiles" +version = "1.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/e2/8ed598c42057de7aa5d97c472254af4906ff0a59a66699d426fc9ef795d7/watchfiles-1.0.5.tar.gz", hash = "sha256:b7529b5dcc114679d43827d8c35a07c493ad6f083633d573d81c660abc5979e9", size = 94537 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/4d/d02e6ea147bb7fff5fd109c694a95109612f419abed46548a930e7f7afa3/watchfiles-1.0.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:5c40fe7dd9e5f81e0847b1ea64e1f5dd79dd61afbedb57759df06767ac719b40", size = 405632 }, + { url = "https://files.pythonhosted.org/packages/60/31/9ee50e29129d53a9a92ccf1d3992751dc56fc3c8f6ee721be1c7b9c81763/watchfiles-1.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8c0db396e6003d99bb2d7232c957b5f0b5634bbd1b24e381a5afcc880f7373fb", size = 395734 }, + { url = "https://files.pythonhosted.org/packages/ad/8c/759176c97195306f028024f878e7f1c776bda66ccc5c68fa51e699cf8f1d/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b551d4fb482fc57d852b4541f911ba28957d051c8776e79c3b4a51eb5e2a1b11", size = 455008 }, + { url = "https://files.pythonhosted.org/packages/55/1a/5e977250c795ee79a0229e3b7f5e3a1b664e4e450756a22da84d2f4979fe/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:830aa432ba5c491d52a15b51526c29e4a4b92bf4f92253787f9726fe01519487", size = 459029 }, + { url = "https://files.pythonhosted.org/packages/e6/17/884cf039333605c1d6e296cf5be35fad0836953c3dfd2adb71b72f9dbcd0/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a16512051a822a416b0d477d5f8c0e67b67c1a20d9acecb0aafa3aa4d6e7d256", size = 488916 }, + { url = "https://files.pythonhosted.org/packages/ef/e0/bcb6e64b45837056c0a40f3a2db3ef51c2ced19fda38484fa7508e00632c/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe0cbc787770e52a96c6fda6726ace75be7f840cb327e1b08d7d54eadc3bc85", size = 523763 }, + { url = "https://files.pythonhosted.org/packages/24/e9/f67e9199f3bb35c1837447ecf07e9830ec00ff5d35a61e08c2cd67217949/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d363152c5e16b29d66cbde8fa614f9e313e6f94a8204eaab268db52231fe5358", size = 502891 }, + { url = "https://files.pythonhosted.org/packages/23/ed/a6cf815f215632f5c8065e9c41fe872025ffea35aa1f80499f86eae922db/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ee32c9a9bee4d0b7bd7cbeb53cb185cf0b622ac761efaa2eba84006c3b3a614", size = 454921 }, + { url = "https://files.pythonhosted.org/packages/92/4c/e14978599b80cde8486ab5a77a821e8a982ae8e2fcb22af7b0886a033ec8/watchfiles-1.0.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29c7fd632ccaf5517c16a5188e36f6612d6472ccf55382db6c7fe3fcccb7f59f", size = 631422 }, + { url = "https://files.pythonhosted.org/packages/b2/1a/9263e34c3458f7614b657f974f4ee61fd72f58adce8b436e16450e054efd/watchfiles-1.0.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8e637810586e6fe380c8bc1b3910accd7f1d3a9a7262c8a78d4c8fb3ba6a2b3d", size = 625675 }, + { url = "https://files.pythonhosted.org/packages/96/1f/1803a18bd6ab04a0766386a19bcfe64641381a04939efdaa95f0e3b0eb58/watchfiles-1.0.5-cp310-cp310-win32.whl", hash = "sha256:cd47d063fbeabd4c6cae1d4bcaa38f0902f8dc5ed168072874ea11d0c7afc1ff", size = 277921 }, + { url = "https://files.pythonhosted.org/packages/c2/3b/29a89de074a7d6e8b4dc67c26e03d73313e4ecf0d6e97e942a65fa7c195e/watchfiles-1.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:86c0df05b47a79d80351cd179893f2f9c1b1cae49d96e8b3290c7f4bd0ca0a92", size = 291526 }, + { url = "https://files.pythonhosted.org/packages/39/f4/41b591f59021786ef517e1cdc3b510383551846703e03f204827854a96f8/watchfiles-1.0.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:237f9be419e977a0f8f6b2e7b0475ababe78ff1ab06822df95d914a945eac827", size = 405336 }, + { url = "https://files.pythonhosted.org/packages/ae/06/93789c135be4d6d0e4f63e96eea56dc54050b243eacc28439a26482b5235/watchfiles-1.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0da39ff917af8b27a4bdc5a97ac577552a38aac0d260a859c1517ea3dc1a7c4", size = 395977 }, + { url = "https://files.pythonhosted.org/packages/d2/db/1cd89bd83728ca37054512d4d35ab69b5f12b8aa2ac9be3b0276b3bf06cc/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cfcb3952350e95603f232a7a15f6c5f86c5375e46f0bd4ae70d43e3e063c13d", size = 455232 }, + { url = "https://files.pythonhosted.org/packages/40/90/d8a4d44ffe960517e487c9c04f77b06b8abf05eb680bed71c82b5f2cad62/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:68b2dddba7a4e6151384e252a5632efcaa9bc5d1c4b567f3cb621306b2ca9f63", size = 459151 }, + { url = "https://files.pythonhosted.org/packages/6c/da/267a1546f26465dead1719caaba3ce660657f83c9d9c052ba98fb8856e13/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:95cf944fcfc394c5f9de794ce581914900f82ff1f855326f25ebcf24d5397418", size = 489054 }, + { url = "https://files.pythonhosted.org/packages/b1/31/33850dfd5c6efb6f27d2465cc4c6b27c5a6f5ed53c6fa63b7263cf5f60f6/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ecf6cd9f83d7c023b1aba15d13f705ca7b7d38675c121f3cc4a6e25bd0857ee9", size = 523955 }, + { url = "https://files.pythonhosted.org/packages/09/84/b7d7b67856efb183a421f1416b44ca975cb2ea6c4544827955dfb01f7dc2/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:852de68acd6212cd6d33edf21e6f9e56e5d98c6add46f48244bd479d97c967c6", size = 502234 }, + { url = "https://files.pythonhosted.org/packages/71/87/6dc5ec6882a2254cfdd8b0718b684504e737273903b65d7338efaba08b52/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5730f3aa35e646103b53389d5bc77edfbf578ab6dab2e005142b5b80a35ef25", size = 454750 }, + { url = "https://files.pythonhosted.org/packages/3d/6c/3786c50213451a0ad15170d091570d4a6554976cf0df19878002fc96075a/watchfiles-1.0.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:18b3bd29954bc4abeeb4e9d9cf0b30227f0f206c86657674f544cb032296acd5", size = 631591 }, + { url = "https://files.pythonhosted.org/packages/1b/b3/1427425ade4e359a0deacce01a47a26024b2ccdb53098f9d64d497f6684c/watchfiles-1.0.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ba5552a1b07c8edbf197055bc9d518b8f0d98a1c6a73a293bc0726dce068ed01", size = 625370 }, + { url = "https://files.pythonhosted.org/packages/15/ba/f60e053b0b5b8145d682672024aa91370a29c5c921a88977eb565de34086/watchfiles-1.0.5-cp311-cp311-win32.whl", hash = "sha256:2f1fefb2e90e89959447bc0420fddd1e76f625784340d64a2f7d5983ef9ad246", size = 277791 }, + { url = "https://files.pythonhosted.org/packages/50/ed/7603c4e164225c12c0d4e8700b64bb00e01a6c4eeea372292a3856be33a4/watchfiles-1.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:b6e76ceb1dd18c8e29c73f47d41866972e891fc4cc7ba014f487def72c1cf096", size = 291622 }, + { url = "https://files.pythonhosted.org/packages/a2/c2/99bb7c96b4450e36877fde33690ded286ff555b5a5c1d925855d556968a1/watchfiles-1.0.5-cp311-cp311-win_arm64.whl", hash = "sha256:266710eb6fddc1f5e51843c70e3bebfb0f5e77cf4f27129278c70554104d19ed", size = 283699 }, + { url = "https://files.pythonhosted.org/packages/2a/8c/4f0b9bdb75a1bfbd9c78fad7d8854369283f74fe7cf03eb16be77054536d/watchfiles-1.0.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b5eb568c2aa6018e26da9e6c86f3ec3fd958cee7f0311b35c2630fa4217d17f2", size = 401511 }, + { url = "https://files.pythonhosted.org/packages/dc/4e/7e15825def77f8bd359b6d3f379f0c9dac4eb09dd4ddd58fd7d14127179c/watchfiles-1.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a04059f4923ce4e856b4b4e5e783a70f49d9663d22a4c3b3298165996d1377f", size = 392715 }, + { url = "https://files.pythonhosted.org/packages/58/65/b72fb817518728e08de5840d5d38571466c1b4a3f724d190cec909ee6f3f/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e380c89983ce6e6fe2dd1e1921b9952fb4e6da882931abd1824c092ed495dec", size = 454138 }, + { url = "https://files.pythonhosted.org/packages/3e/a4/86833fd2ea2e50ae28989f5950b5c3f91022d67092bfec08f8300d8b347b/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fe43139b2c0fdc4a14d4f8d5b5d967f7a2777fd3d38ecf5b1ec669b0d7e43c21", size = 458592 }, + { url = "https://files.pythonhosted.org/packages/38/7e/42cb8df8be9a37e50dd3a818816501cf7a20d635d76d6bd65aae3dbbff68/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee0822ce1b8a14fe5a066f93edd20aada932acfe348bede8aa2149f1a4489512", size = 487532 }, + { url = "https://files.pythonhosted.org/packages/fc/fd/13d26721c85d7f3df6169d8b495fcac8ab0dc8f0945ebea8845de4681dab/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a0dbcb1c2d8f2ab6e0a81c6699b236932bd264d4cef1ac475858d16c403de74d", size = 522865 }, + { url = "https://files.pythonhosted.org/packages/a1/0d/7f9ae243c04e96c5455d111e21b09087d0eeaf9a1369e13a01c7d3d82478/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a2014a2b18ad3ca53b1f6c23f8cd94a18ce930c1837bd891262c182640eb40a6", size = 499887 }, + { url = "https://files.pythonhosted.org/packages/8e/0f/a257766998e26aca4b3acf2ae97dff04b57071e991a510857d3799247c67/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10f6ae86d5cb647bf58f9f655fcf577f713915a5d69057a0371bc257e2553234", size = 454498 }, + { url = "https://files.pythonhosted.org/packages/81/79/8bf142575a03e0af9c3d5f8bcae911ee6683ae93a625d349d4ecf4c8f7df/watchfiles-1.0.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1a7bac2bde1d661fb31f4d4e8e539e178774b76db3c2c17c4bb3e960a5de07a2", size = 630663 }, + { url = "https://files.pythonhosted.org/packages/f1/80/abe2e79f610e45c63a70d271caea90c49bbf93eb00fa947fa9b803a1d51f/watchfiles-1.0.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ab626da2fc1ac277bbf752446470b367f84b50295264d2d313e28dc4405d663", size = 625410 }, + { url = "https://files.pythonhosted.org/packages/91/6f/bc7fbecb84a41a9069c2c6eb6319f7f7df113adf113e358c57fc1aff7ff5/watchfiles-1.0.5-cp312-cp312-win32.whl", hash = "sha256:9f4571a783914feda92018ef3901dab8caf5b029325b5fe4558c074582815249", size = 277965 }, + { url = "https://files.pythonhosted.org/packages/99/a5/bf1c297ea6649ec59e935ab311f63d8af5faa8f0b86993e3282b984263e3/watchfiles-1.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:360a398c3a19672cf93527f7e8d8b60d8275119c5d900f2e184d32483117a705", size = 291693 }, + { url = "https://files.pythonhosted.org/packages/7f/7b/fd01087cc21db5c47e5beae507b87965db341cce8a86f9eb12bf5219d4e0/watchfiles-1.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:1a2902ede862969077b97523987c38db28abbe09fb19866e711485d9fbf0d417", size = 283287 }, + { url = "https://files.pythonhosted.org/packages/c7/62/435766874b704f39b2fecd8395a29042db2b5ec4005bd34523415e9bd2e0/watchfiles-1.0.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0b289572c33a0deae62daa57e44a25b99b783e5f7aed81b314232b3d3c81a11d", size = 401531 }, + { url = "https://files.pythonhosted.org/packages/6e/a6/e52a02c05411b9cb02823e6797ef9bbba0bfaf1bb627da1634d44d8af833/watchfiles-1.0.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a056c2f692d65bf1e99c41045e3bdcaea3cb9e6b5a53dcaf60a5f3bd95fc9763", size = 392417 }, + { url = "https://files.pythonhosted.org/packages/3f/53/c4af6819770455932144e0109d4854437769672d7ad897e76e8e1673435d/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9dca99744991fc9850d18015c4f0438865414e50069670f5f7eee08340d8b40", size = 453423 }, + { url = "https://files.pythonhosted.org/packages/cb/d1/8e88df58bbbf819b8bc5cfbacd3c79e01b40261cad0fc84d1e1ebd778a07/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:894342d61d355446d02cd3988a7326af344143eb33a2fd5d38482a92072d9563", size = 458185 }, + { url = "https://files.pythonhosted.org/packages/ff/70/fffaa11962dd5429e47e478a18736d4e42bec42404f5ee3b92ef1b87ad60/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ab44e1580924d1ffd7b3938e02716d5ad190441965138b4aa1d1f31ea0877f04", size = 486696 }, + { url = "https://files.pythonhosted.org/packages/39/db/723c0328e8b3692d53eb273797d9a08be6ffb1d16f1c0ba2bdbdc2a3852c/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d6f9367b132078b2ceb8d066ff6c93a970a18c3029cea37bfd7b2d3dd2e5db8f", size = 522327 }, + { url = "https://files.pythonhosted.org/packages/cd/05/9fccc43c50c39a76b68343484b9da7b12d42d0859c37c61aec018c967a32/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2e55a9b162e06e3f862fb61e399fe9f05d908d019d87bf5b496a04ef18a970a", size = 499741 }, + { url = "https://files.pythonhosted.org/packages/23/14/499e90c37fa518976782b10a18b18db9f55ea73ca14641615056f8194bb3/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0125f91f70e0732a9f8ee01e49515c35d38ba48db507a50c5bdcad9503af5827", size = 453995 }, + { url = "https://files.pythonhosted.org/packages/61/d9/f75d6840059320df5adecd2c687fbc18960a7f97b55c300d20f207d48aef/watchfiles-1.0.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:13bb21f8ba3248386337c9fa51c528868e6c34a707f729ab041c846d52a0c69a", size = 629693 }, + { url = "https://files.pythonhosted.org/packages/fc/17/180ca383f5061b61406477218c55d66ec118e6c0c51f02d8142895fcf0a9/watchfiles-1.0.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:839ebd0df4a18c5b3c1b890145b5a3f5f64063c2a0d02b13c76d78fe5de34936", size = 624677 }, + { url = "https://files.pythonhosted.org/packages/bf/15/714d6ef307f803f236d69ee9d421763707899d6298d9f3183e55e366d9af/watchfiles-1.0.5-cp313-cp313-win32.whl", hash = "sha256:4a8ec1e4e16e2d5bafc9ba82f7aaecfeec990ca7cd27e84fb6f191804ed2fcfc", size = 277804 }, + { url = "https://files.pythonhosted.org/packages/a8/b4/c57b99518fadf431f3ef47a610839e46e5f8abf9814f969859d1c65c02c7/watchfiles-1.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:f436601594f15bf406518af922a89dcaab416568edb6f65c4e5bbbad1ea45c11", size = 291087 }, + { url = "https://files.pythonhosted.org/packages/1a/03/81f9fcc3963b3fc415cd4b0b2b39ee8cc136c42fb10a36acf38745e9d283/watchfiles-1.0.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f59b870db1f1ae5a9ac28245707d955c8721dd6565e7f411024fa374b5362d1d", size = 405947 }, + { url = "https://files.pythonhosted.org/packages/54/97/8c4213a852feb64807ec1d380f42d4fc8bfaef896bdbd94318f8fd7f3e4e/watchfiles-1.0.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9475b0093767e1475095f2aeb1d219fb9664081d403d1dff81342df8cd707034", size = 397276 }, + { url = "https://files.pythonhosted.org/packages/78/12/d4464d19860cb9672efa45eec1b08f8472c478ed67dcd30647c51ada7aef/watchfiles-1.0.5-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc533aa50664ebd6c628b2f30591956519462f5d27f951ed03d6c82b2dfd9965", size = 455550 }, + { url = "https://files.pythonhosted.org/packages/90/fb/b07bcdf1034d8edeaef4c22f3e9e3157d37c5071b5f9492ffdfa4ad4bed7/watchfiles-1.0.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fed1cd825158dcaae36acce7b2db33dcbfd12b30c34317a88b8ed80f0541cc57", size = 455542 }, +] + [[package]] name = "websockets" version = "15.0.1"