feat(news): add Nextcloud News app integration

Add full integration for the Nextcloud News (RSS/Atom reader) app:

- Add NewsClient with complete CRUD operations for folders, feeds, and items
- Add 8 read-only MCP tools for listing/getting folders, feeds, items
- Add Pydantic models for News entities with camelCase alias support
- Add vector sync support for starred + unread items
- Add HTML to Markdown converter using markdownify for better embeddings
- Add Docker post-install hook to enable News app
- Add 25 unit tests for NewsClient API methods

Vector sync indexes starred and unread items, providing a balanced approach
that captures important (starred) and current (unread) content without
indexing the entire article history.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2025-11-29 14:30:23 +01:00
parent 1b1667bc2b
commit a33f6a2f15
15 changed files with 2055 additions and 7 deletions
+2
View File
@@ -18,6 +18,7 @@ from .contacts import ContactsClient
from .cookbook import CookbookClient
from .deck import DeckClient
from .groups import GroupsClient
from .news import NewsClient
from .notes import NotesClient
from .sharing import SharingClient
from .tables import TablesClient
@@ -81,6 +82,7 @@ class NextcloudClient:
self.contacts = ContactsClient(self._client, username)
self.cookbook = CookbookClient(self._client, username)
self.deck = DeckClient(self._client, username)
self.news = NewsClient(self._client, username)
self.users = UsersClient(self._client, username)
self.groups = GroupsClient(self._client, username)
self.sharing = SharingClient(self._client, username)
+394
View File
@@ -0,0 +1,394 @@
"""Client for Nextcloud News app operations."""
import logging
from enum import IntEnum
from typing import Any
from .base import BaseNextcloudClient
logger = logging.getLogger(__name__)
class NewsItemType(IntEnum):
"""Type constants for News API item queries."""
FEED = 0 # Single feed
FOLDER = 1 # Folder and its feeds
STARRED = 2 # All starred items
ALL = 3 # All items
class NewsClient(BaseNextcloudClient):
"""Client for Nextcloud News app operations."""
app_name = "news"
API_BASE = "/apps/news/api/v1-3"
# --- Folders ---
async def get_folders(self) -> list[dict[str, Any]]:
"""Get all folders."""
response = await self._make_request("GET", f"{self.API_BASE}/folders")
return response.json().get("folders", [])
async def create_folder(self, name: str) -> dict[str, Any]:
"""Create a new folder.
Args:
name: Folder name
Returns:
Created folder data
Raises:
HTTPStatusError: 409 if folder name already exists,
422 if name is empty
"""
response = await self._make_request(
"POST", f"{self.API_BASE}/folders", json={"name": name}
)
folders = response.json().get("folders", [])
return folders[0] if folders else {}
async def rename_folder(self, folder_id: int, name: str) -> None:
"""Rename a folder.
Args:
folder_id: Folder ID
name: New folder name
Raises:
HTTPStatusError: 404 if folder not found, 409 if name exists
"""
await self._make_request(
"PUT", f"{self.API_BASE}/folders/{folder_id}", json={"name": name}
)
async def delete_folder(self, folder_id: int) -> None:
"""Delete a folder and all its feeds/items.
Args:
folder_id: Folder ID
Raises:
HTTPStatusError: 404 if folder not found
"""
await self._make_request("DELETE", f"{self.API_BASE}/folders/{folder_id}")
async def mark_folder_read(self, folder_id: int, newest_item_id: int) -> None:
"""Mark all items in a folder as read.
Args:
folder_id: Folder ID
newest_item_id: ID of newest item to mark read (prevents marking
items user hasn't seen yet)
Raises:
HTTPStatusError: 404 if folder not found
"""
await self._make_request(
"POST",
f"{self.API_BASE}/folders/{folder_id}/read",
json={"newestItemId": newest_item_id},
)
# --- Feeds ---
async def get_feeds(self) -> dict[str, Any]:
"""Get all feeds with metadata.
Returns:
Dict with keys:
- feeds: List of feed objects
- starredCount: Number of starred items
- newestItemId: ID of newest item (omitted if no items)
"""
response = await self._make_request("GET", f"{self.API_BASE}/feeds")
return response.json()
async def create_feed(
self, url: str, folder_id: int | None = None
) -> dict[str, Any]:
"""Subscribe to a new feed.
Args:
url: Feed URL
folder_id: Optional folder ID (None for root)
Returns:
Created feed data
Raises:
HTTPStatusError: 409 if feed already exists, 422 if URL is invalid
"""
body: dict[str, Any] = {"url": url}
if folder_id is not None:
body["folderId"] = folder_id
response = await self._make_request("POST", f"{self.API_BASE}/feeds", json=body)
data = response.json()
feeds = data.get("feeds", [])
return feeds[0] if feeds else {}
async def delete_feed(self, feed_id: int) -> None:
"""Unsubscribe from a feed (deletes all items).
Args:
feed_id: Feed ID
Raises:
HTTPStatusError: 404 if feed not found
"""
await self._make_request("DELETE", f"{self.API_BASE}/feeds/{feed_id}")
async def move_feed(self, feed_id: int, folder_id: int | None) -> None:
"""Move a feed to a different folder.
Args:
feed_id: Feed ID
folder_id: Target folder ID (None for root)
Raises:
HTTPStatusError: 404 if feed not found
"""
await self._make_request(
"POST",
f"{self.API_BASE}/feeds/{feed_id}/move",
json={"folderId": folder_id},
)
async def rename_feed(self, feed_id: int, title: str) -> None:
"""Rename a feed.
Args:
feed_id: Feed ID
title: New feed title
Raises:
HTTPStatusError: 404 if feed not found
"""
await self._make_request(
"POST",
f"{self.API_BASE}/feeds/{feed_id}/rename",
json={"feedTitle": title},
)
async def mark_feed_read(self, feed_id: int, newest_item_id: int) -> None:
"""Mark all items in a feed as read.
Args:
feed_id: Feed ID
newest_item_id: ID of newest item to mark read
Raises:
HTTPStatusError: 404 if feed not found
"""
await self._make_request(
"POST",
f"{self.API_BASE}/feeds/{feed_id}/read",
json={"newestItemId": newest_item_id},
)
# --- Items ---
async def get_items(
self,
batch_size: int = 50,
offset: int = 0,
type_: int = NewsItemType.ALL,
id_: int = 0,
get_read: bool = True,
oldest_first: bool = False,
) -> list[dict[str, Any]]:
"""Get items (articles) with filtering.
Args:
batch_size: Number of items to return (-1 for all)
offset: Item ID to start after (for pagination)
type_: Item type filter (NewsItemType)
id_: Feed/folder ID (ignored for STARRED/ALL types)
get_read: Include read items
oldest_first: Sort oldest first instead of newest
Returns:
List of item objects
"""
params: dict[str, Any] = {
"batchSize": batch_size,
"offset": offset,
"type": type_,
"id": id_,
"getRead": str(get_read).lower(),
"oldestFirst": str(oldest_first).lower(),
}
response = await self._make_request(
"GET", f"{self.API_BASE}/items", params=params
)
return response.json().get("items", [])
async def get_item(self, item_id: int) -> dict[str, Any]:
"""Get a specific item by ID.
Note: The News API doesn't have a direct single-item endpoint,
so we fetch all items and filter. For efficiency, consider
caching or using get_items with specific feed if known.
Args:
item_id: Item ID
Returns:
Item data
Raises:
ValueError: If item not found
"""
# Fetch all items and find the one we need
# This is inefficient but the API doesn't provide a direct endpoint
items = await self.get_items(batch_size=-1, get_read=True)
for item in items:
if item.get("id") == item_id:
return item
raise ValueError(f"Item {item_id} not found")
async def get_updated_items(
self,
last_modified: int,
type_: int = NewsItemType.ALL,
id_: int = 0,
) -> list[dict[str, Any]]:
"""Get items modified since a timestamp (for delta sync).
Args:
last_modified: Unix timestamp (seconds or microseconds)
type_: Item type filter
id_: Feed/folder ID
Returns:
List of modified items (includes deleted items)
"""
params: dict[str, Any] = {
"lastModified": last_modified,
"type": type_,
"id": id_,
}
response = await self._make_request(
"GET", f"{self.API_BASE}/items/updated", params=params
)
return response.json().get("items", [])
async def mark_item_read(self, item_id: int) -> None:
"""Mark a single item as read.
Args:
item_id: Item ID
Raises:
HTTPStatusError: 404 if item not found
"""
await self._make_request("POST", f"{self.API_BASE}/items/{item_id}/read")
async def mark_item_unread(self, item_id: int) -> None:
"""Mark a single item as unread.
Args:
item_id: Item ID
Raises:
HTTPStatusError: 404 if item not found
"""
await self._make_request("POST", f"{self.API_BASE}/items/{item_id}/unread")
async def star_item(self, item_id: int) -> None:
"""Star (favorite) a single item.
Args:
item_id: Item ID
Raises:
HTTPStatusError: 404 if item not found
"""
await self._make_request("POST", f"{self.API_BASE}/items/{item_id}/star")
async def unstar_item(self, item_id: int) -> None:
"""Unstar a single item.
Args:
item_id: Item ID
Raises:
HTTPStatusError: 404 if item not found
"""
await self._make_request("POST", f"{self.API_BASE}/items/{item_id}/unstar")
async def mark_items_read(self, item_ids: list[int]) -> None:
"""Mark multiple items as read.
Args:
item_ids: List of item IDs
"""
await self._make_request(
"POST", f"{self.API_BASE}/items/read/multiple", json={"itemIds": item_ids}
)
async def mark_items_unread(self, item_ids: list[int]) -> None:
"""Mark multiple items as unread.
Args:
item_ids: List of item IDs
"""
await self._make_request(
"POST",
f"{self.API_BASE}/items/unread/multiple",
json={"itemIds": item_ids},
)
async def star_items(self, item_ids: list[int]) -> None:
"""Star multiple items.
Args:
item_ids: List of item IDs
"""
await self._make_request(
"POST", f"{self.API_BASE}/items/star/multiple", json={"itemIds": item_ids}
)
async def unstar_items(self, item_ids: list[int]) -> None:
"""Unstar multiple items.
Args:
item_ids: List of item IDs
"""
await self._make_request(
"POST",
f"{self.API_BASE}/items/unstar/multiple",
json={"itemIds": item_ids},
)
async def mark_all_read(self, newest_item_id: int) -> None:
"""Mark all items as read.
Args:
newest_item_id: ID of newest item to mark read
"""
await self._make_request(
"POST", f"{self.API_BASE}/items/read", json={"newestItemId": newest_item_id}
)
# --- Status ---
async def get_status(self) -> dict[str, Any]:
"""Get News app status and configuration.
Returns:
Dict with version and warnings
"""
response = await self._make_request("GET", f"{self.API_BASE}/status")
return response.json()
async def get_version(self) -> str:
"""Get News app version.
Returns:
Version string (e.g., "25.0.0")
"""
response = await self._make_request("GET", f"{self.API_BASE}/version")
return response.json().get("version", "")