From 492c46f0ab15090a2bc2d195a05d6d6060cefbcc Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 16 Jul 2026 13:38:52 +0100 Subject: [PATCH] refactor(news): replace legacy WordPress API with embedded FAF-Newsfeed webview Migrates the client's news tab away from parsing the old WordPress JSON API and rendering custom PyQt widgets manually. Instead, we now embed the modern FAF-Newsfeed web dashboard directly using QWebEngineView. - Updated `src/news/_newswidget.py` to embed `QWebEngineView` pointing to https://faforever.github.io/FAF-Newsfeed/ - Simplified `src/news/__init__.py` to export only `NewsWidget` - Deleted obsolete files: - `src/news/wpapi.py` (old WordPress API integration) - `src/news/newsmanager.py` (old content management logic) - `src/news/newsitem.py` (old custom Qt item delegate paint rendering) Note: This transition introduces a runtime dependency on `PyQtWebEngine`. --- src/news/__init__.py | 8 +-- src/news/_newswidget.py | 156 +++++----------------------------------- src/news/newsitem.py | 92 ------------------------ src/news/newsmanager.py | 35 --------- src/news/wpapi.py | 49 ------------- test_web_isolated.py | 28 ++++++++ 6 files changed, 47 insertions(+), 321 deletions(-) delete mode 100644 src/news/newsitem.py delete mode 100644 src/news/newsmanager.py delete mode 100644 src/news/wpapi.py create mode 100644 test_web_isolated.py diff --git a/src/news/__init__.py b/src/news/__init__.py index fbc0740e2..e145a67cc 100644 --- a/src/news/__init__.py +++ b/src/news/__init__.py @@ -1,11 +1,5 @@ from ._newswidget import NewsWidget -from .newsitem import NewsItem -from .newsmanager import NewsManager -from .wpapi import WPAPI __all__ = ( "NewsWidget", - "NewsItem", - "NewsManager", - "WPAPI", -) +) \ No newline at end of file diff --git a/src/news/_newswidget.py b/src/news/_newswidget.py index c1993a8d1..4b8dc425c 100644 --- a/src/news/_newswidget.py +++ b/src/news/_newswidget.py @@ -1,155 +1,35 @@ import logging -import os.path -from typing import Any - -from PyQt6.QtCore import QPoint -from PyQt6.QtCore import QSize -from PyQt6.QtCore import QUrl -from PyQt6.QtGui import QDesktopServices -from PyQt6.QtGui import QPixmap -from PyQt6.QtWidgets import QToolTip -from PyQt6.QtWidgets import QWidget +from PyQt5.QtCore import QUrl +from PyQt5.QtWidgets import QWidget, QVBoxLayout +from PyQt5.QtWebEngineWidgets import QWebEngineView from src import util -from src.config import Settings -from src.downloadManager import Downloader -from src.downloadManager import DownloadRequest - -from .newsitem import NewsItem -from .newsitem import NewsItemDelegate -from .newsmanager import NewsManager logger = logging.getLogger(__name__) - +# Safely load the default container UI FormClass, BaseClass = util.THEME.loadUiType("news/news.ui") - - class NewsWidget(FormClass, BaseClass): - IMAGE_SIZE = QSize(600, 338) + NEWS_FEED_URL = "https://faforever.github.io/FAF-Newsfeed/" def __init__(self, parent: QWidget | None = None) -> None: BaseClass.__init__(self, parent) - self.setupUi(self) - self._downloader = Downloader(util.NEWS_CACHE_DIR) - self._images_dl_request = DownloadRequest() - self._images_dl_request.done.connect(self.item_image_downloaded) - - self.newsManager = NewsManager(self) - self.newsItems: list[NewsItem] = [] - - self.settingsFrame.hide() - self.hideNewsEdit.setText(Settings.get('news/hideWords', "")) - - self.newsList.setIconSize(QSize(0, 0)) - self.newsList.setItemDelegate(NewsItemDelegate(self)) - self.newsList.currentItemChanged.connect(self.itemChanged) - self.newsSettings.pressed.connect(self.showSettings) - self.showAllButton.pressed.connect(self.showAll) - self.hideNewsEdit.textEdited.connect(self.updateNewsFilter) - self.hideNewsEdit.cursorPositionChanged.connect(self.showEditToolTip) - self.newsLinkButton.clicked.connect(self.open_news_in_browser) - - def addNews(self, newsPost: dict[str, Any]) -> None: - newsItem = NewsItem(newsPost, self.newsList) - self.newsItems.append(newsItem) - - def download_image(self, img_url: str) -> None: - name = os.path.basename(img_url) - self._downloader.download(name, self._images_dl_request, img_url) - - def item_image_downloaded(self, image_name: str, result: tuple[str, bool]) -> None: - image_path, download_failed = result - if not download_failed: - pixmap = QPixmap(image_path) - scaled = pixmap.scaled(self.IMAGE_SIZE) - self.imageLabel.setPixmap(scaled) + # Check if news.ui already defines a "newsLayout", otherwise build one + if hasattr(self, 'newsLayout') and self.newsLayout is not None: + layout = self.newsLayout else: - self.imageLabel.clear() - self.show_newspage() - - def itemChanged(self, current: NewsItem | None, previous: NewsItem | None) -> None: - if current is None: - return + layout = QVBoxLayout(self) + self.setLayout(layout) - url = current.newsPost["img_url"] - image_name = os.path.basename(url) - image_path = os.path.join(util.NEWS_CACHE_DIR, image_name) - if os.path.isfile(image_path): - self.imageLabel.setPixmap(QPixmap(image_path).scaled(self.IMAGE_SIZE)) - self.show_newspage() - else: - self.imageLabel.clear() - self._downloader.download(image_name, self._images_dl_request, url) - - def show_newspage(self) -> None: - current = self.newsList.currentItem() - if current is None: - return - content = current.newsPost["excerpt"].strip().removeprefix("

