diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..6c76f58 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,106 @@ +name: UniNews CI/CD Pipeline + +on: + push: + branches: ["main", "geo"] + tags: ["v*"] + pull_request: + branches: ["main"] + +jobs: + validate-and-package: + runs-on: ubuntu-24.04 + + permissions: + contents: write + + steps: + - name: Checkout Source Code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Python Dependencies + run: | + python -m pip install --upgrade pip + pip install ruff setuptools wheel + pip install -r requirements.txt + + - name: Run Ruff Static Analysis + run: | + ruff check . --fix + + - name: Install Native Build Tools + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential \ + debhelper \ + dh-python \ + python3-all \ + python3-setuptools \ + python3-pyqt6 \ + python3-bs4 \ + python3-requests \ + python3-feedparser + + - name: Scaffold Debian Control Files + run: | + mkdir -p debian + mkdir -p debian/source + + echo "3.0 (native)" > debian/source/format + + cat << 'EOF' > debian/rules + #!/usr/bin/make -f + %: + dh $@ --with python3 --buildsystem=pybuild + EOF + chmod +x debian/rules + + cat << 'EOF' > debian/control + Source: uninews + Section: python + Priority: optional + Maintainer: George Apostolidis + Build-Depends: debhelper-compat (= 13), dh-python, python3-all, python3-setuptools + Standards-Version: 4.6.2 + Homepage: https://github.com/open-source-uom/UniNews + + Package: python3-uninews + Architecture: all + Depends: ${python3:Depends}, ${misc:Depends}, python3-pyqt6, python3-bs4, python3-requests, python3-feedparser + Description: University RSS and news reader desktop app + UniNews is a PyQt6 desktop application that aggregates university + RSS feeds and selected university news pages into one clean interface. + EOF + + DEB_DATE=$(date -R) + DEB_AUTHOR=$(git log -1 --pretty=format:'%an <%ae>') + + printf "uninews (0.1.0) noble; urgency=low\n\n * Auto-generated release.\n\n -- %s %s\n" "$DEB_AUTHOR" "$DEB_DATE" > debian/changelog + + - name: Build Debian Packages + run: | + dpkg-buildpackage -us -uc -b + dpkg-buildpackage -us -uc -S + + mkdir -p output_package + mv ../uninews_* output_package/ + + - name: Upload Package Artifacts + uses: actions/upload-artifact@v4 + with: + name: uninews-packages + path: output_package/* + + - name: Publish .deb to GitHub Releases + uses: softprops/action-gh-release@v2 + if: startsWith(github.ref, 'refs/tags/') + with: + files: output_package/*.deb \ No newline at end of file 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/README.md b/README.md index 171c8b9..1ed06b6 100644 --- a/README.md +++ b/README.md @@ -1 +1,187 @@ -# UniNews \ No newline at end of file +# UniNews + +UniNews is a small desktop application that brings together news and announcements from universities into a single place. + +The project started as a simple way to avoid visiting multiple university websites every day just to check for updates. Instead of opening several tabs, RSS feeds, and department pages, UniNews collects everything into one application and presents it in a clean interface. + +The application is written in Python using PyQt6 and currently supports both RSS feeds and custom website scrapers. News articles are stored locally so that previously fetched content remains available even when a source is temporarily unavailable. + +## Features + +- Read news from multiple universities in one place +- Support for RSS feeds +- Support for custom HTML scrapers +- Local article caching using SQLite +- Search articles by keyword +- Filter articles by university +- Filter articles by specific source or department +- Light and dark themes +- Desktop notifications for new articles +- Configurable refresh intervals +- Local settings storage + +## Project Structure + +```text +UniNews/ +├── scrapers/ +├── ui/ +├── data/ +├── local_data/ +├── app_settings.py +├── database.py +├── main.py +├── notifications.py +├── rss_service.py +├── scraper_service.py +├── settings.py +└── requirements.txt +``` + +## Installation + +Clone the repository: + +```bash +git clone https://github.com/open-source-uom/UniNews.git +cd UniNews +``` + +Create a virtual environment: + +```bash +python -m venv .venv +``` + +Activate it: + +### Windows + +```powershell +.venv\Scripts\activate +``` + +### Linux + +```bash +source .venv/bin/activate +``` + +Install dependencies: + +```bash +pip install -r requirements.txt +``` + +## Running the Application + +```bash +python main.py +``` + +## Adding a New RSS Feed + +Open `data/feeds.json` and add a new entry: + +```json +{ + "name": "MIT News", + "type": "rss", + "url": "https://news.mit.edu/rss/feed" +} +``` + +## Adding a New Scraper + +Create a new file inside the `scrapers` folder. + +Example: + +```python +from scrapers.base_scraper import BaseScraper + +class ExampleScraper(BaseScraper): + + def scrape(self): + return [] +``` + +Register it in `scraper_service.py`: + +```python +SCRAPER_REGISTRY = { + "example": ExampleScraper, +} +``` + +Then add it to `feeds.json`: + +```json +{ + "name": "Example University", + "type": "scraper", + "scraper": "example" +} +``` + +## Data Storage + +UniNews stores its local data inside: + +```text +local_data/ +``` + +This includes: + +- Cached articles +- Application settings +- SQLite database + +Deleting the database file will force the application to rebuild its cache the next time it runs. + +## Development + +Lint the code: + +```bash +ruff check . +``` + +Automatically fix common issues: + +```bash +ruff check . --fix +``` + +## Roadmap + +Planned improvements include: + +- More university sources +- Better image handling +- Pagination +- Source management from the UI +- Export and backup options +- Packaging for Linux distributions +- Automatic update checks + +## Contributing + +Contributions are welcome. + +The easiest way to contribute is by adding support for additional universities through RSS feeds or custom scrapers. + +Before opening a pull request: + +1. Make sure the application runs correctly. +2. Run the linter. +3. Keep changes focused and easy to review. + +## License + +<<<<<<< HEAD +This project is released under the MIT License. +======= +This project is released under the GNU General Public License v3.0. +>>>>>>> d4366a7acaeea3b24f79877cde7e2b7fea51bfa7 diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/__pycache__/app_settings.cpython-310.pyc b/__pycache__/app_settings.cpython-310.pyc new file mode 100644 index 0000000..e1d214d 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..2579ca9 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..a5736d7 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..fbb624b --- /dev/null +++ b/app_settings.py @@ -0,0 +1,42 @@ +import json + +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..e9955b2 --- /dev/null +++ b/data/feeds.json @@ -0,0 +1,12 @@ +[ + { + "name": "MIT News", + "type": "rss", + "url": "https://news.mit.edu/rss/feed" + }, + { + "name": "University of Macedonia", + "type": "scraper", + "scraper": "uom_news" + } +] \ 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..186ce50 --- /dev/null +++ b/database.py @@ -0,0 +1,220 @@ +import sqlite3 +from pathlib import Path + +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 + + try: + self.connection.execute("ALTER TABLE articles ADD COLUMN source TEXT;") + self.connection.commit() + except sqlite3.OperationalError: + pass + + def save_article(self, article: dict) -> None: + query = """ + INSERT \ + OR IGNORE INTO articles ( + university, + source, + title, + summary, + link, + published, + fetched_at, + image_url + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?); \ + """ + + self.connection.execute( + query, + ( + article.get("university", "Unknown university"), + article.get("source", article.get("university", "Unknown source")), + 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=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 \ + """ + + params = [] + + if university and university != "All": + query += " AND university = ?" + params.append(university) + + if source and source != "All": + query += " AND 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 ? + ) + """ + 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.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; + """ + + cursor = self.connection.execute(query) + rows = cursor.fetchall() + + return [row["university"] for row in rows] + + def get_sources_for_university(self, university: str) -> list[str]: + cursor = self.connection.execute( + """ + SELECT DISTINCT source + FROM articles + WHERE university = ? + ORDER BY source ASC; + """, + (university,), + ) + + return [row["source"] for row in cursor.fetchall() if row["source"]] \ No newline at end of file diff --git a/local_data/settings.json b/local_data/settings.json new file mode 100644 index 0000000..7c4e9c0 --- /dev/null +++ b/local_data/settings.json @@ -0,0 +1,13 @@ +{ + "refresh_on_startup": true, + "refresh_interval_minutes": 0, + "notifications_enabled": true, + "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..42704b4 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..ec9d5df 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..670a935 --- /dev/null +++ b/rss_service.py @@ -0,0 +1,46 @@ +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, + "source": university_name, + "title": entry.get("title", "Untitled"), + "summary": entry.get("summary", ""), + "link": entry.get("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/scrapers/__init__.py b/scrapers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scrapers/__pycache__/__init__.cpython-310.pyc b/scrapers/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..86fe48a Binary files /dev/null and b/scrapers/__pycache__/__init__.cpython-310.pyc differ diff --git a/scrapers/__pycache__/base_scraper.cpython-310.pyc b/scrapers/__pycache__/base_scraper.cpython-310.pyc new file mode 100644 index 0000000..995af45 Binary files /dev/null and b/scrapers/__pycache__/base_scraper.cpython-310.pyc differ diff --git a/scrapers/__pycache__/scraper_service.cpython-310.pyc b/scrapers/__pycache__/scraper_service.cpython-310.pyc new file mode 100644 index 0000000..d3388b8 Binary files /dev/null and b/scrapers/__pycache__/scraper_service.cpython-310.pyc differ diff --git a/scrapers/__pycache__/uom_scraper.cpython-310.pyc b/scrapers/__pycache__/uom_scraper.cpython-310.pyc new file mode 100644 index 0000000..41aaae4 Binary files /dev/null and b/scrapers/__pycache__/uom_scraper.cpython-310.pyc differ diff --git a/scrapers/base_scraper.py b/scrapers/base_scraper.py new file mode 100644 index 0000000..4f6fc1c --- /dev/null +++ b/scrapers/base_scraper.py @@ -0,0 +1,9 @@ +from abc import ABC, abstractmethod + + +class BaseScraper(ABC): + name: str = "Base Scraper" + + @abstractmethod + def scrape(self) -> list[dict]: + pass \ No newline at end of file diff --git a/scrapers/scraper_service.py b/scrapers/scraper_service.py new file mode 100644 index 0000000..b071fe9 --- /dev/null +++ b/scrapers/scraper_service.py @@ -0,0 +1,16 @@ +from scrapers.uom_scraper import UomNewsScraper + + +SCRAPER_REGISTRY = { + "uom_news": UomNewsScraper, +} + + +def run_scraper(scraper_id: str) -> list[dict]: + scraper_class = SCRAPER_REGISTRY.get(scraper_id) + + if scraper_class is None: + raise ValueError(f"Unknown scraper: {scraper_id}") + + scraper = scraper_class() + return scraper.scrape() \ No newline at end of file diff --git a/scrapers/uom_scraper.py b/scrapers/uom_scraper.py new file mode 100644 index 0000000..d42b0e8 --- /dev/null +++ b/scrapers/uom_scraper.py @@ -0,0 +1,154 @@ +from datetime import datetime, timezone +from urllib.parse import urljoin + +import requests +from bs4 import BeautifulSoup + +from scrapers.base_scraper import BaseScraper + + +class UomNewsScraper(BaseScraper): + name = "University of Macedonia" + + def __init__(self): + self.base_url = "https://www.uom.gr" + + self.sources = [ + { + "label": "UoM Main News", + "url": "https://www.uom.gr/nea", + "pages": 2, + }, + { + "label": "UoM Economics", + "url": "https://www.uom.gr/eco/ta-teleytaia-nea-toy-tmhmatos", + "pages": 1, + }, + { + "label": "UoM Balkan, Slavic & Oriental Studies", + "url": "https://www.uom.gr/bso/ta-nea-mas", + "pages": 1, + }, + { + "label": "UoM Applied Informatics", + "url": "https://www.uom.gr/dai/ta-nea-toy-tmhmatos", + "pages": 1, + }, + { + "label": "UoM Business Administration", + "url": "https://www.uom.gr/ba/enhmerosh", + "pages": 1, + }, + { + "label": "UoM Accounting & Finance", + "url": "https://www.uom.gr/fin/enhmerosh", + "pages": 1, + }, + { + "label": "UoM International & European Studies", + "url": "https://www.uom.gr/ies/ta-teleytaia-nea-toy-tmhmatos", + "pages": 1, + }, + { + "label": "UoM Music Science & Art", + "url": "https://www.uom.gr/msa/enhmerosh", + "pages": 1, + }, + { + "label": "UoM Educational & Social Policy", + "url": "https://www.uom.gr/esp/ta-nea-mas", + "pages": 1, + }, + ] + + def scrape(self) -> list[dict]: + articles = [] + seen_links = set() + + for source in self.sources: + source_articles = self.scrape_source(source) + + for article in source_articles: + link = article.get("link") + + if link and link not in seen_links: + seen_links.add(link) + articles.append(article) + + return articles + + def scrape_source(self, source: dict) -> list[dict]: + articles = [] + + for page_number in range(1, source.get("pages", 1) + 1): + page_url = self.build_page_url(source["url"], page_number) + + try: + html = self.fetch_page(page_url) + articles.extend(self.parse_page(html, source["label"])) + except Exception as error: + print(f"Could not scrape {page_url}: {error}") + + return articles + + def build_page_url(self, base_url: str, page_number: int) -> str: + if page_number == 1: + return base_url + + separator = "&" if "?" in base_url else "?" + return f"{base_url}{separator}pn={page_number}" + + def fetch_page(self, url: str) -> str: + response = requests.get( + url, + timeout=15, + headers={ + "User-Agent": "UniNews/0.1 (+https://github.com/open-source-uom/UniNews)" + }, + ) + + response.raise_for_status() + return response.text + + def parse_page(self, html: str, source_label: str) -> list[dict]: + soup = BeautifulSoup(html, "html.parser") + results = [] + + news_items = soup.select("article.post-news.post-news-wide") + + for item in news_items: + title_link = item.select_one("h6 a") + + if not title_link: + continue + + title = title_link.get_text(strip=True) + link = urljoin(self.base_url, title_link.get("href", "")) + + image = item.select_one("img") + image_url = "" + + if image and image.get("src"): + image_url = urljoin(self.base_url, image.get("src")) + + date_spans = item.select(".post-news-meta span.small") + published = ( + date_spans[0].get_text(strip=True) + if date_spans + else "Unknown date" + ) + + results.append( + { + "university": self.name, + "source": source_label, + "title": title, + "summary": source_label, + "link": link, + "published": published, + "fetched_at": datetime.now(timezone.utc).isoformat(), + "image_url": image_url, + } + ) + + return results \ 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/setup.py b/setup.py new file mode 100644 index 0000000..7b52ccd --- /dev/null +++ b/setup.py @@ -0,0 +1,33 @@ +from setuptools import setup + + +setup( + name="uninews", + version="0.1.0", + py_modules=[ + "app_settings", + "database", + "main", + "notifications", + "rss_service", + "scraper_service", + "settings", + ], + packages=[ + "ui", + "scrapers", + ], + include_package_data=True, + install_requires=[ + "PyQt6", + "feedparser", + "requests", + "beautifulsoup4", + "plyer", + ], + entry_points={ + "console_scripts": [ + "uninews=main:main", + ], + }, +) \ No newline at end of file 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..3339a15 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..98901a8 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..dcb9a1c --- /dev/null +++ b/ui/main_window.py @@ -0,0 +1,959 @@ +import json + +from PyQt6.QtCore import Qt, QUrl +from PyQt6.QtWidgets import ( + QFrame, + QHBoxLayout, + QLabel, + QListWidget, + QListWidgetItem, + QMainWindow, + QMessageBox, + QPushButton, + QVBoxLayout, + QWidget, QScrollArea, QLineEdit, +) +from PyQt6.QtGui import QDesktopServices, QPixmap + +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 +from scrapers.scraper_service import run_scraper + +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.selected_university = "All" + self.selected_source = "All" + self.search_text = "" + + + 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() + else: + QTimer.singleShot(1500, self.refresh_news) + + self.refresh_timer = QTimer(self) + self.refresh_timer.timeout.connect(self.refresh_news) + + self.apply_theme() + self.apply_refresh_timer() + + def get_theme_stylesheet(self, theme: str) -> str: + if theme == "dark": + bg = "#080A14" + panel = "#111827" + panel_2 = "#0B1220" + border = "#2A3144" + text = "#F8FAFC" + muted = "#CBD5E1" + accent = "#C084FC" + accent_hover = "#E879F9" + input_bg = "#111827" + card = "#111827" + card_hover = "#161F33" + image_bg = "#1F2937" + else: + bg = "#F4F0FA" + panel = "#FFFFFF" + panel_2 = "#FFFFFF" + border = "#E6DDF3" + text = "#171124" + muted = "#4B5563" + accent = "#A855F7" + accent_hover = "#9333EA" + input_bg = "#F9F5FF" + card = "#FFFFFF" + card_hover = "#FBF7FF" + image_bg = "#F3E8FF" + + return f""" + QMainWindow, QDialog {{ + background-color: {bg}; + }} + + QWidget {{ + font-family: "Segoe UI", "Inter", "Arial"; + color: {text}; + }} + + #HeaderCard, #Sidebar {{ + background-color: {panel}; + border: 1px solid {border}; + border-radius: 24px; + }} + + #TitleLabel {{ + color: {text}; + font-size: 34px; + font-weight: 900; + }} + + #SubtitleLabel, #ArticleMeta, #OpenArticleLabel {{ + color: {accent}; + font-weight: 700; + }} + + QPushButton {{ + background-color: {panel_2}; + color: {text}; + border: 1px solid {border}; + padding: 10px 18px; + border-radius: 11px; + font-size: 13px; + font-weight: 700; + }} + + QPushButton:hover {{ + background-color: {card_hover}; + border: 1px solid {accent}; + }} + + #RefreshButton, #SaveButton {{ + background-color: {accent}; + color: white; + border: none; + }} + + #RefreshButton:hover, #SaveButton:hover {{ + background-color: {accent_hover}; + }} + + QLineEdit, QComboBox, QSpinBox {{ + background-color: {input_bg}; + color: {text}; + border: 1px solid {border}; + border-radius: 10px; + padding: 8px 10px; + font-size: 13px; + }} + + QCheckBox {{ + color: {text}; + spacing: 8px; + }} + + QScrollArea, QScrollArea > QWidget > QWidget, #ArticleContainer {{ + background-color: {bg}; + border: none; + }} + + #ArticleCard {{ + background-color: {card}; + border: 1px solid {border}; + border-radius: 24px; + }} + + #ArticleCard:hover {{ + background-color: {card_hover}; + border: 1px solid {accent}; + }} + + #ArticleImage {{ + background-color: {image_bg}; + border: 1px solid {border}; + border-radius: 18px; + color: {accent}; + font-size: 24px; + font-weight: 900; + }} + + #ArticleTitle, #SidebarTitle {{ + color: {text}; + font-weight: 900; + }} + + #ArticleSummary {{ + color: {muted}; + font-size: 13px; + }} + + #SourceList {{ + background-color: transparent; + border: none; + outline: none; + }} + + #SourceList::item {{ + color: {muted}; + padding: 10px 12px; + border-radius: 10px; + }} + + #SourceList::item:hover {{ + background-color: {card_hover}; + color: {text}; + }} + + #SourceList::item:selected {{ + background-color: {accent}; + color: white; + font-weight: 800; + }} + """ + + 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.content_layout = QHBoxLayout() + self.content_layout.setSpacing(20) + + self.create_sidebar() + self.create_article_list() + + self.content_layout.addWidget(self.sidebar) + self.content_layout.addWidget(self.scroll_area, stretch=1) + + self.root_layout.addLayout(self.content_layout) + + self.setCentralWidget(self.root) + + def update_source_list(self): + self.source_list.clear() + + all_item = QListWidgetItem("All") + all_item.setData(Qt.ItemDataRole.UserRole, {"university": "All", "source": "All"}) + self.source_list.addItem(all_item) + + universities = self.database.get_universities() + + for university in universities: + university_item = QListWidgetItem(university) + university_item.setData( + Qt.ItemDataRole.UserRole, + {"university": university, "source": "All"}, + ) + self.source_list.addItem(university_item) + + sources = self.database.get_sources_for_university(university) + + for source in sources: + if source == university: + continue + + source_item = QListWidgetItem(f" └ {source}") + source_item.setData( + Qt.ItemDataRole.UserRole, + {"university": university, "source": source}, + ) + self.source_list.addItem(source_item) + + self.source_list.setCurrentRow(0) + + def on_source_selected(self, item: QListWidgetItem): + data = item.data(Qt.ItemDataRole.UserRole) + + self.selected_university = data.get("university", "All") + self.selected_source = data.get("source", "All") + + self.load_filtered_articles() + + def on_search_changed(self, text: str): + self.search_text = text.strip() + 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") + self.sidebar.setFixedWidth(230) + + sidebar_layout = QVBoxLayout(self.sidebar) + sidebar_layout.setContentsMargins(18, 18, 18, 18) + sidebar_layout.setSpacing(14) + + title = QLabel("Sources") + title.setObjectName("SidebarTitle") + + self.search_input = QLineEdit() + self.search_input.setObjectName("SearchInput") + self.search_input.setPlaceholderText("Search articles...") + self.search_input.textChanged.connect(self.on_search_changed) + + self.source_list = QListWidget() + self.source_list.setObjectName("SourceList") + self.source_list.itemClicked.connect(self.on_source_selected) + + sidebar_layout.addWidget(title) + sidebar_layout.addWidget(self.search_input) + sidebar_layout.addWidget(self.source_list) + sidebar_layout.addStretch() + + 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) + + 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; + } + + #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; + border: 1px solid #E9D5FF; + border-radius: 10px; + 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; + } + """ + ) + + if hasattr(self, "article_container"): + self.article_container.setStyleSheet("background-color: #F4F0FA;") + + def apply_dark_theme(self): + self.setStyleSheet( + """ + #Sidebar { + background-color: #0B1220; + border: 1px solid #1E293B; + border-radius: 22px; + } + + #SidebarTitle { + color: #FFFFFF; + font-size: 18px; + font-weight: 900; + } + + #SearchInput { + background-color: #111827; + color: #F8FAFC; + border: 1px solid #334155; + border-radius: 10px; + 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; + font-weight: 800; + } + 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() + + for feed in feeds: + university_name = feed.get("name", "Unknown university") + feed_url = feed.get("url", "") + + + try: + source_type = feed.get("type", "rss") + + if source_type == "scraper": + scraper_id = feed.get("scraper") + articles = run_scraper(scraper_id) + else: + articles = fetch_feed(university_name, feed_url) + + self.database.save_articles(articles) + + 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.update_source_list() + self.load_filtered_articles() + + def load_cached_articles(self): + self.update_source_list() + self.load_filtered_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") + + source = article.get("source", university) + published = article.get("published", "Unknown date") + + meta = QLabel(f"{university} • {source} • {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) + dialog.setStyleSheet(self.get_theme_stylesheet(self.app_settings.get("theme", "light"))) + + 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") + stylesheet = self.get_theme_stylesheet(theme) + self.setStyleSheet(stylesheet) + + if hasattr(self, "article_container"): + bg = "#080A14" if theme == "dark" else "#F4F0FA" + self.article_container.setStyleSheet(f"background-color: {bg};") + + diff --git a/ui/settings_dialog.py b/ui/settings_dialog.py new file mode 100644 index 0000000..712428b --- /dev/null +++ b/ui/settings_dialog.py @@ -0,0 +1,135 @@ +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.setObjectName("SaveButton") + 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