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