"""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", "")