From e1b39cf18318bf7cc1268f5f49df0404eaa2080b Mon Sep 17 00:00:00 2001 From: George - David Apostolidis Date: Mon, 1 Jun 2026 22:34:47 +0200 Subject: [PATCH 1/6] First implementation --- .idea/.gitignore | 8 + .idea/UniNews.iml | 10 + .idea/deployment.xml | 21 + .idea/inspectionProfiles/Project_Default.xml | 21 + .../inspectionProfiles/profiles_settings.xml | 6 + .idea/misc.xml | 7 + .idea/modules.xml | 8 + .idea/vcs.xml | 7 + __init__.py | 0 __pycache__/app_settings.cpython-310.pyc | Bin 0 -> 1246 bytes __pycache__/database.cpython-310.pyc | Bin 0 -> 3940 bytes __pycache__/notifications.cpython-310.pyc | Bin 0 -> 980 bytes __pycache__/rss_service.cpython-310.pyc | Bin 0 -> 761 bytes __pycache__/settings.cpython-310.pyc | Bin 0 -> 783 bytes app_settings.py | 43 ++ data/feeds.json | 10 + data/settings.json | 7 + database.py | 130 ++++++ main.py | 18 + notifications.py | 24 + requirements.txt | Bin 0 -> 412 bytes rss_service.py | 29 ++ settings.py | 30 ++ ui/__init__.py | 0 ui/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 160 bytes ui/__pycache__/main_window.cpython-310.pyc | Bin 0 -> 11520 bytes .../settings_dialog.cpython-310.pyc | Bin 0 -> 3626 bytes ui/main_window.py | 415 ++++++++++++++++++ ui/settings_dialog.py | 134 ++++++ 29 files changed, 928 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/UniNews.iml create mode 100644 .idea/deployment.xml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 __init__.py create mode 100644 __pycache__/app_settings.cpython-310.pyc create mode 100644 __pycache__/database.cpython-310.pyc create mode 100644 __pycache__/notifications.cpython-310.pyc create mode 100644 __pycache__/rss_service.cpython-310.pyc create mode 100644 __pycache__/settings.cpython-310.pyc create mode 100644 app_settings.py create mode 100644 data/feeds.json create mode 100644 data/settings.json create mode 100644 database.py create mode 100644 main.py create mode 100644 notifications.py create mode 100644 requirements.txt create mode 100644 rss_service.py create mode 100644 settings.py create mode 100644 ui/__init__.py create mode 100644 ui/__pycache__/__init__.cpython-310.pyc create mode 100644 ui/__pycache__/main_window.cpython-310.pyc create mode 100644 ui/__pycache__/settings_dialog.cpython-310.pyc create mode 100644 ui/main_window.py create mode 100644 ui/settings_dialog.py 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/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/__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 0000000000000000000000000000000000000000..ec2c91ef909860522b080053cdce5aa9c8bd683e GIT binary patch literal 1246 zcmY*YL2uhO6ecN3RvbHVlC>Dtc89INro#&CRusdqpiYVmg%{KgiU|e;MWih{k|>aL zofOWgy{7vEJ0!dHFY!8HK<_){ub;wdXc#p89I<+4bX&P5%sr*&P3EHy$_G3BReY5hWsb0hx62t|S2 zR(zzm&J$TB+N8=%Yy0KWCo03$tF%Z;QB4ik_D&^@7=4PT6JDjq1!uS2$q7H3NyW7F zP0mYhcgplM$A^>cyBO9#=E3f zsE=NMZ+rtZFv7eG?~nF5GQnkIj;`=IehFkym~T9cs_;joAI{*Uo4}Sd93)G|Y^S@ozkBq2kStGFn#uaiQd#rLQb(wzg-n?ZrZr0q zx0}EAf9rKQVDUYrq>4OCN21_1Tn3FdORmPy-lgL`Qum>M*WIU2hDVw!JsfkH*2AvM zrX{ZotcA&^$BE*#)WS$5xE>x=qQ_@?xSH=HNNvldbE}Fk977Hv#BLJO!7Y6MW9T(; zKsHDSEApTE8aAkVpfb2?p9cZ!Gs^>57&O5X0&D`G7x0*j2i$s$G9S3zI>&}i{5hIq z7O=(zu)4}H17?8ZZ&x{Xz9{SsB4QhLs$99il_MmPCl=3C8$i_ts1NYi5xlNIR1CI7 zf$D(628&bHFHe|I>WsDKAa1}Mjtze*C7TxfXV+NmG6_)&w@By_ QN-)6z22&PX%8_&@2nM_hX7p_6NFb@S0(fCLFyZf*sI$9?;Dm;2t^-|lKq zt(GNtzWvub`~R*=(m!#s_{cE118;N-1|~5QNL~D<0qN5&B}mJG+*i6vN-Kfd*SZ>! z?n+E$+Ofp6=d@d5q$Qc9Z@`9Rn