").removesuffix("

") - self.newsTitleLabel.setText(current.newsPost["title"]) - self.bodyLabel.setText(content) - - def showAll(self) -> None: - for item in self.newsItems: - item.setHidden(False) - self.updateLabel(0) - - def showEditToolTip(self) -> None: - """ - Default tooltips are too slow and disappear when user starts typing - """ - widget = self.hideNewsEdit - position = widget.mapToGlobal( - QPoint(int(widget.width()), -widget.height() // 2), - ) - QToolTip.showText( - position, - "To separate multiple words use commas: nomads,server,dev", - ) - - def showSettings(self): - if self.settingsFrame.isHidden(): - self.settingsFrame.show() - else: - self.settingsFrame.hide() - - def updateNewsFilter(self, text=False): - if text is not False: - Settings.set('news/hideWords', text) - - filterList = Settings.get('news/hideWords', "").lower().split(",") - newsHidden = 0 - - if filterList[0]: - for item in self.newsItems: - for word in filterList: - if word in item.newsPost["title"].lower(): - item.setHidden(True) - newsHidden += 1 - break - else: - item.setHidden(False) - else: - for item in self.newsItems: - item.setHidden(False) - - self.updateLabel(newsHidden) - - def updateLabel(self, number): - self.totalHidden.setText("NEWS HIDDEN: " + str(number)) - - def open_news_in_browser(self) -> None: - current = self.newsList.currentItem() - if current is None: - return - if current.newsPost["external_link"] == "": - external_link = current.newsPost["link"] - else: - external_link = current.newsPost["external_link"] - QDesktopServices.openUrl(QUrl(external_link)) + # Inject the new WebEngine client + self.browser = QWebEngineView(self) + self.browser.setUrl(QUrl(self.NEWS_FEED_URL)) + + layout.addWidget(self.browser) def on_news_loaded(self) -> None: - self.stackedWidget.setCurrentIndex(1) + # Keep this trigger alive for the parent manager navigation flow + if hasattr(self, 'stackedWidget') and self.stackedWidget is not None: + self.stackedWidget.setCurrentIndex(1) \ No newline at end of file diff --git a/src/news/newsitem.py b/src/news/newsitem.py deleted file mode 100644 index 2973513a6..000000000 --- a/src/news/newsitem.py +++ /dev/null @@ -1,92 +0,0 @@ -import logging -from typing import Any -from typing import cast - -from PyQt6 import QtCore -from PyQt6 import QtGui -from PyQt6 import QtWidgets - -from src import util - -logger = logging.getLogger(__name__) - - -class NewsItemDelegate(QtWidgets.QStyledItemDelegate): - def __init__(self, parent: QtCore.QObject | None = None) -> None: - super().__init__(parent) - - self.html = QtGui.QTextDocument() - to = QtGui.QTextOption() - to.setWrapMode(QtGui.QTextOption.WrapMode.WordWrap) - self.html.setDefaultTextOption(to) - self.html.setTextWidth(NewsItem.TEXTWIDTH) - - def paint( - self, - painter: QtGui.QPainter | None, - option: QtWidgets.QStyleOptionViewItem, - index: QtCore.QModelIndex, - ) -> None: - if painter is None: - return - - self.initStyleOption(option, index) - painter.save() - - self.html.setHtml(option.text) - - # clear icon and text before letting the control draw itself because - # we're rendering these parts ourselves - option.icon = QtGui.QIcon() - option.text = "" - - style = cast(QtWidgets.QStyle, option.widget.style()) - style.drawControl( - QtWidgets.QStyle.ControlElement.CE_ItemViewItem, option, painter, option.widget, - ) - - painter.translate(option.rect.left() + 10, option.rect.top() + 10) - clip = QtCore.QRectF(0, 0, option.rect.width() - 10 - 5, option.rect.height()) - self.html.drawContents(painter, clip) - - painter.restore() - - def sizeHint( - self, - option: QtWidgets.QStyleOptionViewItem, - index: QtCore.QModelIndex, - ) -> QtCore.QSize: - self.initStyleOption(option, index) - self.html.setHtml(option.text) - return QtCore.QSize(NewsItem.TEXTWIDTH + NewsItem.PADDING, NewsItem.TEXTHEIGHT) - - -class NewsItem(QtWidgets.QListWidgetItem): - TEXTWIDTH = 230 - TEXTHEIGHT = 85 - PADDING = 10 - - FORMATTER = util.THEME.readfile("news/formatters/newsitem.qhtml") - COLORS = util.THEME.find_stylesheet_style_as_dict("NewsItemFormatter::custom") - - def __init__( - self, - newsPost: dict[str, Any], - parent: QtWidgets.QListWidget | None = None, - ) -> None: - super().__init__(parent) - self.newsPost = newsPost - - self.setText( - self.FORMATTER.format( - author=newsPost['author'][0]['name'], - date=newsPost['date'], - title=newsPost['title'], - **self.COLORS, - ), - ) - - def __lt__(self, other: object) -> bool: - if not isinstance(other, NewsItem): - return False - return self.newsPost['date'].__lt__(other.newsPost['date']) diff --git a/src/news/newsmanager.py b/src/news/newsmanager.py deleted file mode 100644 index 2ad0bc070..000000000 --- a/src/news/newsmanager.py +++ /dev/null @@ -1,35 +0,0 @@ -import logging -from typing import TYPE_CHECKING -from typing import Any - -from PyQt6 import QtCore -from PyQt6.QtCore import QObject - -if TYPE_CHECKING: - from src.news._newswidget import NewsWidget - -from .wpapi import WPAPI - -logger = logging.getLogger(__name__) - - -class NewsManager(QObject): - def __init__(self, widget: NewsWidget) -> None: - QObject.__init__(self) - self.widget = widget - - self.WpApi = WPAPI() - self.WpApi.newsDone.connect(self.on_wpapi_done) - self.WpApi.download(page=1, perpage=20) - - @QtCore.pyqtSlot(list) - def on_wpapi_done(self, items: list[dict[str, Any]]): - for item in items: - self.widget.addNews(item) - - self.widget.updateNewsFilter() - for i in range(len(items)): - if not self.widget.newsList.item(i).isHidden(): - self.widget.newsList.setCurrentItem(self.widget.newsList.item(i)) - break - self.widget.on_news_loaded() diff --git a/src/news/wpapi.py b/src/news/wpapi.py deleted file mode 100644 index 129a6d46b..000000000 --- a/src/news/wpapi.py +++ /dev/null @@ -1,49 +0,0 @@ -import logging -from typing import Any - -from PyQt6 import QtCore - -from src.api.ApiBase import JsonApiBase - -logger = logging.getLogger(__name__) - -# FIXME: Make setting -WPAPI_ROOT = "/wp-json/wp/v2/posts?per_page={perpage}&page={page}&_embed=1" - - -class WPAPI(QtCore.QObject): - newsDone = QtCore.pyqtSignal(list) - - def __init__(self) -> None: - super().__init__() - self.api = JsonApiBase() - self.api.host_config_key = "news/host" - - def process_news(self, news: list[dict[str, Any]]) -> None: - try: - posts = [ - { - 'title': post.get('title', {}).get('rendered'), - 'body': post.get('content', {}).get('rendered'), - 'date': post.get('date'), - 'excerpt': post.get('excerpt', {}).get('rendered'), - 'author': post.get('_embedded', {}).get('author'), - 'link': post.get('link'), - 'external_link': post.get('newshub_externalLinkUrl', ''), - 'img_url': ( - post.get('_embedded', {}) - .get('wp:featuredmedia', [{}])[0] - .get('source_url', "") - ), - } for post in news - ] - - self.newsDone.emit(posts) - except Exception: - logger.exception("Error handling wp data") - - def download(self, page: int = 1, perpage: int = 10) -> None: - path = WPAPI_ROOT.format(page=page, perpage=perpage) - # news api returns list of json objects instead of json object - # but JsonApiBase class is convenient, so we just smash ignore for now - self.api.get(path, self.process_news) # type: ignore[arg-type] diff --git a/test_web_isolated.py b/test_web_isolated.py new file mode 100644 index 000000000..443c1e9cc --- /dev/null +++ b/test_web_isolated.py @@ -0,0 +1,28 @@ +import sys +from PyQt5.QtCore import QUrl +from PyQt5.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget +from PyQt5.QtWebEngineWidgets import QWebEngineView + +class StandaloneNewsWindow(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("FAF Newsfeed - Web-Only Test Harness") + self.resize(1024, 768) + + # Main Layout container + central_widget = QWidget(self) + self.setCentralWidget(central_widget) + layout = QVBoxLayout(central_widget) + layout.setContentsMargins(0, 0, 0, 0) + + # Embedded Chromium browser instance + self.browser = QWebEngineView(self) + self.browser.setUrl(QUrl("https://faforever.github.io/FAF-Newsfeed/")) + + layout.addWidget(self.browser) + +if __name__ == "__main__": + app = QApplication(sys.argv) + window = StandaloneNewsWindow() + window.show() + sys.exit(app.exec_()) \ No newline at end of file