diff --git a/database.py b/database.py index 186ce50..67c2ed4 100644 --- a/database.py +++ b/database.py @@ -20,36 +20,16 @@ def __init__(self, database_path: Path | None = None): def create_tables(self) -> None: self.connection.execute( """ - CREATE TABLE IF NOT EXISTS articles - ( - id - INTEGER - PRIMARY - KEY - AUTOINCREMENT, - university - TEXT - NOT - NULL, - title - TEXT - NOT - NULL, - summary - TEXT, - link - TEXT - NOT - NULL - UNIQUE, - published - TEXT, - fetched_at - TEXT - NOT - NULL, - image_url - TEXT + CREATE TABLE IF NOT EXISTS articles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + university TEXT NOT NULL, + source TEXT, + title TEXT NOT NULL, + summary TEXT, + link TEXT NOT NULL UNIQUE, + published TEXT, + fetched_at TEXT NOT NULL, + image_url TEXT ); """ ) @@ -69,8 +49,7 @@ def create_tables(self) -> None: def save_article(self, article: dict) -> None: query = """ - INSERT \ - OR IGNORE INTO articles ( + INSERT OR IGNORE INTO articles ( university, source, title, @@ -80,8 +59,8 @@ def save_article(self, article: dict) -> None: fetched_at, image_url ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?); \ - """ + VALUES (?, ?, ?, ?, ?, ?, ?, ?); + """ self.connection.execute( query, @@ -103,103 +82,132 @@ def save_articles(self, articles: list[dict]) -> None: for article in articles: self.save_article(article) - def get_articles( - self, - university=None, - source=None, - search_text=None, - limit: int = 500, - ) -> list[dict]: - query = """ - SELECT - id, - university, - source, - title, - summary, - link, - published, - fetched_at, - image_url - FROM articles - WHERE 1 = 1 \ - """ - + def build_article_filters( + self, + university=None, + source=None, + search_text=None, + ) -> tuple[str, list]: + filters = ["1 = 1"] params = [] if university and university != "All": - query += " AND university = ?" + filters.append("university = ?") params.append(university) if source and source != "All": - query += " AND source = ?" + filters.append("source = ?") params.append(source) - if university and university != "All": - query += " AND university = ?" - params.append(university) - if search_text: - query += """ - AND ( - title LIKE ? - OR summary LIKE ? - OR university LIKE ? + filters.append( + """ + ( + title LIKE ? + OR summary LIKE ? + OR university LIKE ? + OR source LIKE ? + ) + """ ) - """ search_pattern = f"%{search_text}%" - params.extend([search_pattern, search_pattern, search_pattern]) + params.extend( + [ + search_pattern, + search_pattern, + search_pattern, + search_pattern, + ] + ) + + return " AND ".join(filters), params + + def get_articles( + self, + university=None, + source=None, + search_text=None, + limit: int = 12, + offset: int = 0, + ) -> list[dict]: + where_clause, params = self.build_article_filters( + university=university, + source=source, + search_text=search_text, + ) - query += """ + query = f""" + SELECT + id, + university, + source, + title, + summary, + link, + published, + fetched_at, + image_url + FROM articles + WHERE {where_clause} ORDER BY id DESC LIMIT ? + OFFSET ?; """ - params.append(limit) + params.extend([limit, offset]) cursor = self.connection.execute(query, params) rows = cursor.fetchall() return [dict(row) for row in rows] + def count_articles( + self, + university=None, + source=None, + search_text=None, + ) -> int: + where_clause, params = self.build_article_filters( + university=university, + source=source, + search_text=search_text, + ) + + query = f""" + SELECT COUNT(*) AS total + FROM articles + WHERE {where_clause}; + """ + + cursor = self.connection.execute(query, params) + row = cursor.fetchone() + + return row["total"] if row else 0 + def delete_all_articles(self) -> None: self.connection.execute("DELETE FROM articles;") self.connection.commit() - def close(self) -> None: - self.connection.close() - def enforce_article_limit(self, max_articles: int) -> None: query = """ - DELETE \ - FROM articles - WHERE id NOT IN (SELECT id \ - FROM articles \ - ORDER BY id DESC - LIMIT ? - ); \ - """ + DELETE FROM articles + WHERE id NOT IN ( + SELECT id + FROM articles + ORDER BY id DESC + LIMIT ? + ); + """ self.connection.execute(query, (max_articles,)) self.connection.commit() - def load_cached_articles(self): - try: - self.articles = self.database.get_articles() - except Exception as error: - print(f"Could not load cached articles: {error}") - self.articles = [] - self.selected_university = "All" - self.search_text = "" - - self.render_articles() - def get_universities(self) -> list[str]: query = """ - SELECT DISTINCT university - FROM articles - ORDER BY university ASC; - """ + SELECT DISTINCT university + FROM articles + ORDER BY university ASC; + """ cursor = self.connection.execute(query) rows = cursor.fetchall() @@ -217,4 +225,7 @@ def get_sources_for_university(self, university: str) -> list[str]: (university,), ) - return [row["source"] for row in cursor.fetchall() if row["source"]] \ No newline at end of file + return [row["source"] for row in cursor.fetchall() if row["source"]] + + def close(self) -> None: + self.connection.close() diff --git a/ui/main_window.py b/ui/main_window.py index dcb9a1c..334ae45 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -1,28 +1,30 @@ import json -from PyQt6.QtCore import Qt, QUrl +from PyQt6.QtCore import Qt, QTimer, QUrl +from PyQt6.QtGui import QDesktopServices from PyQt6.QtWidgets import ( + QComboBox, QFrame, QHBoxLayout, QLabel, + QLineEdit, QListWidget, QListWidgetItem, QMainWindow, QMessageBox, QPushButton, + QScrollArea, QVBoxLayout, - QWidget, QScrollArea, QLineEdit, + QWidget, ) -from PyQt6.QtGui import QDesktopServices, QPixmap +from app_settings import load_app_settings from database import Database -from settings import FEEDS_FILE +from notifications import send_article_notification, should_notify from rss_service import fetch_feed -from ui.settings_dialog import SettingsDialog -from app_settings import load_app_settings -from PyQt6.QtCore import QTimer -from notifications import should_notify, send_article_notification from scrapers.scraper_service import run_scraper +from settings import FEEDS_FILE +from ui.settings_dialog import SettingsDialog ARTICLE_LINK_ROLE = 1000 @@ -43,6 +45,10 @@ def __init__(self): self.selected_source = "All" self.search_text = "" + self.current_page = 1 + self.page_size = 12 + self.total_articles = 0 + self.total_pages = 1 self.app_settings = load_app_settings() self.setup_ui() @@ -94,7 +100,7 @@ def get_theme_stylesheet(self, theme: str) -> str: }} QWidget {{ - font-family: "Segoe UI", "Inter", "Arial"; + font-family: "Noto Sans", "Inter", "Arial"; color: {text}; }} @@ -211,6 +217,96 @@ def get_theme_stylesheet(self, theme: str) -> str: color: white; font-weight: 800; }} + + #PaginationBar {{ + background-color: {panel}; + border: 1px solid {border}; + border-radius: 18px; + }} + + #PaginationSummary {{ + color: {muted}; + font-size: 13px; + font-weight: 650; + font-family: "Noto Sans", "Inter", "Arial"; }} + + #PaginationButton, + #PageButton {{ + background-color: {panel_2}; + color: {text}; + border: 1px solid {border}; + border-radius: 11px; + padding: 8px 14px; + font-size: 13px; + font-weight: 750; + font-family: "Noto Sans", "Inter", "Arial"; }} + + #PaginationButton:hover, + #PageButton:hover {{ + background-color: {card_hover}; + border: 1px solid {accent}; + }} + + #PaginationButton:disabled {{ + background-color: {panel_2}; + color: {muted}; + border: 1px solid {border}; + }} + + #PageButtonActive {{ + background-color: {accent}; + color: white; + border: none; + border-radius: 11px; + padding: 8px 14px; + font-size: 13px; + font-weight: 850; + font-family: "Noto Sans", "Inter", "Arial"; }} + + #PaginationDots {{ + color: {muted}; + font-size: 13px; + font-weight: 700; + padding-left: 4px; + padding-right: 4px; + font-family: "Noto Sans", "Inter", "Arial"; }} + + #PaginationSize {{ + background-color: {panel_2}; + color: {text}; + border: 1px solid {border}; + border-radius: 11px; + padding: 8px 30px 8px 12px; + font-size: 13px; + font-weight: 750; + font-family: "Noto Sans", "Inter", "Arial"; }} + + #PaginationSize:hover {{ + background-color: {card_hover}; + border: 1px solid {accent}; + }} + + #PaginationSize::drop-down {{ + border: none; + width: 26px; + }} + + #PaginationSize::down-arrow {{ + image: none; + width: 0px; + height: 0px; + }} + + #PaginationSize QAbstractItemView {{ + background-color: {panel}; + color: {text}; + border: 1px solid {border}; + border-radius: 10px; + selection-background-color: {accent}; + selection-color: white; + padding: 4px; + outline: none; + }} """ def setup_ui(self): @@ -226,9 +322,18 @@ def setup_ui(self): self.create_sidebar() self.create_article_list() + self.create_pagination_bar() + + self.article_area = QWidget() + self.article_area_layout = QVBoxLayout(self.article_area) + self.article_area_layout.setContentsMargins(0, 0, 0, 0) + self.article_area_layout.setSpacing(12) + + self.article_area_layout.addWidget(self.scroll_area, stretch=1) + self.article_area_layout.addWidget(self.pagination_bar) self.content_layout.addWidget(self.sidebar) - self.content_layout.addWidget(self.scroll_area, stretch=1) + self.content_layout.addWidget(self.article_area, stretch=1) self.root_layout.addLayout(self.content_layout) @@ -238,7 +343,9 @@ def update_source_list(self): self.source_list.clear() all_item = QListWidgetItem("All") - all_item.setData(Qt.ItemDataRole.UserRole, {"university": "All", "source": "All"}) + all_item.setData( + Qt.ItemDataRole.UserRole, {"university": "All", "source": "All"} + ) self.source_list.addItem(all_item) universities = self.database.get_universities() @@ -272,22 +379,14 @@ def on_source_selected(self, item: QListWidgetItem): self.selected_university = data.get("university", "All") self.selected_source = data.get("source", "All") + self.current_page = 1 self.load_filtered_articles() def on_search_changed(self, text: str): self.search_text = text.strip() + self.current_page = 1 self.load_filtered_articles() - def load_filtered_articles(self): - self.articles = self.database.get_articles( - university=self.selected_university, - source=self.selected_source, - search_text=self.search_text, - limit=500, - ) - - self.render_articles() - def create_sidebar(self): self.sidebar = QFrame() self.sidebar.setObjectName("Sidebar") @@ -383,8 +482,7 @@ def apply_light_theme(self): } QWidget { - font-family: "Segoe UI", "Inter", "Arial"; - color: #1F2937; + font-family: "Noto Sans", "Inter", "Arial"; color: #1F2937; } #HeaderCard { @@ -517,19 +615,19 @@ def apply_light_theme(self): QScrollBar::sub-line:vertical { height: 0px; } - + #Sidebar { background-color: #FFFFFF; border: 1px solid #E6DDF3; border-radius: 22px; } - + #SidebarTitle { color: #171124; font-size: 18px; font-weight: 900; } - + #SearchInput { background-color: #F9F5FF; color: #171124; @@ -538,33 +636,89 @@ def apply_light_theme(self): padding: 9px 12px; font-size: 13px; } - + #SearchInput:focus { border: 1px solid #A855F7; } - + #SourceList { background-color: transparent; border: none; outline: none; } - + #SourceList::item { color: #4B5563; padding: 10px 12px; border-radius: 10px; } - + #SourceList::item:hover { background-color: #F3E8FF; color: #171124; } - + #SourceList::item:selected { background-color: #A855F7; color: white; font-weight: 800; } + + #PaginationBar { + background-color: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 16px; + } + + #PaginationSummary { + color: #64748b; + font-size: 13px; + font-weight: 500; + } + + #PaginationButton, + #PageButton { + background-color: #f8fafc; + color: #334155; + border: 1px solid #e2e8f0; + border-radius: 10px; + padding: 7px 12px; + font-weight: 600; + } + + #PaginationButton:hover, + #PageButton:hover { + background-color: #eef2ff; + color: #1e293b; + } + + #PaginationButton:disabled { + background-color: #f1f5f9; + color: #94a3b8; + } + + #PageButtonActive { + background-color: #111827; + color: #ffffff; + border: 1px solid #111827; + border-radius: 10px; + padding: 7px 12px; + font-weight: 700; + } + + #PaginationDots { + color: #94a3b8; + padding-left: 4px; + padding-right: 4px; + } + + #PaginationSize { + background-color: #f8fafc; + color: #334155; + border: 1px solid #e2e8f0; + border-radius: 10px; + padding: 6px 10px; + } """ ) @@ -579,13 +733,13 @@ def apply_dark_theme(self): border: 1px solid #1E293B; border-radius: 22px; } - + #SidebarTitle { color: #FFFFFF; font-size: 18px; font-weight: 900; } - + #SearchInput { background-color: #111827; color: #F8FAFC; @@ -594,28 +748,28 @@ def apply_dark_theme(self): padding: 9px 12px; font-size: 13px; } - + #SearchInput:focus { border: 1px solid #C084FC; } - + #SourceList { background-color: transparent; border: none; outline: none; } - + #SourceList::item { color: #CBD5E1; padding: 10px 12px; border-radius: 10px; } - + #SourceList::item:hover { background-color: #1E293B; color: #FFFFFF; } - + #SourceList::item:selected { background-color: #C084FC; color: #080A14; @@ -626,8 +780,7 @@ def apply_dark_theme(self): } QWidget { - font-family: "Segoe UI", "Inter", "Arial"; - color: #F8FAFC; + font-family: "Noto Sans", "Inter", "Arial"; color: #F8FAFC; } #HeaderCard { @@ -783,7 +936,6 @@ def refresh_news(self): university_name = feed.get("name", "Unknown university") feed_url = feed.get("url", "") - try: source_type = feed.get("type", "rss") @@ -808,6 +960,7 @@ def refresh_news(self): def load_cached_articles(self): self.update_source_list() + self.current_page = 1 self.load_filtered_articles() def open_article(self, item: QListWidgetItem): @@ -914,7 +1067,9 @@ def clean_text(self, text: str) -> str: def open_settings(self): dialog = SettingsDialog(self) - dialog.setStyleSheet(self.get_theme_stylesheet(self.app_settings.get("theme", "light"))) + dialog.setStyleSheet( + self.get_theme_stylesheet(self.app_settings.get("theme", "light")) + ) if dialog.exec(): self.app_settings = load_app_settings() @@ -956,4 +1111,166 @@ def apply_theme(self) -> None: bg = "#080A14" if theme == "dark" else "#F4F0FA" self.article_container.setStyleSheet(f"background-color: {bg};") + def create_pagination_bar(self): + self.pagination_bar = QFrame() + self.pagination_bar.setObjectName("PaginationBar") + + pagination_layout = QHBoxLayout(self.pagination_bar) + pagination_layout.setContentsMargins(12, 8, 12, 8) + pagination_layout.setSpacing(8) + + self.page_summary_label = QLabel() + self.page_summary_label.setObjectName("PaginationSummary") + + self.pagination_prev_button = QPushButton("‹ Previous") + self.pagination_prev_button.setObjectName("PaginationButton") + self.pagination_prev_button.clicked.connect(self.go_to_previous_page) + + self.page_buttons_layout = QHBoxLayout() + self.page_buttons_layout.setSpacing(6) + + self.pagination_next_button = QPushButton("Next ›") + self.pagination_next_button.setObjectName("PaginationButton") + self.pagination_next_button.clicked.connect(self.go_to_next_page) + + self.page_size_combo = QComboBox() + self.page_size_combo.setObjectName("PaginationSize") + + for size in [10, 12, 20, 30, 50]: + self.page_size_combo.addItem(f"{size} / page", size) + + self.page_size_combo.setCurrentIndex(1) + self.page_size_combo.currentIndexChanged.connect(self.on_page_size_changed) + + pagination_layout.addWidget(self.page_summary_label) + pagination_layout.addStretch() + pagination_layout.addWidget(self.pagination_prev_button) + pagination_layout.addLayout(self.page_buttons_layout) + pagination_layout.addWidget(self.pagination_next_button) + pagination_layout.addSpacing(8) + pagination_layout.addWidget(self.page_size_combo) + + def load_filtered_articles(self): + self.total_articles = self.database.count_articles( + university=self.selected_university, + source=self.selected_source, + search_text=self.search_text, + ) + + self.total_pages = max( + 1, + (self.total_articles + self.page_size - 1) // self.page_size, + ) + + if self.current_page > self.total_pages: + self.current_page = self.total_pages + + offset = (self.current_page - 1) * self.page_size + + self.articles = self.database.get_articles( + university=self.selected_university, + source=self.selected_source, + search_text=self.search_text, + limit=self.page_size, + offset=offset, + ) + + self.render_articles() + self.update_pagination_controls() + + def update_pagination_controls(self): + self.clear_pagination_page_buttons() + + if self.total_articles == 0: + self.pagination_bar.setVisible(False) + return + + self.pagination_bar.setVisible(True) + + first_article = (self.current_page - 1) * self.page_size + 1 + last_article = min( + self.current_page * self.page_size, + self.total_articles, + ) + + self.page_summary_label.setText( + f"Showing {first_article}-{last_article} of {self.total_articles}" + ) + + self.pagination_prev_button.setEnabled(self.current_page > 1) + self.pagination_next_button.setEnabled(self.current_page < self.total_pages) + + for page in self.get_visible_page_numbers(): + if page == "...": + label = QLabel("...") + label.setObjectName("PaginationDots") + label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.page_buttons_layout.addWidget(label) + continue + + button = QPushButton(str(page)) + + if page == self.current_page: + button.setObjectName("PageButtonActive") + button.setEnabled(False) + else: + button.setObjectName("PageButton") + button.clicked.connect( + lambda checked=False, selected_page=page: self.go_to_page( + selected_page + ) + ) + + self.page_buttons_layout.addWidget(button) + + def get_visible_page_numbers(self) -> list: + if self.total_pages <= 7: + return list(range(1, self.total_pages + 1)) + + pages = [1] + + start_page = max(2, self.current_page - 1) + end_page = min(self.total_pages - 1, self.current_page + 1) + + if start_page > 2: + pages.append("...") + + for page in range(start_page, end_page + 1): + pages.append(page) + + if end_page < self.total_pages - 1: + pages.append("...") + pages.append(self.total_pages) + + return pages + + def clear_pagination_page_buttons(self): + while self.page_buttons_layout.count(): + item = self.page_buttons_layout.takeAt(0) + widget = item.widget() + + if widget: + widget.deleteLater() + + def go_to_page(self, page: int): + if page < 1 or page > self.total_pages: + return + + if page == self.current_page: + return + + self.current_page = page + self.load_filtered_articles() + self.scroll_area.verticalScrollBar().setValue(0) + + def go_to_previous_page(self): + self.go_to_page(self.current_page - 1) + + def go_to_next_page(self): + self.go_to_page(self.current_page + 1) + + def on_page_size_changed(self): + self.page_size = self.page_size_combo.currentData() + self.current_page = 1 + self.load_filtered_articles()