0yYVCV)^~=nKM36b=;~IzUH`b=vYh+%_NN|- zE&M}g{eJ-EwM6ibhgZw zc}aLY?7d}{0pyTF%u1y)z4-xqn6DVau3XRB-t?AO5lC3YF~E&#R8eg@PE zdjloYUwV_h1)Pgah3&qR=v!{=K6E3VRN0hrhOl#w!X9Q1(s;_7@J7FeVL~R7BgN!| zPNaS56lZcw_7#}PjyjP}v{MPsx+T3JwpkJy=p?1+An;@Uhos~U!jOA$Ql1iUzFoOF zc-mbSgQrf<_2PjzOsbyXZpylVGMIWel&XM2tFJ{KA7`)wA%@Nn?H?qXB*uZ9wxfu_@N&=&JylThe0DXQYIBL zzBoIYjhRfM2_BC`XnO<%XHVgD-UTirM{pwh(g~T6Q$nParn3_ z_D;hFbN4K}eXXD|3jHT=On!W5w5>1O>2KJb?d_se?8gEBUjosn-*-i3qo^AA;p3vF z(XktU>R3gw;pk!DM~^s5Ws7S)9(%CjxN*@?z7g|APQH)@qAe-$XWSdbJkh*CzwgH; z6&2*G2O};H(;&%)8gRuj3>V;yFdh|Bsnp1LJ-_8bxFx0O*%yF-AVB*m5O#rpt4<*R zS4}4X2gLYkMgiKU6rg=d={W^>pYoQG*V9fRQKf+*=YdkO$N$K8+^}0#vu*4&jmBMj zrwNf_w|8C_AoJ@k9&viXMb`96i%h)DA}7DNB71&kxua%IeO}-0SS{n~?Q2HiZ{Z%3 z^4!(&YA1Xg4xWa_oR_Gn%Zc6zQ-VPj$zm{(5(V8%mU7p|m6-uRIPaF{&Y4OYE@Bm~ zERpwkEY^TBLO$eipW%yQ@{730XnW(UnECW*UE8r@~W$J|f87l8zo}6Y% zg=x8F#*y~vi97*Yr}U^aDV->%(hBrJU-;$nV)n2m4f6t??Wo=uV?R=W$%VN~d zRAk|4KefL@J>+2IWUTU%C_efs33oV|y z*y155@R^=uHkprwlg;5VThDR>fCn4HhHYHUG91F`C!d1lpOl77@EWKts-A`I;+lD5 zahB3)pv(%_@8JB-Ewz_<*c%8hueMHFd!FEpWzE`38E);}{MODDhwg6$G;CA0ZSixU z%vEggL$}WzCn-Blf51io(iO)!7`Z{VA}}lk28O`yFl`gX`$%EU#Wo{~g@JQKO(MfA zk^L|hzd_z_alr5rAK);DLQ185Css`S*CM)_604-Ft*)%UO*Kj=QNGhC{XvuI_f(H-zJCH$W(@E@jLCR%(r^uxW(9X;Z446`WP25Oz>M55o!c7F@u PA7S^b0~X;!SLOc#1SoBv literal 0 HcmV?d00001 diff --git a/__pycache__/notifications.cpython-310.pyc b/__pycache__/notifications.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ef129c9fc68d227844b0319f9c8111813316270f GIT binary patch literal 980 zcmY*X&2AGh5VpPEpQMTS5o*-}Qm%W6khq~JLTb6@QpzC;inePf-Lkvh+TONX1qsw5 z;J_0gq~_Q+;Te44zyb9YDuftsLrJ}wPaf}hzVSCB+U+KS_2bv$eN7PhX`SWef%6

3O%n74w z*Pd&=HFS>W=rhvfBbs35G5;)>;VpEEPvKS=3|<;nCmM*+748^cm01?aD#noe-?F5Z z@F|#j1n?dRUN_$289qmI^a&lJR|um6!Vsjy19+U9iX$#VVo3Li@fT0Jz=mpmtS`{j zi_K@F9mS;@O}U7Q(UyqIjOQBsMDLZmW62Al5-nuH)o3SA2K-2kmK9UoqB4!%l;`}o zko}gejRKH&8|b8?_C%Soaeblc_EHf?sp@xc!b8V6Q?8}$x^bes3W>B&ls1LDW1(%~ zE`Vv@%4Q>!rK6bQ4Toa#XO+qaa4@)~noVcNW)rL)o4Q4qkvRe7Y`YvrVNW#PC(NDU zujm_@friZcKwy{%^I7nP!iZT_8w1lU^W+dDOLS%Yg(T84oUDL4oy7p1+*@)w6DhLJ z<#!0W3;;q}Uod{gm5Qc(7(_)e&ZCSQUndzCr4Ai=A0nk~`p~-;5YB3GESX%lP%BhJ zt5_5s2~E@Az(rHyQeIkNWg}eb@vc>3?oN6o zZ-h3|?g~}6&6VJHM+^>@OoRhOqzDx>ZY_V5z1;QjEgqH zI#za_P<)zb>N~^$%Soui3tw74@io#?TUpgWiamQ5#`%u)89d(4X75%D=x=vvrc>$|QNO3uZtS!rVmAl=AHyy_! zA>K%ib4-HKd-jJ;^m9mq*}deTR-b_9B4f2*g0YTrS=o>lhJ6Ijd}jK-X?b6Z4;Uij LJD;MIyomk+`lrhS literal 0 HcmV?d00001 diff --git a/__pycache__/settings.cpython-310.pyc b/__pycache__/settings.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2b3a3a959997a6e9bfc12b87044a7fc5fdb843ac GIT binary patch literal 783 zcmYjN&2Abo5VpO}vcR&XRc}=vfD6YSsw&Dh5H(VYppc7&v|8c`ELqko+cZr?PW0M$ zNRB=AA^H|RRqCm)&`WERfSA>c=V$%-n@=N7fayfg_G{ zw7?8wmy?_ihWG~Q5xZpM1&IQCbzXGUD|xoYeh~PJ literal 0 HcmV?d00001 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..869295c --- /dev/null +++ b/data/feeds.json @@ -0,0 +1,10 @@ +[ + { + "name": "University of Oxford", + "url": "http://feeds.reuters.com/reuters/topNews" + }, + { + "name": "MIT News", + "url": "https://news.mit.edu/rss/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..bbb48bc --- /dev/null +++ b/database.py @@ -0,0 +1,130 @@ +import sqlite3 +from pathlib import Path +from typing import Optional + +from settings import DATABASE_PATH + + +class Database: + def __init__(self, database_path: Path = DATABASE_PATH): + self.database_path = database_path + self.connection = sqlite3.connect(self.database_path) + self.connection.row_factory = sqlite3.Row + self.create_tables() + + def create_tables(self) -> None: + query = """ + 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 + ); + """ + + self.connection.execute(query) + self.connection.commit() + + def save_article(self, article: dict) -> None: + query = """ + INSERT OR IGNORE INTO articles ( + university, + title, + summary, + link, + published, + fetched_at + ) + 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", ""), + ), + ) + + 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 + 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() \ No newline at end of file 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 0000000000000000000000000000000000000000..b9935732b73e23bc9b9c08cb09e4592416cf8416 GIT binary patch literal 412 zcmZvY-3o$G5QOJC=uwJP61wmaf_j3eJcVfamq;&P&FoRTsEfse^X=^HzF#RE&M48~ zh=+X>Bv@mKh!tRt87^cTQdDFbw0t*Y62v4{Muo<0P0vY|=y5m3yNL2kg#ypXA+&C} zXwHtxo+;wVJz#=gb@7+-S-&0!Ce4^xNrPb 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, + } + ) + + return articles \ No newline at end of file diff --git a/settings.py b/settings.py new file mode 100644 index 0000000..323acfb --- /dev/null +++ b/settings.py @@ -0,0 +1,30 @@ +from pathlib import Path + +from PyQt6.QtCore import QStandardPaths + + +APP_NAME = "UniNews" + +BASE_DIR = Path(__file__).resolve().parent + +DATA_DIR = BASE_DIR / "data" +FEEDS_FILE = DATA_DIR / "feeds.json" + + +def get_app_data_dir() -> Path: + app_data_location = QStandardPaths.writableLocation( + QStandardPaths.StandardLocation.AppLocalDataLocation + ) + + if app_data_location: + app_data_dir = Path(app_data_location) / APP_NAME + else: + app_data_dir = Path.home() / ".uninews" + + app_data_dir.mkdir(parents=True, exist_ok=True) + + return app_data_dir + + +APP_DATA_DIR = get_app_data_dir() +DATABASE_PATH = APP_DATA_DIR / "uninews.db" \ 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 0000000000000000000000000000000000000000..2f152cd7409d971e9a523a18d3f60e71de1932f1 GIT binary patch literal 160 zcmd1j<>g`kf_aZ*vq1D?5P=LBfgA@QE@lA|DGb33nv8xc8Hzx{2;!HsvsFxJacWU< zOnPd5VnK{cesXDUYF-JDpIMSoniOA@T999yS(0CrnOYnZnwRO9T3#Geni&%xpP83g c5+AQuPzD z0~}clwKY|qtQ6r{c;@#Za0lBPCNh z<*LoJkrr{)%`~${wy7CfGiT(Qc_ZH}7=>ohC^kz*saZD4%_(E5SurZ2e%hUGR*kBN zXWW_QtT8L%S@&4;xN%&>HTOjGq;XQjbMC3;Y2&nr=P{0VjCVx5;J({DW1MN8HO?Yk zG~WBU#7eCEoy5v^@m0#unY=93re0$vQoW{x%2E)jOOLr*lf%l=9oydsy!Nuqzi}G2 zU(1J?rA2Nv?Xa-)#VzmUJ!{MB1jxK+J-1zC-gErmiNjXyAext81 z-X(>-ZDy`Jf3@zkhRkb8wTW`32zLQoW`5_&1ey-mf>O7vVW z^`}X2I|~0CwW^dG%=9GD`z(5$T|Xvzuf}6I9=AWSr@b|3&!lB+PfR*n53}*Cozs}0 z^UsPTh1x)Yj4HQRxb3f*Ud!|Ys6?mzsCF_;`<=GUL(MdumJ^t!QHEYcVteESu5Dz{ z$m!a=2vLPo1En*cM+%;8xK3lkW?{DBwOV!~2uohu#v5Yg;m3}K7%zITYe@=B&vQdhpKP8D|qHiv*B94Z<^mrZx(OQKYQ%k+<&%edsh3|9k0=8 z+N}U-Cs^w|H@V&Rd?)a@WBbqIHF(x>o;3jx=BB`ix%L)+7o$6ipZ^hpyqs5x_*G<0 z$>68S^k2p^%Ay0QiI!8V4G{U#h`&RadH4wF3{JgP#@@kTW*A%{Kt3? zzqlJ*qS%KBeuAG*T2aQuH_)(z_y`Vm#is)xNPOFJPwoTv#70+o5aPDd zQ~ClH1T>HU6$n&_FG!rGMlutZ(x?jYJ5Hcvs0_urk^e%h+ii<8egy*LR}s7>QSws=jJ$vkVN1IoK!?A< z26BQeoi?iOv~;g!>o7`IOK-bY!!}CGo#z1|j%f0x_%MPiQQs)VsmQGI(|AGcBv0c3 zi)RO4K3@m))M3jxtt0=Kf*TZ&TJkIcqaZqma>BR+h_=`d$87Au&*x$*!QBA7FB zq=y~udz=GR(OHyOUbEV^QGMV!Ewa*ISS=RUqJ7TaqkuL$KSP0zz@Rrpo`#V}bUEN; z@A-9<)l`0gsuZFfVKyLIqZB8iO=aYxSyFr`B1@9b)#@Ia$Cp^drhlSD{>7F7>C%OA&B5VMV44=;}{m{E1SwJce%km*S+sC z+sKc^%TfG+%^x@~p$F3roVhz*=ecX&jtrStw6G|QSo|KbIQ%nu&m{DjN5MLNK8-P! zCL~)`y6;bv=;-M8mn%UTY&;xtl8``PrEcYNupIix--sKk|7D_tDm}Lv8>`&wwAh7) z=X!izKeu>o@#Do?KS@;G&L@)RhCBO6eQvHnipd_u%RLTUzrZc#bo_b!(v9}Z{VJif z{ZjWm*J1j(g=-5p7q29%j*oDNEfYpKnChj4%b#2sn<@=#1xD!t`84zTmCKU`B`R#% z&gxn)uivyuP`xD6(PTT^(w^ZFi;IaCOLi0UrhSiHG-d;cXGf!5o%?p#~Al&pIASa2qScXd=% z1bAOwyqbK+P@oCfAA(HhCIR@^aF-&3e#_$X*z3adAE-d{`hjY_>9Al8W1NUpgXKI7 zq({ayzXq4kwU0WsbCVWmD7;;5yf&_k`%7hf1V_*;cnL?qlhIki8e9+?ey|2VPB;{! zRpEFit4>(1wb=CTS%vLHw;;aA`ClfIV0q29gNg3hd2-F@M<)YSR%GpM_jJ-Ri@1Q8 zYyjU8ba*S_nUS|kG^+4;{~nR99!P;8#aQaMslP67Yf)~H?q_-u1F06szmxt}*+I)yoJgOdHR>tr*{x++ z+6EWZt;TgTEZb9tb)SgZ40V2`5FbR(O?gwQY4uR%-E=2dxo`vW((G2lBg@^b-gkT- z5uKPuUte)tyIZ+U+#x_aK)S`|^WEy*);AV;Wh1qI^wjI{C}VEj_gbQ@zGl&>E~@;e z%U{;#^EEA0$&Ka}2rN{oYN!&E6HYJUZFMhLB+qn#bDXLQsAFgi?uadHOi>qiY>d?w z<+YSJ8igv2Gfdl}O_Bu&^MWN31N|5DCRi2)j<_a&^tPzb%46+i_5ZYVMINRqY3*C( zP4{F{BZgfi^ngZ5KcNTLaSp&PWyAU^x_(>ov&m>^D3l3C9Z2l!k0 z4tT$9WtC_H#J~QC2>1F)uFV1(*2Dx6kmwBaZXM0(+C~s>$ z4J~j)?RvTKBgBDeS5-ax7zXN^V%R!64 zZ<1aJKV`fFBrZmh7rjpP2E+rn&>hq;D>$i4$E;B`rW8m9DOQQk zcO;ML-IGkUzStcc>7j#jb8`tX(-E7__RIMB#}TaIC0}O+BZDFC$bX}}fSN;}RiMsB zP3AvBB_jOiE{55`sclR>a-RY#8eX&g*?&>Dbdt=+d zr2pC6ZhPmiX7D!INV`tc!3{$Tyu+L7Q?V)ONXL$Mz5Piaas{3ae@nurq4KS$gk3T>e!d_yCP_#^ve!0)4kzfV<* zU_}Rnzc1v%rzt-Jki%VoQSz;C?A;1sZsBFaZqtQ8m~L~>82lX7K4!O8Jl@!?Xo{)w z52$Pk9w82P!#-vsBbT^d@N0#L%t(t3Y2-XyO3-5Y#uA7CW>9tB@fkX&oQx%>z6eLA$~1?>TrRVgZ&AJnu45(Lbu0z3dUqA6vN zB+9oHrL5>Ou;6VuMMMelutiWL<_T8{S@sFI>&!6 z*TMAAV)3UGkWl$ADVW4A{1FPK(YH^`8Vy{KY4zQ^jk$^3xJsUMgN8HsBNIkl2h57KydkdU; zG_r*9xMkz?ju@%Ag*bmi!Pwf|Lf$c&5beM`)}cx)_n1Wk00skBiM&+<+}6ajDy@Y!BrwrR0fIgjlx{A%=>ZOd{v<05q=P6Bhm{prOmU6n z1QtucSdh8f*#PMJ3MA}?%J28GxFXH}S~(^4i#rl4oREOgDd20drwWuMtS$Dkz}oD# z7NmL_#+d8n`lX;O-c+XE>9z@ZQ@~o_v6z|G6Aamlvw{Jt)VrV8Jv~M^SZy+(bNSnA zc4I@w?IqVAE-%Nn+T*atfz4ZXfTO;+o53!h3)6x@G_=Q{EWx(2FhAUCoDet6wZVru zJ}z6j#Q~~80qijAcbag5xAxXhE~xA0I>xJ0C=|OTC0A5E&89avFVP!;ZB{*A1^ zkxMl-%;F-8xOl#S9^kQo)`m3Ot`iJ)wTl8nSfkeXzIIUb(*aPOl*{tCPc>Yp#EvG= zAQ%xln)(l*`SA|Mkona>5qDX*zv`>8j{%QL?2lAG6HOBPn66p0-hgk;_H#Sv$Arhy zFT|~|GyA3Jx`uX>*imXdG9XXz;_lRPd~2zbkz{kVGOr>(RPC2`LtI!2>&X8Ynfy;E zAipDm@~9IzY>UriA!^bF7+o`Fdg09Bx#cSgPTFvqv(Dx zvQ1S*{hx8dUSKBFTzoGO7DugYJ)tZl?Olog42cLsBT4!>l1rSET9tr#xcmZE7PeEk zO$0{1QurAlSc-hc7u8sb#xe25LHu!nZ`zTEvY5;_H|(uV(1Jb;;5ocFlHW0k(LCb| z5FQ{NYJ{YXyslimbnV*F#(m%=2lYRoJNIJ% zsm=(&KKZg^x6-~B9#mtJTo1eWpmu{H?ni7C+N+7lp|F? z_(DWe?vxi|BDf-H?Bi_s4l8Re}@WZx6)w!_FR%sjY%RLq27-yzysM+ zdeMMl-^=_{Dg7OAIY7mSjC9{i0%|*9!4KNV1LZtu{|x3c!lGZcfWDVPOO><<1b5u`!y+m9AeIMfM2sE7WwKG~l`%ueOf2qxA7s{%ZseYiT z$1C}KRpV5<_CEgt52N^CYbm%sw}j8ExGj!M;s|aO2KhhlIO2#NP7QLRk6e85=}h#k z3?1CWcNNjZ$a4^EtoYzB`hJGKMTopy`kGIi(u9*Km|dg5@tqt#kE5?@1=nO$JI>s| z5jA5cmmKNH#{qdJi7)kxQo?8CyMCZJvWc0F2uvsk2`tn;jaKtB@}d0G2sEjJ!_`H( PSQ;F$=nEHBnN9sa)?4kF literal 0 HcmV?d00001 diff --git a/ui/__pycache__/settings_dialog.cpython-310.pyc b/ui/__pycache__/settings_dialog.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..71eee1ee0629a0018d6bcd5f213a4ab8c5abd82a GIT binary patch literal 3626 zcmaJ^TW=f36`s9vxx9#yCE1bWl<1v>BDhZD0*Y!Bv79!AtXiRx01Fk1)y_ylO?^A%*J!f`FlPXXOJ9{oOXU?2G^PQut(Qpa; ze*e#hXMbBHe*-wA13b@T|we^G~uNl38+` zAz3-tjeLUGoI`Pm)Sr7^IK4sjD4^d9J3f!?L{ejP9xO69OS)E20~=1bPl@x zC`DtngDQTQy2uf zA!F@IyVW#uMaDXvPzG*!t7}A3M2gtp8T(%5fH^2?OGEW)!?mP=@4xl<1UOFa%~8c8aEn7TG%S$l`C>q89VsN z@jH|peE1m-KK~5o(pvGwXkA&V3RhXG!P?rmP8({s!g>Q_4Y1y%3#A3*T69raXsUG! zFlS-hqDw_~0KG`>P3fglZjA5IWtdrbiQEcsg_jdu;U#jbpi^F6A$K3RhT0#w2f!(B z#4<$06f5*Sbq-v!dUk(|`uEfsR_L12tbt^$)T}Gd%<+cGQgi$eSCulP*A%URTt)p# z8^3>Ll(J$CN=%ff++N?8&g4svesGed;seao?e^ox{o%)LCAeVGX-{mokF%J5Jlgo2 zoj^MG+9}?LiylkcfC24X1YG2!+KV6^1aUh{<4caIGHN}4#rS3W=O49`C>;ovzgd_O zfB0xBejmm4a(26y4fvZK6m2|DgX5UBDa+4A)^Deoh)$v~5K)%qqjkyx8Uu1|w)3!; z#R1QIQGc_|hC$+4d+52eVtje#Zotn0R$zSd7%WGBya(5jj20&+ic-NiT*B?q>i*0o z?Q?c{k#U-DkL<%9OW5{k`9&~nhe6n5v<>Hu!kFdTp8YIHLl%2x7htgSJzj%Ncm)R8 zfV%54jpQXMEA>aX64bP9WAx8j{tCJZM@B9$aT~-20KNBpG zt8)Q)x7xwg&57>-P5puJmcWshaS$>H6=nR84FT>29;z_Qv7pJtBpCYYczzDx559OV zEPMg*mJE`D4u*(yb7}&EYEH0Ss3eH5>lU1OVuO<9Yv`vM){$C<;UPTv9t;j(p{v=t3y9d%ia(?gBauT2|C&>6Q_XGx zjiVJ~k*6$lKyp7d#ZoTm2ff=GemO zhImWObBljp$?OsX5Md`aENai$sGJh8VZeN6T&2#~MYK?OQS4ErF6d3r*I*C8jce=5 zQhOXKnSEuIv+H9-TlBL=>%|@lVH*2#X_CV4lKfCUmhS@|d?FZt~tVgP}z!FKF~HEZ0r`Lztj)SanQ{a)1FM4M-^dBUCvjP`Y;m z5qJymm7WbaR^iW5%7*+Wpyxlu=@n3^+hd+(QOY~0_?cP_iZ8K{ckCi1TXzpCEwImq z(wTVAu@~~nYp8U0^Ku0ybm7T=41*0hXTEKkn1^-9xPn`PJbWi{-j3F~^A&ui#h6Pv zM44$3|1&V~86oLzp;5Xe*HFl*{+d7`F`!k_cCozcFauKV7H8Vj+$qa;$y-Ic!eNLI?&-wCvIcj71)fUy0i9PFyT$R@gOYx=)!&3-$2 zbSJ8FIJLDCXJHWMPl1@2`A*y9SZ(-+IQ#;Kk8r?m&VLC*XQ9X_QvewAUx5T%?Z=TQ z615uTvw2#iXe}(V60@{O%4q^hOSO@_frjiL3|U`vj3N~qlZ^A#URYl51b}}4vJ9Ra z%Q51aquc*+D$ZNSEFRZrX^3#pBX0b^Sah^~XpNyKP6H?|zI1&*$><O@mt&eM4`ie4c%huezCYvS1Dv9;x(O8g(?xPDSq}K>)SM@+4UY2t&VpQcd3hk7 zY#l%+j>RHZ1y|Knj_F<$VO6KFL~|^cs$!`UsA{*Y&;EC#x_1>;wZ)82)A}Kuo#Kp7 PvGvK(_#@J4EF1p?y5-8v literal 0 HcmV?d00001 diff --git a/ui/main_window.py b/ui/main_window.py new file mode 100644 index 0000000..f787c46 --- /dev/null +++ b/ui/main_window.py @@ -0,0 +1,415 @@ +import json + +from PyQt6.QtCore import Qt, QUrl +from PyQt6.QtGui import QDesktopServices +from PyQt6.QtWidgets import ( + QFrame, + QHBoxLayout, + QLabel, + QListWidget, + QListWidgetItem, + QMainWindow, + QMessageBox, + QPushButton, + QVBoxLayout, + QWidget, +) + +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_styles() + 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() + + if self.app_settings.get("refresh_on_startup", True): + self.refresh_news() + + 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.article_list = QListWidget() + self.article_list.setObjectName("ArticleList") + self.article_list.setSpacing(12) + self.article_list.setVerticalScrollMode(QListWidget.ScrollMode.ScrollPerPixel) + self.article_list.itemDoubleClicked.connect(self.open_article) + + self.root_layout.addWidget(self.article_list) + + def apply_styles(self): + self.setStyleSheet( + """ + QMainWindow { + background-color: #F5F7FB; + } + + #HeaderCard { + background-color: white; + border-radius: 18px; + border: 1px solid #E5EAF3; + } + + #TitleLabel { + color: #1E293B; + font-size: 32px; + font-weight: 800; + } + + #SubtitleLabel { + color: #64748B; + font-size: 14px; + } + + #RefreshButton { + background-color: #385963; + color: white; + border: none; + padding: 11px 20px; + border-radius: 10px; + font-size: 14px; + font-weight: 600; + } + + #RefreshButton:hover { + background-color: #2E4A52; + } + + #RefreshButton:pressed { + background-color: #22383F; + } + + #RefreshButton:disabled { + background-color: #94A3B8; + } + + #ArticleList { + background-color: transparent; + border: none; + outline: none; + } + + #ArticleList::item { + background-color: white; + color: #1E293B; + border: 1px solid #E5EAF3; + border-radius: 16px; + padding: 18px; + margin: 2px; + } + + #ArticleList::item:hover { + background-color: #F8FAFC; + border: 1px solid #CBD5E1; + } + + #ArticleList::item:selected { + background-color: #EAF2F4; + border: 1px solid #385963; + color: #1E293B; + } + + QScrollBar:vertical { + background: transparent; + width: 10px; + margin: 4px; + } + + QScrollBar::handle:vertical { + background: #CBD5E1; + border-radius: 5px; + } + + QScrollBar::handle:vertical:hover { + background: #94A3B8; + } + + 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.article_list.clear() + + self.refresh_button.setEnabled(False) + self.refresh_button.setText("Refreshing...") + + feeds = self.load_feeds() + + old_links = { + article.get("link") + for article in self.database.get_articles(limit=5000) + } + + new_articles_for_notification = [] + + for feed in feeds: + university_name = feed.get("name", "Unknown university") + feed_url = feed.get("url", "") + + if not feed_url: + continue + + try: + articles = fetch_feed(university_name, feed_url) + + for article in articles: + if article.get("link") not in old_links: + new_articles_for_notification.append(article) + + self.database.save_articles(articles) + + except Exception as error: + print(f"Could not fetch {university_name}: {error}") + + 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.handle_notifications(new_articles_for_notification) + + 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 render_articles(self): + self.article_list.clear() + + if not self.articles: + empty_item = QListWidgetItem( + "No articles found.\nCheck your RSS feeds or internet connection." + ) + empty_item.setFlags(Qt.ItemFlag.NoItemFlags) + self.article_list.addItem(empty_item) + return + + for article in self.articles: + title = article.get("title", "Untitled") + university = article.get("university", "Unknown university") + published = article.get("published", "Unknown date") + summary = article.get("summary", "") + + clean_summary = self.clean_text(summary) + + if len(clean_summary) > 220: + clean_summary = clean_summary[:220].strip() + "..." + + item_text = ( + f"{title}\n\n" + f"{university} • {published}\n\n" + f"{clean_summary}" + ) + + item = QListWidgetItem(item_text) + item.setData(ARTICLE_LINK_ROLE, article.get("link", "")) + item.setToolTip("Double-click to open article") + + self.article_list.addItem(item) + + 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.setStyleSheet( + """ + QMainWindow { + background-color: #0F172A; + } + + #HeaderCard { + background-color: #1E293B; + border-radius: 18px; + border: 1px solid #334155; + } + + #TitleLabel { + color: #F8FAFC; + font-size: 32px; + font-weight: 800; + } + + #SubtitleLabel { + color: #CBD5E1; + font-size: 14px; + } + + #RefreshButton { + background-color: #385963; + color: white; + border: none; + padding: 11px 20px; + border-radius: 10px; + font-size: 14px; + font-weight: 600; + } + + #ArticleList { + background-color: transparent; + border: none; + outline: none; + } + + #ArticleList::item { + background-color: #1E293B; + color: #F8FAFC; + border: 1px solid #334155; + border-radius: 16px; + padding: 18px; + margin: 2px; + } + + #ArticleList::item:selected { + background-color: #334155; + border: 1px solid #385963; + } + """ + ) + else: + self.apply_styles() + 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 From c48cfa2f69f40b4155e7e4a9c2013825360ee97e Mon Sep 17 00:00:00 2001 From: George - David Apostolidis Date: Tue, 2 Jun 2026 21:35:21 +0200 Subject: [PATCH 2/6] changed style, fixed some bugs, needs improvement --- .idea/dataSources.xml | 12 + __pycache__/database.cpython-310.pyc | Bin 3940 -> 4996 bytes __pycache__/rss_service.cpython-310.pyc | Bin 761 -> 1149 bytes __pycache__/settings.cpython-310.pyc | Bin 783 -> 449 bytes data/feeds.json | 22 +- database.py | 91 +++- local_data/settings.json | 13 + local_data/uninews.db | Bin 0 -> 135168 bytes rss_service.py | 18 +- settings.py | 22 +- test.py | 15 + ui/__pycache__/main_window.cpython-310.pyc | Bin 11520 -> 16607 bytes ui/main_window.py | 482 +++++++++++++++------ 13 files changed, 487 insertions(+), 188 deletions(-) create mode 100644 .idea/dataSources.xml create mode 100644 local_data/settings.json create mode 100644 local_data/uninews.db create mode 100644 test.py 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/__pycache__/database.cpython-310.pyc b/__pycache__/database.cpython-310.pyc index 5e25043f577de63e1f717be0196feb335e3ce148..9d714c4dfa647aa974d8cfb344b7f2074d03d516 100644 GIT binary patch literal 4996 zcmc&2O>^7E6}$K#M3J;Ce7nChMmjo&{*GRI#kD8@gr=#}_W%+kM5R9XP>@)>ef##|?c4XY z=$6Yx39h~BJ6q2dCF!5onZ9J`+=M&40u7TG@g)nN)F&NkQG&4S%N@m15}5d^rQVj9 z!qh#9sZXh;F|sCAwHGj7YE((A+;yUd@YGj(k=G3z-^G#X3njVzsDOCmZXPsvcS z<)O4CZz)^wM)=V`g2p3lzkt{S9M>alYYuw4T^vgL^9K^{0_a0xq^^ixA7i@-BdAZiA-Kwpb_T5_RGl4P*6`EK}=f!I05%WY`2p{`i#6O7( zZZ`mehL&7kUymU-K3&fxDh>vw9+`!o2@%~@Qn-CC_TK)gF< zqjh0sN#PXA7d(1B z31()u8};9{&DmG=`VV|Be8^`v5m!7*zriCHsBI^jdBtn0$m=+p-0lni)r_lGb2NjG zYAY?XIWpq0*&4OwWn*b|rG2MiWV{U&@@iETWpJ&yz@KopAMsdoyPb{~iFv?U3#&aY zoHQGlLUhG*B96g85-QT>ICjy=Pr)6YghnHjC_fh8qqIa8NCkYK{x~?D`Ft(}#f6cK zxfqPO4W&vve**wUc4_M6Lx{ioltHFBNFm5)BM36i2+}hMa?S`hpW#XfZNNQ>B61d+ z%MIrYM4g;Q(XZ=`HM7}5D0i(kjr#4zY7?SGqqUj^2_qjEChnOxp~SiJsF7>rk>~+= zOlGKg^u!+X_-x0sbyYLyi`q)tTrN8M zecb0_=a5ClrO^4B+au$u0bTeOGz!rw6jD)6x)U_MsUA&5DFK zf$@+mOJ5bxNcW{-l87EtO!vibASa3epur2^m{`aC_)Vyos__vru-*I z_Tulz2_=Zj9O!P%7uXb#^9T526`P57B`ki`fN}gOj zH?FML!1j_vu?bb&R~ppRU}{gvH}o-GOT7XQBg}InC{XFek1ZKW*u&mZyau^Cf?UdYWCkGJP0FK~jQ0nF^_v&+bqJbpL ziqpX9@B&US?sGUofO0bZ{u$^(j5Q@l_k*+99vnfMvj`L$H!cwyYlu|raXuFB0jf&V z0!4NGE*wja5J_AHvcJHc7y$@S$plE|#HEh&B$M^0<2XjgS}?Ibz<6?Y6XoI)0guxsbCT|1IIXEIE9=8bJBe?z)3;?)z61IYkLfh2tSe-Z=zpLuOUmHwOnXPo8++0T znA(8DZ$o?lP<0W{>VebYwjCF3yVGTTAK{X1KkhqzI+A{UfKO0qnJ=mcy$cOibYUdk z$G(hx8G5k{OHI{q2?x+O#jmiz?^IKZ#8rso@PL5>&-vjm6W}B%mKHCZK1Vf*2Z51Ly1ckyyjS(Hl^f%B?`^q>OYQ=*5y_aG5A)}oyrd`C&7639BCJ?iDL Wel1<~b&N$J+LR7atOW6QQT{K@nMG9q delta 1593 zcmaJ>O>7%Q6rP!#U9Z=>o5V@nrunHU3S{FD8gW4oA`CH2)UuSsQK>~`jAvY&uH!Ph z3A74|5V>-Q=EkLo;F1Gedgj29E4OYB96;j2h2DTt@n)TvII0+HpTGC!{mpxCX7~AD zC)~EW;mE zc%N*eb#$OOEyC**IIXI0qkZE5VZ_iXI>f%05(e1FNwQ=raEOE>H%ZwxfibX)Eo@`? zkc__N*D;8W7Xi?XIspvBpFyN`w2!y(0mdl7I)VKP5u9@lM|=-r@X!y@z|)h zTdii~5n(I2{_!RkcZ37DLtKS2K>FYdRk10yZ5Q9ob$j{1n6i{{(#!W{CUtqLVH}X7 zYHHnT+Wy_2yNN9OtEH8){5tiO{8mrP8|DQK(v)ACZSK(|N09}ev38S3Vi1t0B^iog zz+y;|-Xvo?tZngXO+?Llz|Vt1coqo7hE1?VESQPTkNicSxVF5VzBVsXpy6SmM&a1M zvg`yo<=;8!KYT@EYX;}!to1$qN&ar#OOEklU+(@7nV0cP$`&p$Wz9O&HYT$dE1GD3 zayn;gK4^v!wBYtaOU^h;1=XD7;1x4zYb^-8bm!nXwRJ&}XB8Qe_nc|(RE{=O>$n8r z0;q*!K&U|-`N;WvP7DBdh&%pHnGP}@@CXVR1YHkp`JH=hCMgo>%#B_T+94M+DuZ4J zVpfH_Ki9W~=T(^FdcLSspVqHozR_rldbefOpb3MxC4cFkbzvO)!Or(I-xtrwT6TJe zDnIH13pI(!NQy~?RP$o0>L~JxR~J0ifQz-}MkESK_JSfRL2*%$|1wWdGUGaIsOKY` zN8j1A1&hu3R-0`Gd_lcVp=!E`Y0%{C{FpULlzSWQr+XvWksZ z#YdczE=n;HD$v1HD5=7aawJux0~I3$I@HlFn?@>8J+#D1yk^7n8)jld;S!ZuZHr2~ zg!sIv^`VPE+ZO7alhf}b@J4KGAIZ#-Dc-Sj20I-)`^}ZuWlOFCC0AHOh2Fl0b^)GW zu}9wi9PPmYdvkrkJC4yYMK7gFRve!Bmma+c#@TE3?y-`D{?KhzS{2zOZ)&S+3xw1K zEtHPA{@P?u+v>Q^iqeH;QNJ}#+RK@qM@5yL=%*uj3VJ#WfxgKFC)z@S67)z-jm^NC zi^85_i$W@J=_FKN1>Pe9KaQ`iOyZN^7+ zai}lM7>t=<(0P&TC$md;O~1F0<#ysBO%Q#Axy=n^+kC*c#egRwMQMkNpE7`JgstD! z#W@x(?B%4+Xl_%j6$R%sqCbaLW&_EO>l_7(qv`mHFN77#z{<05#h1~FErJuakexV- zaR`qVVHYkF#g$lzMbt&+?z=%Jm%WbpcRyM$h2+siUXE0Za$wUg{J>T6DPG}PQXGz| zcL>{99SDyLZDC79d{lcux!AN-m0@#xv-Y?w%&AuM&7=Bl-CWdrw3)-$rT;z{s5Nkl zm`{3}lBQE_a}1nh*7g2&a=1YTt!5cu$6dI-sY5Qn>=1aVwK>L^X4*i7;W14b2L{wR z7iF`Vu9a{5FoprX!F6P#4WCR+C_n6yYl7=ir#U^q=gwsyB@Dy?RJ^Cp(;Q=}h6CmgwHQYhT1;)a9A#g8^by5|%^}g$xSE?s3 Wsz$Y?en~%yp-tamiP#SA^?w2Eiy{*M delta 416 zcmYL^ze@u#6vyw9Tzb9QS{%f-{s6mpC^#vC#YG%kx(E(k%6WO!)AoA4q(a5f&8gh# z;^^d`;-1M({1X(UCRH%-J|W5HeINPuKfTJZTn-$wUf)(Oi*!<1c6TS6YkPtL6&^6~ zYEF*}E(-Uo!v#5ULjS}1A<@2w6sTkX+PAzNrqIr}NTaDaf*N5O6Knphg4s)^4o%4{ zKO-A%Gk+khCE-8vC)ahZ7w(veFSa%7C+U^W-^tE4*TE=FZUAMX##l7HXhYDZ;hcdE zdZ6M95Zy?HL4Hd2k2F08rFoQPkczN?%(|XIr<6q~joJecSh3xHger;qAhidn!tpHl z*}+==;vUz3vu8Rtwp=#|+$v#iKBbFY_Qj{Hgbj0R#@NpywU{^1R4TMTkxDa>#A>Et b&A$JmZcV>67>ZFJ4qS7Rc2L#v$twK;Ez)I| diff --git a/__pycache__/settings.cpython-310.pyc b/__pycache__/settings.cpython-310.pyc index 2b3a3a959997a6e9bfc12b87044a7fc5fdb843ac..ce7e0f42c4e09dfa2947cbd5bbfb44841b94cdf2 100644 GIT binary patch delta 313 zcmYjNyH3ME5ZpVz&c?RrQy?K47f{fG5Jm|i1(8KuU1K@yNwMPxvK{egD5w!a`2i^N z8GHe~TPps7b;qoBM>E=;V&BX!?YZ_m2ME@|UVh8~eg^R$I*D`YHll%mtbme9#sInG ziYe`dedwQ%8`4xJY9o-AvQQ@$qzz(Q*|~!zmG#OQC>sU2J7~`#@SR_Zg@E;Xly=Ma z?Pw~UqP|Uw1k)~!1KwMAX``yH?S*>C+AgVAPtLl^Ds|r;;bIoJ!$!9(vPHuwN{TRw zl392?**x--L+K839L8ZXp5F9Exx%dJy}`H$=aVD~# literal 783 zcmYjN&2Abo5VpO}vcR&XRc}=vfD6YSsw&Dh5H(VYppc7&v|8c`ELqko+cZr?PW0M$ zNRB=AA^H|RRqCm)&`WERfSA>c=V$%-n@=N7fayfg_G{ zw7?8wmy?_ihWG~Q5xZpM1&IQCbzXGUD|xoYeh~PJ diff --git a/data/feeds.json b/data/feeds.json index 869295c..9ef12bc 100644 --- a/data/feeds.json +++ b/data/feeds.json @@ -1,10 +1,22 @@ [ - { - "name": "University of Oxford", - "url": "http://feeds.reuters.com/reuters/topNews" - }, { "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/database.py b/database.py index bbb48bc..5d73e7f 100644 --- a/database.py +++ b/database.py @@ -2,44 +2,80 @@ from pathlib import Path from typing import Optional -from settings import DATABASE_PATH +import settings class Database: - def __init__(self, database_path: Path = DATABASE_PATH): - self.database_path = database_path + 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: - query = """ - 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 - ); - """ - - self.connection.execute(query) + 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 ( + INSERT \ + OR IGNORE INTO articles ( university, title, summary, link, published, - fetched_at + fetched_at, + image_url ) - VALUES (?, ?, ?, ?, ?, ?); - """ + VALUES (?, ?, ?, ?, ?, ?, ?); \ + """ self.connection.execute( query, @@ -50,6 +86,7 @@ def save_article(self, article: dict) -> None: article.get("link", ""), article.get("published", "Unknown date"), article.get("fetched_at", ""), + article.get("image_url", ""), ), ) @@ -73,7 +110,8 @@ def get_articles( summary, link, published, - fetched_at + fetched_at, + image_url FROM articles WHERE 1 = 1 """ @@ -127,4 +165,13 @@ def enforce_article_limit(self, max_articles: int) -> None: """ self.connection.execute(query, (max_articles,)) - self.connection.commit() \ No newline at end of file + 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 0000000000000000000000000000000000000000..d18b69d0c0bc1f725db1d07809e61940e41b45fe GIT binary patch literal 135168 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 MJ~IS!aBNln|1WInPyhe` literal 0 HcmV?d00001 diff --git a/rss_service.py b/rss_service.py index c781031..02c9364 100644 --- a/rss_service.py +++ b/rss_service.py @@ -3,10 +3,25 @@ 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: @@ -23,6 +38,7 @@ def fetch_feed(university_name: str, feed_url: str) -> list[dict]: "link": link, "published": entry.get("published", "Unknown date"), "fetched_at": fetched_at, + "image_url": extract_image_url(entry), } ) diff --git a/settings.py b/settings.py index 323acfb..ea36c5a 100644 --- a/settings.py +++ b/settings.py @@ -1,7 +1,5 @@ from pathlib import Path -from PyQt6.QtCore import QStandardPaths - APP_NAME = "UniNews" @@ -10,21 +8,7 @@ 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) -def get_app_data_dir() -> Path: - app_data_location = QStandardPaths.writableLocation( - QStandardPaths.StandardLocation.AppLocalDataLocation - ) - - if app_data_location: - app_data_dir = Path(app_data_location) / APP_NAME - else: - app_data_dir = Path.home() / ".uninews" - - app_data_dir.mkdir(parents=True, exist_ok=True) - - return app_data_dir - - -APP_DATA_DIR = get_app_data_dir() -DATABASE_PATH = APP_DATA_DIR / "uninews.db" \ No newline at end of file +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/__pycache__/main_window.cpython-310.pyc b/ui/__pycache__/main_window.cpython-310.pyc index 273557381fc794740056e0d0cf0605b5b329b3c6..a81c36327f77ba3b191e900eb457fbe23127315f 100644 GIT binary patch literal 16607 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 delta 4684 zcmZu#U2Gf25xzSf$-CpPNb1)TeX>Mbv?JM)EL)Oox$=+lQ(JK&J5H`}m<#tLQ9Ax; z?_^5?Rl2Z?rYMTmSrmN;0!T$4^H4W0%|lzbNYTEeO&&v}Xi)SaMjA9J`p`ebo!O&g zSuu%Q&hF06&d$wzGs{1JOT1}B%aMpCz-R80u7$p#JJC+@-8+Y_=%PRb;d%>q1G(5ocWwvC|cAA;!k-z}Lj~CxE0!wwpnudZ9V-_N`!5j1Ob~C^YwFq}o|b zeMRhG6?JdmI1Y@mAF0pO3>8#6yG)%jr4kGqA+IwtZ&oZLaj9G=;SruPOQgY-#>IQF z>BFWA8yy-$H3_+3u7cWz2IP!OEjK?;kHK74rv1ngnzu}1(Oll7#E3wLy{xsScAa4i zIH%ZmwR!f3h$=qM{;OqcuCU-=2yBY@78b;X;D!RfYNM}hgm4slM1XhZn>Ip|-rKlP zaNPT0)F4b1_yPNS6FD1Z=qVVYqtF=5V1^egoHOm2JT2Sy#WJxB&7*6)6ZJZmEPAQ1 zV%bJh!L^F#%FD0X)>*rdU$h9fOFdk!SfyNJBYu85fg3&Ug^k0+0z3{Xsfc0t#^IZg z*1E5h3e(mN=W3xu*ry{2_D&?t-s)_TRF$dGN&m>y@u@THZgZM_7Cr30F%QZxv$>7E z-?Eo|-W6c4#A5RK$*Ez^av_}w44xkv8$QF9;$7145EIiWcBD1J^h^``I1^`IruzcN zjvhZciX4a8ubboS{bYoFn%Ey49vvAwHhhBJ>uuZKr-N;z60B2?`Ufj3`Vjl#Kr`$b zW1k;r0kM8|xGl(j5I@8In2fQL`wj($&L5o|8RELUpNz7ny8Hb@L&KxPY%rAurc>_nhbgjJ^^TCQ4*C0YaKiMrF>(+0}c;o-u_2+ zMik?0>d^TCjKjJ-Td=LV-=Y-4{?YKOYj)Wrxmn91&IbZ}=g(hF7J zhE`Q@+ocKI3g94!y{p1fMi$)gYDE;*RZ&=ryAcwu`rm*QR8`*)-jr`~u9pBd*41yS zOBwI?hIj*2j=~<0aW+p&>lzmut7_iaV2j`;u;k*96Pg;iAyM@;X3-+?ZCYjn{av*@ zm~H!mKuS?q8}-@r+`4DjYm3~aL&UQ2>t(eT@aV!^G; zC9`P#hnf8yH98Ff*5YT&%Qn$VWmlg?G}qUpalM`_nk#_e`FV?M!JKpdpK!v<^du^p zq?7Pd_d_}~DLoCep@DMeOC~@iF@iu}wpLtv5oTyNvWDj@cUu&mwkFUfoCts)TO~*q zj(OeM=BTUZSMpZHEtE@je}zJhqCLpnY?WrqG{4OVC=bV?`*1EsK@Q}ehWVU&Y95lB zZROx_g;~ITl;&hZUZy(Y=MiA2Wt)JM(xPK@m8=`vt8=i=BP-dD2lmx)BYFv&3)sAX z&7-f0C(XaYxPvJ{6T?ziRHT3umI7bOeg%4AQI;f8k~A2Pikc|hj7w2T7nPgRgQySY zB}LSLKQ8VLdCJml|lbteM$I#%fq3qPKB)K;MI4*#QsE9v1)tf*!qFb|H-~@aW`RN_9JJ zLtldEzOpV=r45xx#0MbY zo-db5kTSr}2I_vd;M$gonsk+3fuZ{! zL9^x?7#LWSG;KKuJzf9H&7bJH7p&gJu2?87yKq0+B^A&34UxTeYWfP!c;G=qs3RnV4B}gqXaXW! zA~HrG#vJr~n00yl`KKY4@P33zY;N8)WMa}q^Iwo)H61=I96iWB>ud{64IUqwJmHN! z7u~}?iSA<;d)q?1EN+Z1K|%OoOAC9ir!_o0d~|SRWXFOh)BD&jqha=^y>0BxOl+%) zKGmhNOYJJ_&ZOA2XpH?konevvy}%NO61bU-QutjLQzk{!q?r9E0#Drs$VV~Msg4cC2tC9) zPq#huP^$qPzXFY8H$)u={%WAt3wetdO-5q5FaX%gd2$V8K#=ha2^F6}@m~ic z@<{1Jw=sdkH{boCX^(p(o}#XtESAZ#ZB2lY1*ZcVMTi5?9udQlEx5&dtSqH`{|E6K Bo?!q0 diff --git a/ui/main_window.py b/ui/main_window.py index f787c46..c83a007 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -12,8 +12,11 @@ QMessageBox, QPushButton, QVBoxLayout, - QWidget, + QWidget, QScrollArea, ) +from PyQt6.QtGui import QDesktopServices, QPixmap +from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkRequest +from PyQt6.QtCore import QByteArray from database import Database from settings import FEEDS_FILE @@ -41,7 +44,7 @@ def __init__(self): self.app_settings = load_app_settings() self.setup_ui() - self.apply_styles() + self.apply_theme() self.load_cached_articles() if self.app_settings.get("refresh_on_startup", True): @@ -104,84 +107,289 @@ def create_header(self): self.root_layout.addWidget(header_card) def create_article_list(self): - self.article_list = QListWidget() - self.article_list.setObjectName("ArticleList") - self.article_list.setSpacing(12) - self.article_list.setVerticalScrollMode(QListWidget.ScrollMode.ScrollPerPixel) - self.article_list.itemDoubleClicked.connect(self.open_article) + 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.root_layout.addWidget(self.article_list) + 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() - def apply_styles(self): + self.scroll_area.setWidget(self.article_container) + self.root_layout.addWidget(self.scroll_area) + + def apply_light_theme(self): self.setStyleSheet( """ QMainWindow { - background-color: #F5F7FB; + background-color: #F4F0FA; + } + + QWidget { + font-family: "Segoe UI", "Inter", "Arial"; + color: #1F2937; } #HeaderCard { - background-color: white; - border-radius: 18px; - border: 1px solid #E5EAF3; + background-color: #FFFFFF; + border-radius: 24px; + border: 1px solid #E5D9F2; } #TitleLabel { - color: #1E293B; - font-size: 32px; - font-weight: 800; + color: #171124; + font-size: 34px; + font-weight: 900; + letter-spacing: -1px; } #SubtitleLabel { - color: #64748B; + 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: #385963; - color: white; + background-color: #A855F7; + color: #FFFFFF; border: none; - padding: 11px 20px; - border-radius: 10px; - font-size: 14px; - font-weight: 600; } #RefreshButton:hover { - background-color: #2E4A52; + 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; } - #RefreshButton:pressed { - background-color: #22383F; + #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: #94A3B8; + background-color: #475569; + color: #CBD5E1; } - #ArticleList { + #ArticleScrollArea { background-color: transparent; border: none; - outline: none; } - #ArticleList::item { - background-color: white; - color: #1E293B; - border: 1px solid #E5EAF3; - border-radius: 16px; - padding: 18px; - margin: 2px; + #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; } - #ArticleList::item:hover { - background-color: #F8FAFC; - border: 1px solid #CBD5E1; + #ArticleSummary { + color: #CBD5E1; + font-size: 13px; + line-height: 1.5; } - #ArticleList::item:selected { - background-color: #EAF2F4; - border: 1px solid #385963; - color: #1E293B; + #OpenArticleLabel { + color: #F0ABFC; + font-size: 12px; + font-weight: 800; + } + + #EmptyLabel { + color: #94A3B8; + font-size: 16px; + padding: 60px; } QScrollBar:vertical { @@ -191,12 +399,12 @@ def apply_styles(self): } QScrollBar::handle:vertical { - background: #CBD5E1; + background: #374151; border-radius: 5px; } QScrollBar::handle:vertical:hover { - background: #94A3B8; + background: #C084FC; } QScrollBar::add-line:vertical, @@ -226,46 +434,34 @@ def load_feeds(self) -> list[dict]: return [] def refresh_news(self): - self.article_list.clear() + self.clear_article_cards() self.refresh_button.setEnabled(False) self.refresh_button.setText("Refreshing...") feeds = self.load_feeds() - - old_links = { - article.get("link") - for article in self.database.get_articles(limit=5000) - } - - new_articles_for_notification = [] + print("Feeds loaded:", feeds) for feed in feeds: university_name = feed.get("name", "Unknown university") feed_url = feed.get("url", "") - if not feed_url: - continue + print("Fetching:", university_name, feed_url) try: articles = fetch_feed(university_name, feed_url) - - for article in articles: - if article.get("link") not in old_links: - new_articles_for_notification.append(article) + 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}") - 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() + print("Articles loaded from DB:", len(self.articles)) - self.handle_notifications(new_articles_for_notification) + self.render_articles() self.refresh_button.setEnabled(True) self.refresh_button.setText("Refresh news") @@ -280,39 +476,94 @@ def open_article(self, item: QListWidgetItem): 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.article_list.clear() + self.clear_article_cards() if not self.articles: - empty_item = QListWidgetItem( - "No articles found.\nCheck your RSS feeds or internet connection." - ) - empty_item.setFlags(Qt.ItemFlag.NoItemFlags) - self.article_list.addItem(empty_item) + 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: - title = article.get("title", "Untitled") - university = article.get("university", "Unknown university") - published = article.get("published", "Unknown date") - summary = article.get("summary", "") + card = self.create_article_card(article) + self.article_layout.addWidget(card) - clean_summary = self.clean_text(summary) + self.article_layout.addStretch() - if len(clean_summary) > 220: - clean_summary = clean_summary[:220].strip() + "..." + def create_article_card(self, article: dict) -> QFrame: + card = QFrame() + card.setObjectName("ArticleCard") + card.setCursor(Qt.CursorShape.PointingHandCursor) - item_text = ( - f"{title}\n\n" - f"{university} • {published}\n\n" - f"{clean_summary}" - ) + 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() - item = QListWidgetItem(item_text) - item.setData(ARTICLE_LINK_ROLE, article.get("link", "")) - item.setToolTip("Double-click to open article") + image_box.setText(initials) - self.article_list.addItem(item) + 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() @@ -356,60 +607,9 @@ def apply_theme(self) -> None: theme = self.app_settings.get("theme", "light") if theme == "dark": - self.setStyleSheet( - """ - QMainWindow { - background-color: #0F172A; - } - - #HeaderCard { - background-color: #1E293B; - border-radius: 18px; - border: 1px solid #334155; - } - - #TitleLabel { - color: #F8FAFC; - font-size: 32px; - font-weight: 800; - } - - #SubtitleLabel { - color: #CBD5E1; - font-size: 14px; - } - - #RefreshButton { - background-color: #385963; - color: white; - border: none; - padding: 11px 20px; - border-radius: 10px; - font-size: 14px; - font-weight: 600; - } - - #ArticleList { - background-color: transparent; - border: none; - outline: none; - } - - #ArticleList::item { - background-color: #1E293B; - color: #F8FAFC; - border: 1px solid #334155; - border-radius: 16px; - padding: 18px; - margin: 2px; - } - - #ArticleList::item:selected { - background-color: #334155; - border: 1px solid #385963; - } - """ - ) + self.apply_dark_theme() else: - self.apply_styles() + self.apply_light_theme() + + From 1b2c9930e6ce44370a7f89c0957be655777753d3 Mon Sep 17 00:00:00 2001 From: Apostolos Chalis Date: Fri, 5 Jun 2026 19:53:39 +0300 Subject: [PATCH 3/6] gitignored useless files --- .gitignore | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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 From 471383fe5217d8cf919958584e90bf146483e6ba Mon Sep 17 00:00:00 2001 From: Apostolos Chalis Date: Fri, 5 Jun 2026 19:55:48 +0300 Subject: [PATCH 4/6] Removed useless imports --- ui/main_window.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/ui/main_window.py b/ui/main_window.py index c83a007..7f59380 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -1,12 +1,10 @@ import json from PyQt6.QtCore import Qt, QUrl -from PyQt6.QtGui import QDesktopServices from PyQt6.QtWidgets import ( QFrame, QHBoxLayout, QLabel, - QListWidget, QListWidgetItem, QMainWindow, QMessageBox, @@ -14,9 +12,7 @@ QVBoxLayout, QWidget, QScrollArea, ) -from PyQt6.QtGui import QDesktopServices, QPixmap -from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkRequest -from PyQt6.QtCore import QByteArray +from PyQt6.QtGui import QDesktopServices from database import Database from settings import FEEDS_FILE From 33c47efe1d95660ff6bf1ff70a8052b27f04f3ed Mon Sep 17 00:00:00 2001 From: Apostolos Chalis Date: Fri, 5 Jun 2026 20:00:18 +0300 Subject: [PATCH 5/6] Removed useless __init__.py file --- __init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 __init__.py diff --git a/__init__.py b/__init__.py deleted file mode 100644 index e69de29..0000000 From 2a84e25f9eec457b0e9d15ba0c558f706b4df545 Mon Sep 17 00:00:00 2001 From: Apostolos Chalis Date: Fri, 5 Jun 2026 20:06:29 +0300 Subject: [PATCH 6/6] Removed duplicate code --- ui/main_window.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/ui/main_window.py b/ui/main_window.py index 7f59380..355ae0b 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -24,7 +24,6 @@ ARTICLE_LINK_ROLE = 1000 - class UniNewsWindow(QMainWindow): def __init__(self): super().__init__() @@ -52,9 +51,6 @@ def __init__(self): self.apply_theme() self.apply_refresh_timer() - if self.app_settings.get("refresh_on_startup", True): - self.refresh_news() - def setup_ui(self): self.root = QWidget() self.root_layout = QVBoxLayout(self.root) @@ -606,6 +602,3 @@ def apply_theme(self) -> None: self.apply_dark_theme() else: self.apply_light_theme() - - -