diff --git a/.gitignore b/.gitignore index f417ad0..c63c687 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,17 @@ +.idea/ +__pycache__ +local_data/ +ui/__pycache__/ +env/ +venv/ + +# Local Data and Databases +data/ +*.db +*.sqlite3 +*.csv +*.json + # C++ objects and libs *.slo *.lo diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/UniNews.iml b/.idea/UniNews.iml new file mode 100644 index 0000000..8217bd6 --- /dev/null +++ b/.idea/UniNews.iml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 0000000..cab4090 --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,12 @@ + + + + + sqlite.xerial + true + org.sqlite.JDBC + jdbc:sqlite:C:\Users\geoap\Documents\github_repositories\UniNews\local_data\uninews.db + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/.idea/deployment.xml b/.idea/deployment.xml new file mode 100644 index 0000000..4ff1d38 --- /dev/null +++ b/.idea/deployment.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..03a1660 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,21 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..aa60783 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..0d37d4b --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..8306744 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/__pycache__/app_settings.cpython-310.pyc b/__pycache__/app_settings.cpython-310.pyc new file mode 100644 index 0000000..ec2c91e Binary files /dev/null and b/__pycache__/app_settings.cpython-310.pyc differ diff --git a/__pycache__/database.cpython-310.pyc b/__pycache__/database.cpython-310.pyc new file mode 100644 index 0000000..9d714c4 Binary files /dev/null and b/__pycache__/database.cpython-310.pyc differ diff --git a/__pycache__/notifications.cpython-310.pyc b/__pycache__/notifications.cpython-310.pyc new file mode 100644 index 0000000..ef129c9 Binary files /dev/null and b/__pycache__/notifications.cpython-310.pyc differ diff --git a/__pycache__/rss_service.cpython-310.pyc b/__pycache__/rss_service.cpython-310.pyc new file mode 100644 index 0000000..e818347 Binary files /dev/null and b/__pycache__/rss_service.cpython-310.pyc differ diff --git a/__pycache__/settings.cpython-310.pyc b/__pycache__/settings.cpython-310.pyc new file mode 100644 index 0000000..ce7e0f4 Binary files /dev/null and b/__pycache__/settings.cpython-310.pyc differ diff --git a/app_settings.py b/app_settings.py new file mode 100644 index 0000000..d38355e --- /dev/null +++ b/app_settings.py @@ -0,0 +1,43 @@ +import json +from pathlib import Path + +from settings import APP_DATA_DIR + + +SETTINGS_FILE = APP_DATA_DIR / "settings.json" + + +DEFAULT_SETTINGS = { + "refresh_on_startup": True, + "refresh_interval_minutes": 0, + "notifications_enabled": False, + "notification_keywords": ["deadline", "scholarship", "application", "exam"], + "theme": "light", + "max_cached_articles": 500, +} + + +def load_app_settings() -> dict: + if not SETTINGS_FILE.exists(): + save_app_settings(DEFAULT_SETTINGS) + return DEFAULT_SETTINGS.copy() + + try: + with open(SETTINGS_FILE, "r", encoding="utf-8") as file: + settings = json.load(file) + + merged_settings = DEFAULT_SETTINGS.copy() + merged_settings.update(settings) + + return merged_settings + + except json.JSONDecodeError: + save_app_settings(DEFAULT_SETTINGS) + return DEFAULT_SETTINGS.copy() + + +def save_app_settings(settings: dict) -> None: + SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True) + + with open(SETTINGS_FILE, "w", encoding="utf-8") as file: + json.dump(settings, file, indent=4) \ No newline at end of file diff --git a/data/feeds.json b/data/feeds.json new file mode 100644 index 0000000..9ef12bc --- /dev/null +++ b/data/feeds.json @@ -0,0 +1,22 @@ +[ + { + "name": "MIT News", + "url": "https://news.mit.edu/rss/feed" + }, + { + "name": "Harvard Gazette", + "url": "https://news.harvard.edu/gazette/feed/" + }, + { + "name": "Nature News", + "url": "https://www.nature.com/nature.rss" +}, +{ + "name": "Science Magazine", + "url": "https://www.science.org/action/showFeed?type=etoc&feed=rss&jc=science" +}, +{ + "name": "NIH News", + "url": "https://www.nih.gov/news-events/news-releases/feed" +} +] \ No newline at end of file diff --git a/data/settings.json b/data/settings.json new file mode 100644 index 0000000..b80724d --- /dev/null +++ b/data/settings.json @@ -0,0 +1,7 @@ +{ + "refresh_interval_minutes": 30, + "notifications_enabled": true, + "notification_keywords": ["scholarship", "deadline", "exam", "application"], + "theme": "light", + "max_cached_articles": 500 +} \ No newline at end of file diff --git a/database.py b/database.py new file mode 100644 index 0000000..5d73e7f --- /dev/null +++ b/database.py @@ -0,0 +1,177 @@ +import sqlite3 +from pathlib import Path +from typing import Optional + +import settings + + +class Database: + def __init__(self, database_path: Path | None = None): + if database_path is None: + database_path = settings.DATABASE_PATH + + self.database_path = Path(database_path) + self.database_path.parent.mkdir(parents=True, exist_ok=True) + + self.connection = sqlite3.connect(self.database_path) + self.connection.row_factory = sqlite3.Row + + self.create_tables() + + 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 + ); + """ + ) + self.connection.commit() + + try: + self.connection.execute("ALTER TABLE articles ADD COLUMN image_url TEXT;") + self.connection.commit() + except sqlite3.OperationalError: + pass + + def save_article(self, article: dict) -> None: + query = """ + INSERT \ + OR IGNORE INTO articles ( + university, + title, + summary, + link, + published, + fetched_at, + image_url + ) + VALUES (?, ?, ?, ?, ?, ?, ?); \ + """ + + self.connection.execute( + query, + ( + article.get("university", "Unknown university"), + article.get("title", "Untitled"), + article.get("summary", ""), + article.get("link", ""), + article.get("published", "Unknown date"), + article.get("fetched_at", ""), + article.get("image_url", ""), + ), + ) + + self.connection.commit() + + def save_articles(self, articles: list[dict]) -> None: + for article in articles: + self.save_article(article) + + def get_articles( + self, + university: Optional[str] = None, + search_text: Optional[str] = None, + limit: int = 200, + ) -> list[dict]: + query = """ + SELECT + id, + university, + title, + summary, + link, + published, + fetched_at, + image_url + FROM articles + WHERE 1 = 1 + """ + + params = [] + + if university and university != "All Universities": + query += " AND university = ?" + params.append(university) + + if search_text: + query += """ + AND ( + title LIKE ? + OR summary LIKE ? + OR university LIKE ? + ) + """ + + search_pattern = f"%{search_text}%" + params.extend([search_pattern, search_pattern, search_pattern]) + + query += """ + ORDER BY id DESC + LIMIT ? + """ + + params.append(limit) + + cursor = self.connection.execute(query, params) + rows = cursor.fetchall() + + return [dict(row) for row in rows] + + 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 ? + ); \ + """ + + 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.render_articles() \ No newline at end of file diff --git a/local_data/settings.json b/local_data/settings.json new file mode 100644 index 0000000..cde3c07 --- /dev/null +++ b/local_data/settings.json @@ -0,0 +1,13 @@ +{ + "refresh_on_startup": true, + "refresh_interval_minutes": 0, + "notifications_enabled": false, + "notification_keywords": [ + "deadline", + "scholarship", + "application", + "exam" + ], + "theme": "light", + "max_cached_articles": 500 +} \ No newline at end of file diff --git a/local_data/uninews.db b/local_data/uninews.db new file mode 100644 index 0000000..d18b69d Binary files /dev/null and b/local_data/uninews.db differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..68796a7 --- /dev/null +++ b/main.py @@ -0,0 +1,18 @@ +import sys + +from PyQt6.QtWidgets import QApplication + +from ui.main_window import UniNewsWindow + + +def main(): + app = QApplication(sys.argv) + + window = UniNewsWindow() + window.show() + + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/notifications.py b/notifications.py new file mode 100644 index 0000000..a51a844 --- /dev/null +++ b/notifications.py @@ -0,0 +1,24 @@ +from plyer import notification + + +def should_notify(article: dict, keywords: list[str]) -> bool: + if not keywords: + return True + + text = ( + article.get("title", "") + " " + article.get("summary", "") + ).lower() + + return any(keyword.lower() in text for keyword in keywords) + + +def send_article_notification(article: dict) -> None: + title = article.get("university", "UniNews") + message = article.get("title", "New article") + + notification.notify( + title=f"New article from {title}", + message=message[:200], + app_name="UniNews", + timeout=8, + ) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b993573 Binary files /dev/null and b/requirements.txt differ diff --git a/rss_service.py b/rss_service.py new file mode 100644 index 0000000..02c9364 --- /dev/null +++ b/rss_service.py @@ -0,0 +1,45 @@ +from datetime import datetime, timezone + +import feedparser + + +def extract_image_url(entry) -> str: + if "media_content" in entry and entry.media_content: + return entry.media_content[0].get("url", "") + + if "media_thumbnail" in entry and entry.media_thumbnail: + return entry.media_thumbnail[0].get("url", "") + + if "links" in entry: + for link in entry.links: + if link.get("type", "").startswith("image/"): + return link.get("href", "") + + return "" + + +def fetch_feed(university_name: str, feed_url: str) -> list[dict]: + parsed_feed = feedparser.parse(feed_url) + fetched_at = datetime.now(timezone.utc).isoformat() + + articles = [] + + for entry in parsed_feed.entries: + link = entry.get("link", "") + + if not link: + continue + + articles.append( + { + "university": university_name, + "title": entry.get("title", "Untitled"), + "summary": entry.get("summary", ""), + "link": link, + "published": entry.get("published", "Unknown date"), + "fetched_at": fetched_at, + "image_url": extract_image_url(entry), + } + ) + + return articles \ No newline at end of file diff --git a/settings.py b/settings.py new file mode 100644 index 0000000..ea36c5a --- /dev/null +++ b/settings.py @@ -0,0 +1,14 @@ +from pathlib import Path + + +APP_NAME = "UniNews" + +BASE_DIR = Path(__file__).resolve().parent + +DATA_DIR = BASE_DIR / "data" +FEEDS_FILE = DATA_DIR / "feeds.json" + +APP_DATA_DIR = BASE_DIR / "local_data" +APP_DATA_DIR.mkdir(parents=True, exist_ok=True) + +DATABASE_PATH = APP_DATA_DIR / "uninews.db" diff --git a/test.py b/test.py new file mode 100644 index 0000000..7edb5f8 --- /dev/null +++ b/test.py @@ -0,0 +1,15 @@ +from notifications import should_notify, send_article_notification + +article = { + "university": "Wageningen University", + "title": "New Scholarship Deadline Announced", + "summary": "Applications close on June 15." +} + +keywords = ["scholarship", "deadline"] + +print("Should notify:", should_notify(article, keywords)) + +send_article_notification(article) + +print("Notification sent.") \ No newline at end of file diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ui/__pycache__/__init__.cpython-310.pyc b/ui/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..2f152cd Binary files /dev/null and b/ui/__pycache__/__init__.cpython-310.pyc differ diff --git a/ui/__pycache__/main_window.cpython-310.pyc b/ui/__pycache__/main_window.cpython-310.pyc new file mode 100644 index 0000000..a81c363 Binary files /dev/null and b/ui/__pycache__/main_window.cpython-310.pyc differ diff --git a/ui/__pycache__/settings_dialog.cpython-310.pyc b/ui/__pycache__/settings_dialog.cpython-310.pyc new file mode 100644 index 0000000..71eee1e Binary files /dev/null and b/ui/__pycache__/settings_dialog.cpython-310.pyc differ diff --git a/ui/main_window.py b/ui/main_window.py new file mode 100644 index 0000000..355ae0b --- /dev/null +++ b/ui/main_window.py @@ -0,0 +1,604 @@ +import json + +from PyQt6.QtCore import Qt, QUrl +from PyQt6.QtWidgets import ( + QFrame, + QHBoxLayout, + QLabel, + QListWidgetItem, + QMainWindow, + QMessageBox, + QPushButton, + QVBoxLayout, + QWidget, QScrollArea, +) +from PyQt6.QtGui import QDesktopServices + +from database import Database +from settings import FEEDS_FILE +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 + +ARTICLE_LINK_ROLE = 1000 + +class UniNewsWindow(QMainWindow): + def __init__(self): + super().__init__() + + self.setWindowTitle("UniNews") + self.resize(1050, 720) + + self.settings_button = QPushButton("Settings") + self.settings_button.clicked.connect(self.open_settings) + + self.database = Database() + self.articles = [] + + self.app_settings = load_app_settings() + self.setup_ui() + self.apply_theme() + self.load_cached_articles() + + if self.app_settings.get("refresh_on_startup", True): + self.refresh_news() + + self.refresh_timer = QTimer(self) + self.refresh_timer.timeout.connect(self.refresh_news) + + self.apply_theme() + self.apply_refresh_timer() + + def setup_ui(self): + self.root = QWidget() + self.root_layout = QVBoxLayout(self.root) + self.root_layout.setContentsMargins(28, 24, 28, 24) + self.root_layout.setSpacing(20) + + self.create_header() + self.create_article_list() + + self.setCentralWidget(self.root) + + def create_header(self): + header_card = QFrame() + header_card.setObjectName("HeaderCard") + + header_layout = QHBoxLayout(header_card) + header_layout.setContentsMargins(24, 20, 24, 20) + header_layout.setSpacing(20) + + title_area = QVBoxLayout() + title_area.setSpacing(4) + + self.title_label = QLabel("UniNews") + self.title_label.setObjectName("TitleLabel") + + self.subtitle_label = QLabel("University news in one clean place") + self.subtitle_label.setObjectName("SubtitleLabel") + + title_area.addWidget(self.title_label) + title_area.addWidget(self.subtitle_label) + + self.refresh_button = QPushButton("Refresh news") + self.refresh_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.refresh_button.setObjectName("RefreshButton") + self.refresh_button.clicked.connect(self.refresh_news) + + self.settings_button = QPushButton("Settings") + self.settings_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.settings_button.clicked.connect(self.open_settings) + + header_layout.addLayout(title_area) + header_layout.addStretch() + header_layout.addWidget(self.settings_button) + header_layout.addWidget(self.refresh_button) + + self.root_layout.addWidget(header_card) + + def create_article_list(self): + self.scroll_area = QScrollArea() + self.scroll_area.setWidgetResizable(True) + self.scroll_area.setObjectName("ArticleScrollArea") + self.scroll_area.setStyleSheet(""" + QScrollArea { + background-color: #080A14; + border: none; + } + + QScrollArea > QWidget > QWidget { + background-color: #080A14; + } + """) + + self.article_container = QWidget() + self.article_container.setStyleSheet("background-color: #080A14;") + self.article_container.setObjectName("ArticleContainer") + self.article_layout = QVBoxLayout(self.article_container) + self.article_layout.setContentsMargins(0, 0, 0, 0) + self.article_layout.setSpacing(18) + self.article_layout.addStretch() + + self.scroll_area.setWidget(self.article_container) + self.root_layout.addWidget(self.scroll_area) + + def apply_light_theme(self): + self.setStyleSheet( + """ + QMainWindow { + background-color: #F4F0FA; + } + + QWidget { + font-family: "Segoe UI", "Inter", "Arial"; + color: #1F2937; + } + + #HeaderCard { + background-color: #FFFFFF; + border-radius: 24px; + border: 1px solid #E5D9F2; + } + + #TitleLabel { + color: #171124; + font-size: 34px; + font-weight: 900; + letter-spacing: -1px; + } + + #SubtitleLabel { + color: #7E3AF2; + font-size: 14px; + font-weight: 600; + } + + QPushButton { + background-color: #FFFFFF; + color: #2D1B45; + border: 1px solid #D8C7EF; + padding: 10px 18px; + border-radius: 11px; + font-size: 13px; + font-weight: 700; + } + + QPushButton:hover { + background-color: #F3E8FF; + border: 1px solid #A855F7; + } + + QPushButton:pressed { + background-color: #E9D5FF; + } + + #RefreshButton { + background-color: #A855F7; + color: #FFFFFF; + border: none; + } + + #RefreshButton:hover { + background-color: #9333EA; + } + + #RefreshButton:disabled { + background-color: #C4B5FD; + color: #FFFFFF; + } + + QScrollArea { + background-color: #F4F0FA; + border: none; + } + + QScrollArea > QWidget > QWidget { + background-color: #F4F0FA; + } + + #ArticleCard { + background-color: #FFFFFF; + border: 1px solid #E6DDF3; + border-radius: 24px; + } + + #ArticleCard:hover { + background-color: #FBF7FF; + border: 1px solid #A855F7; + } + + #ArticleImage { + background-color: #F3E8FF; + border: 1px solid #E9D5FF; + border-radius: 18px; + color: #7E22CE; + font-size: 24px; + font-weight: 900; + } + + #ArticleTitle { + color: #171124; + font-size: 17px; + font-weight: 850; + } + + #ArticleMeta { + color: #7E22CE; + font-size: 12px; + font-weight: 750; + } + + #ArticleSummary { + color: #4B5563; + font-size: 13px; + } + + #OpenArticleLabel { + color: #A21CAF; + font-size: 12px; + font-weight: 850; + } + + #EmptyLabel { + color: #6B7280; + font-size: 16px; + padding: 60px; + } + + QScrollBar:vertical { + background: transparent; + width: 10px; + margin: 4px; + } + + QScrollBar::handle:vertical { + background: #D8B4FE; + border-radius: 5px; + } + + QScrollBar::handle:vertical:hover { + background: #A855F7; + } + + QScrollBar::add-line:vertical, + QScrollBar::sub-line:vertical { + height: 0px; + } + """ + ) + + if hasattr(self, "article_container"): + self.article_container.setStyleSheet("background-color: #F4F0FA;") + + def apply_dark_theme(self): + self.setStyleSheet( + """ + QMainWindow { + background-color: #080A14; + } + + QWidget { + font-family: "Segoe UI", "Inter", "Arial"; + color: #F8FAFC; + } + + #HeaderCard { + background-color: #111827; + border-radius: 24px; + border: 1px solid #2A3144; + } + + #TitleLabel { + color: #FFFFFF; + font-size: 34px; + font-weight: 900; + letter-spacing: -1px; + } + + #SubtitleLabel { + color: #D8B4FE; + font-size: 14px; + font-weight: 500; + } + + QPushButton { + background-color: #1F2937; + color: #F8FAFC; + border: 1px solid #374151; + padding: 10px 18px; + border-radius: 11px; + font-size: 13px; + font-weight: 700; + } + + QPushButton:hover { + background-color: #312E81; + border: 1px solid #C084FC; + } + + QPushButton:pressed { + background-color: #581C87; + } + + #RefreshButton { + background-color: #C084FC; + color: #080A14; + border: none; + } + + #RefreshButton:hover { + background-color: #E879F9; + } + + #RefreshButton:disabled { + background-color: #475569; + color: #CBD5E1; + } + + #ArticleScrollArea { + background-color: transparent; + border: none; + } + + #ArticleCard { + background-color: #111827; + border: 1px solid #2A3144; + border-radius: 22px; + } + + #ArticleCard:hover { + background-color: #161F33; + border: 1px solid #C084FC; + } + + #ArticleTitle { + color: #FFFFFF; + font-size: 17px; + font-weight: 800; + line-height: 1.3; + } + + #ArticleMeta { + color: #C084FC; + font-size: 12px; + font-weight: 700; + } + + #ArticleSummary { + color: #CBD5E1; + font-size: 13px; + line-height: 1.5; + } + + #OpenArticleLabel { + color: #F0ABFC; + font-size: 12px; + font-weight: 800; + } + + #EmptyLabel { + color: #94A3B8; + font-size: 16px; + padding: 60px; + } + + QScrollBar:vertical { + background: transparent; + width: 10px; + margin: 4px; + } + + QScrollBar::handle:vertical { + background: #374151; + border-radius: 5px; + } + + QScrollBar::handle:vertical:hover { + background: #C084FC; + } + + QScrollBar::add-line:vertical, + QScrollBar::sub-line:vertical { + height: 0px; + } + """ + ) + + def load_feeds(self) -> list[dict]: + try: + with open(FEEDS_FILE, "r", encoding="utf-8") as file: + return json.load(file) + except FileNotFoundError: + QMessageBox.critical( + self, + "Missing feeds file", + f"Could not find:\n{FEEDS_FILE}", + ) + return [] + except json.JSONDecodeError as error: + QMessageBox.critical( + self, + "Invalid feeds file", + f"Your feeds.json file has invalid JSON:\n{error}", + ) + return [] + + def refresh_news(self): + self.clear_article_cards() + + self.refresh_button.setEnabled(False) + self.refresh_button.setText("Refreshing...") + + feeds = self.load_feeds() + print("Feeds loaded:", feeds) + + for feed in feeds: + university_name = feed.get("name", "Unknown university") + feed_url = feed.get("url", "") + + print("Fetching:", university_name, feed_url) + + try: + articles = fetch_feed(university_name, feed_url) + print("Fetched articles:", len(articles)) + + self.database.save_articles(articles) + print("Saved articles. DB now has:", len(self.database.get_articles(limit=5000))) + + except Exception as error: + print(f"Could not fetch {university_name}: {error}") + + self.articles = self.database.get_articles() + print("Articles loaded from DB:", len(self.articles)) + + self.render_articles() + + self.refresh_button.setEnabled(True) + self.refresh_button.setText("Refresh news") + + def load_cached_articles(self): + self.articles = self.database.get_articles() + self.render_articles() + + def open_article(self, item: QListWidgetItem): + url = item.data(ARTICLE_LINK_ROLE) + + if url: + QDesktopServices.openUrl(QUrl(url)) + + def clear_article_cards(self): + while self.article_layout.count(): + item = self.article_layout.takeAt(0) + + widget = item.widget() + + if widget is not None: + widget.deleteLater() + + def render_articles(self): + self.clear_article_cards() + + if not self.articles: + empty_label = QLabel("No articles found.\nTry refreshing your feeds.") + empty_label.setObjectName("EmptyLabel") + empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + self.article_layout.addWidget(empty_label) + self.article_layout.addStretch() + return + + for article in self.articles: + card = self.create_article_card(article) + self.article_layout.addWidget(card) + + self.article_layout.addStretch() + + def create_article_card(self, article: dict) -> QFrame: + card = QFrame() + card.setObjectName("ArticleCard") + card.setCursor(Qt.CursorShape.PointingHandCursor) + + card_layout = QHBoxLayout(card) + card_layout.setContentsMargins(18, 18, 18, 18) + card_layout.setSpacing(18) + + image_box = QLabel() + image_box.setObjectName("ArticleImage") + image_box.setFixedSize(132, 92) + image_box.setAlignment(Qt.AlignmentFlag.AlignCenter) + + university = article.get("university", "UniNews") + initials = "".join(word[0] for word in university.split()[:2]).upper() + + image_box.setText(initials) + + content_layout = QVBoxLayout() + content_layout.setSpacing(8) + + title = QLabel(article.get("title", "Untitled")) + title.setObjectName("ArticleTitle") + title.setWordWrap(True) + + published = article.get("published", "Unknown date") + + meta = QLabel(f"{university} • {published}") + meta.setObjectName("ArticleMeta") + meta.setWordWrap(True) + + summary_text = self.clean_text(article.get("summary", "")) + + if len(summary_text) > 230: + summary_text = summary_text[:230].strip() + "..." + + summary = QLabel(summary_text) + summary.setObjectName("ArticleSummary") + summary.setWordWrap(True) + + open_label = QLabel("Open article →") + open_label.setObjectName("OpenArticleLabel") + + content_layout.addWidget(title) + content_layout.addWidget(meta) + content_layout.addWidget(summary) + content_layout.addWidget(open_label) + + card_layout.addWidget(image_box) + card_layout.addLayout(content_layout) + + link = article.get("link", "") + + def open_link(event): + if link: + QDesktopServices.openUrl(QUrl(link)) + + card.mousePressEvent = open_link + + return card + + def clean_text(self, text: str) -> str: + clean = text.replace("\n", " ").replace("\r", " ").strip() + return " ".join(clean.split()) + + def open_settings(self): + dialog = SettingsDialog(self) + + if dialog.exec(): + self.app_settings = load_app_settings() + self.apply_theme() + self.apply_refresh_timer() + + max_cached_articles = self.app_settings.get("max_cached_articles", 500) + self.database.enforce_article_limit(max_cached_articles) + + self.articles = self.database.get_articles() + self.render_articles() + + self.statusBar().showMessage("Settings saved.") + + def handle_notifications(self, new_articles: list[dict]) -> None: + if not self.app_settings.get("notifications_enabled", False): + return + + keywords = self.app_settings.get("notification_keywords", []) + + for article in new_articles: + if should_notify(article, keywords): + send_article_notification(article) + + def apply_refresh_timer(self) -> None: + interval_minutes = self.app_settings.get("refresh_interval_minutes", 0) + + self.refresh_timer.stop() + + if interval_minutes and interval_minutes > 0: + self.refresh_timer.start(interval_minutes * 60 * 1000) + + def apply_theme(self) -> None: + theme = self.app_settings.get("theme", "light") + + if theme == "dark": + self.apply_dark_theme() + else: + self.apply_light_theme() diff --git a/ui/settings_dialog.py b/ui/settings_dialog.py new file mode 100644 index 0000000..d84c03e --- /dev/null +++ b/ui/settings_dialog.py @@ -0,0 +1,134 @@ +from PyQt6.QtWidgets import ( + QCheckBox, + QComboBox, + QDialog, + QFormLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QSpinBox, + QVBoxLayout, +) + +from app_settings import load_app_settings, save_app_settings + + +class SettingsDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + + self.setWindowTitle("UniNews Settings") + self.resize(420, 300) + + self.settings = load_app_settings() + + self.setup_ui() + self.load_values() + + def setup_ui(self): + main_layout = QVBoxLayout(self) + + title = QLabel("Settings") + title.setStyleSheet("font-size: 22px; font-weight: bold;") + + form_layout = QFormLayout() + + self.refresh_on_startup_checkbox = QCheckBox("Refresh news when app starts") + + self.refresh_interval_combo = QComboBox() + self.refresh_interval_combo.addItem("Manual only", 0) + self.refresh_interval_combo.addItem("Every 15 minutes", 15) + self.refresh_interval_combo.addItem("Every 30 minutes", 30) + self.refresh_interval_combo.addItem("Every 1 hour", 60) + + self.notifications_checkbox = QCheckBox("Enable desktop notifications") + + self.keywords_input = QLineEdit() + self.keywords_input.setPlaceholderText("deadline, scholarship, exam") + + self.theme_combo = QComboBox() + self.theme_combo.addItem("Light", "light") + self.theme_combo.addItem("Dark", "dark") + + self.max_cache_spinbox = QSpinBox() + self.max_cache_spinbox.setMinimum(50) + self.max_cache_spinbox.setMaximum(5000) + self.max_cache_spinbox.setSingleStep(50) + + form_layout.addRow("", self.refresh_on_startup_checkbox) + form_layout.addRow("Refresh interval:", self.refresh_interval_combo) + form_layout.addRow("", self.notifications_checkbox) + form_layout.addRow("Notification keywords:", self.keywords_input) + form_layout.addRow("Theme:", self.theme_combo) + form_layout.addRow("Max cached articles:", self.max_cache_spinbox) + + button_layout = QHBoxLayout() + + self.cancel_button = QPushButton("Cancel") + self.cancel_button.clicked.connect(self.reject) + + self.save_button = QPushButton("Save") + self.save_button.clicked.connect(self.save_settings) + + button_layout.addStretch() + button_layout.addWidget(self.cancel_button) + button_layout.addWidget(self.save_button) + + main_layout.addWidget(title) + main_layout.addLayout(form_layout) + main_layout.addStretch() + main_layout.addLayout(button_layout) + + def load_values(self): + self.refresh_on_startup_checkbox.setChecked( + self.settings.get("refresh_on_startup", True) + ) + + refresh_interval = self.settings.get("refresh_interval_minutes", 0) + index = self.refresh_interval_combo.findData(refresh_interval) + + if index >= 0: + self.refresh_interval_combo.setCurrentIndex(index) + + self.notifications_checkbox.setChecked( + self.settings.get("notifications_enabled", False) + ) + + keywords = self.settings.get("notification_keywords", []) + self.keywords_input.setText(", ".join(keywords)) + + theme = self.settings.get("theme", "light") + theme_index = self.theme_combo.findData(theme) + + if theme_index >= 0: + self.theme_combo.setCurrentIndex(theme_index) + + self.max_cache_spinbox.setValue( + self.settings.get("max_cached_articles", 500) + ) + + def save_settings(self): + keywords_text = self.keywords_input.text().strip() + + keywords = [ + keyword.strip() + for keyword in keywords_text.split(",") + if keyword.strip() + ] + + self.settings["refresh_on_startup"] = ( + self.refresh_on_startup_checkbox.isChecked() + ) + self.settings["refresh_interval_minutes"] = ( + self.refresh_interval_combo.currentData() + ) + self.settings["notifications_enabled"] = ( + self.notifications_checkbox.isChecked() + ) + self.settings["notification_keywords"] = keywords + self.settings["theme"] = self.theme_combo.currentData() + self.settings["max_cached_articles"] = self.max_cache_spinbox.value() + + save_app_settings(self.settings) + self.accept() \ No newline at end of file