Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c3af591810 | |||
| edb0af2bda | |||
| 7d5bb54b64 | |||
| a18c63792a | |||
| 0561b55af5 | |||
| d785ed9054 | |||
| 88fb8417fd | |||
| f70d743c8b | |||
| 251b8a10c0 | |||
| 3f06e2ee77 | |||
| 7f11c793ef | |||
| 92c4bf36f6 | |||
| 0bedbf1877 | |||
| a5cb6e1242 | |||
| a33f6a2f15 |
@@ -42,7 +42,7 @@ jobs:
|
|||||||
VECTOR_SYNC_SCAN_INTERVAL: "5"
|
VECTOR_SYNC_SCAN_INTERVAL: "5"
|
||||||
|
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5
|
||||||
|
|
||||||
- name: Wait for Nextcloud to be ready
|
- name: Wait for Nextcloud to be ready
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5
|
||||||
- name: Install Python 3.11
|
- name: Install Python 3.11
|
||||||
run: uv python install 3.11
|
run: uv python install 3.11
|
||||||
- name: Build
|
- name: Build
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5
|
||||||
- name: Check format
|
- name: Check format
|
||||||
run: |
|
run: |
|
||||||
uv run --frozen ruff format --diff
|
uv run --frozen ruff format --diff
|
||||||
@@ -56,7 +56,7 @@ jobs:
|
|||||||
up-flags: "--build"
|
up-flags: "--build"
|
||||||
|
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5
|
||||||
|
|
||||||
- name: Install Playwright dependencies
|
- name: Install Playwright dependencies
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -1,3 +1,27 @@
|
|||||||
|
## v0.49.1 (2025-12-09)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Revert mcp version <1.23
|
||||||
|
|
||||||
|
## v0.49.0 (2025-12-08)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **news**: add Nextcloud News app integration
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- resolve all type checking errors (8 errors fixed)
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- **news**: simplify vector sync to fetch all items
|
||||||
|
|
||||||
|
### Perf
|
||||||
|
|
||||||
|
- **news**: use direct API endpoint for get_item()
|
||||||
|
|
||||||
## v0.48.6 (2025-12-03)
|
## v0.48.6 (2025-12-03)
|
||||||
|
|
||||||
### Fix
|
### Fix
|
||||||
|
|||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -euox pipefail
|
||||||
|
|
||||||
|
php /var/www/html/occ app:enable news
|
||||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
|||||||
name: nextcloud-mcp-server
|
name: nextcloud-mcp-server
|
||||||
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
||||||
type: application
|
type: application
|
||||||
version: 0.48.6
|
version: 0.49.1
|
||||||
appVersion: "0.48.6"
|
appVersion: "0.49.1"
|
||||||
keywords:
|
keywords:
|
||||||
- nextcloud
|
- nextcloud
|
||||||
- mcp
|
- mcp
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: docker.io/library/nextcloud:32.0.2@sha256:8cb1dc8c26944115469dd22f4965d2ed35bab9cf8c48d2bb052c8e9f83821ded
|
image: docker.io/library/nextcloud:32.0.2@sha256:04cc19547e586ac75e08dd056c11330d4ce4c5c561c89405b326180a37c19afb
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- 0.0.0.0:8080:80
|
- 0.0.0.0:8080:80
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ from nextcloud_mcp_server.server import (
|
|||||||
configure_contacts_tools,
|
configure_contacts_tools,
|
||||||
configure_cookbook_tools,
|
configure_cookbook_tools,
|
||||||
configure_deck_tools,
|
configure_deck_tools,
|
||||||
|
configure_news_tools,
|
||||||
configure_notes_tools,
|
configure_notes_tools,
|
||||||
configure_semantic_tools,
|
configure_semantic_tools,
|
||||||
configure_sharing_tools,
|
configure_sharing_tools,
|
||||||
@@ -514,7 +515,7 @@ async def load_oauth_client_credentials(
|
|||||||
# and the authorization server will limit them to these allowed scopes.
|
# and the authorization server will limit them to these allowed scopes.
|
||||||
#
|
#
|
||||||
# The PRM endpoint advertises the same scopes dynamically via @require_scopes decorators.
|
# The PRM endpoint advertises the same scopes dynamically via @require_scopes decorators.
|
||||||
dcr_scopes = "openid profile email notes:read notes:write calendar:read calendar:write todo:read todo:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write"
|
dcr_scopes = "openid profile email notes:read notes:write calendar:read calendar:write todo:read todo:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write news:read news:write"
|
||||||
|
|
||||||
# Add offline_access scope if refresh tokens are enabled
|
# Add offline_access scope if refresh tokens are enabled
|
||||||
enable_offline_access = os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() in (
|
enable_offline_access = os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() in (
|
||||||
@@ -1046,6 +1047,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
"contacts": configure_contacts_tools,
|
"contacts": configure_contacts_tools,
|
||||||
"cookbook": configure_cookbook_tools,
|
"cookbook": configure_cookbook_tools,
|
||||||
"deck": configure_deck_tools,
|
"deck": configure_deck_tools,
|
||||||
|
"news": configure_news_tools,
|
||||||
}
|
}
|
||||||
|
|
||||||
# If no specific apps are specified, enable all
|
# If no specific apps are specified, enable all
|
||||||
|
|||||||
@@ -303,10 +303,13 @@ class UnifiedTokenVerifier(TokenVerifier):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Introspection requires client authentication
|
# Introspection requires client authentication
|
||||||
|
client_id = self.settings.oidc_client_id
|
||||||
|
client_secret = self.settings.oidc_client_secret
|
||||||
|
assert client_id is not None and client_secret is not None
|
||||||
response = await self.http_client.post(
|
response = await self.http_client.post(
|
||||||
self.introspection_uri,
|
self.introspection_uri,
|
||||||
data={"token": token},
|
data={"token": token},
|
||||||
auth=(self.settings.oidc_client_id, self.settings.oidc_client_secret),
|
auth=(client_id, client_secret),
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ async def _get_authenticated_client(request: Request) -> httpx.AsyncClient:
|
|||||||
raise RuntimeError("BasicAuth credentials not configured")
|
raise RuntimeError("BasicAuth credentials not configured")
|
||||||
|
|
||||||
assert nextcloud_host is not None # Type narrowing for type checker
|
assert nextcloud_host is not None # Type narrowing for type checker
|
||||||
|
assert username is not None and password is not None # Type narrowing
|
||||||
return httpx.AsyncClient(
|
return httpx.AsyncClient(
|
||||||
base_url=nextcloud_host,
|
base_url=nextcloud_host,
|
||||||
auth=(username, password),
|
auth=(username, password),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from .contacts import ContactsClient
|
|||||||
from .cookbook import CookbookClient
|
from .cookbook import CookbookClient
|
||||||
from .deck import DeckClient
|
from .deck import DeckClient
|
||||||
from .groups import GroupsClient
|
from .groups import GroupsClient
|
||||||
|
from .news import NewsClient
|
||||||
from .notes import NotesClient
|
from .notes import NotesClient
|
||||||
from .sharing import SharingClient
|
from .sharing import SharingClient
|
||||||
from .tables import TablesClient
|
from .tables import TablesClient
|
||||||
@@ -81,6 +82,7 @@ class NextcloudClient:
|
|||||||
self.contacts = ContactsClient(self._client, username)
|
self.contacts = ContactsClient(self._client, username)
|
||||||
self.cookbook = CookbookClient(self._client, username)
|
self.cookbook = CookbookClient(self._client, username)
|
||||||
self.deck = DeckClient(self._client, username)
|
self.deck = DeckClient(self._client, username)
|
||||||
|
self.news = NewsClient(self._client, username)
|
||||||
self.users = UsersClient(self._client, username)
|
self.users = UsersClient(self._client, username)
|
||||||
self.groups = GroupsClient(self._client, username)
|
self.groups = GroupsClient(self._client, username)
|
||||||
self.sharing = SharingClient(self._client, username)
|
self.sharing = SharingClient(self._client, username)
|
||||||
|
|||||||
@@ -0,0 +1,385 @@
|
|||||||
|
"""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.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_id: Item ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Item data
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPStatusError: 404 if item not found
|
||||||
|
"""
|
||||||
|
response = await self._make_request("GET", f"{self.API_BASE}/items/{item_id}")
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
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", "")
|
||||||
@@ -1174,7 +1174,9 @@ class WebDAVClient(BaseNextcloudClient):
|
|||||||
|
|
||||||
if display_name_elem is not None and display_name_elem.text == tag_name:
|
if display_name_elem is not None and display_name_elem.text == tag_name:
|
||||||
tag_info = {
|
tag_info = {
|
||||||
"id": int(tag_id_elem.text) if tag_id_elem is not None else None,
|
"id": int(tag_id_elem.text)
|
||||||
|
if tag_id_elem is not None and tag_id_elem.text is not None
|
||||||
|
else None,
|
||||||
"name": display_name_elem.text,
|
"name": display_name_elem.text,
|
||||||
"userVisible": user_visible_elem.text.lower() == "true"
|
"userVisible": user_visible_elem.text.lower() == "true"
|
||||||
if user_visible_elem is not None
|
if user_visible_elem is not None
|
||||||
@@ -1369,7 +1371,9 @@ class WebDAVClient(BaseNextcloudClient):
|
|||||||
)
|
)
|
||||||
|
|
||||||
file_info = {
|
file_info = {
|
||||||
"id": int(fileid_elem.text) if fileid_elem is not None else None,
|
"id": int(fileid_elem.text)
|
||||||
|
if fileid_elem is not None and fileid_elem.text is not None
|
||||||
|
else None,
|
||||||
"path": path,
|
"path": path,
|
||||||
"name": displayname_elem.text
|
"name": displayname_elem.text
|
||||||
if displayname_elem is not None
|
if displayname_elem is not None
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""Pydantic models for Nextcloud News app responses."""
|
||||||
|
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from .base import BaseResponse
|
||||||
|
|
||||||
|
|
||||||
|
class NewsFolder(BaseModel):
|
||||||
|
"""Model for a News folder."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
|
id: int = Field(description="Folder ID")
|
||||||
|
name: str = Field(description="Folder name")
|
||||||
|
|
||||||
|
|
||||||
|
class NewsFeed(BaseModel):
|
||||||
|
"""Model for a News feed (RSS/Atom subscription)."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
|
id: int = Field(description="Feed ID")
|
||||||
|
url: str = Field(description="Feed URL")
|
||||||
|
title: str = Field(description="Feed title")
|
||||||
|
favicon_link: str | None = Field(
|
||||||
|
None, alias="faviconLink", description="Favicon URL"
|
||||||
|
)
|
||||||
|
link: str | None = Field(None, description="Website link")
|
||||||
|
added: int = Field(description="Unix timestamp when feed was added")
|
||||||
|
folder_id: int | None = Field(
|
||||||
|
None, alias="folderId", description="Parent folder ID"
|
||||||
|
)
|
||||||
|
unread_count: int = Field(
|
||||||
|
0, alias="unreadCount", description="Number of unread items"
|
||||||
|
)
|
||||||
|
ordering: int = Field(
|
||||||
|
0, description="Feed ordering (0=default, 1=oldest, 2=newest)"
|
||||||
|
)
|
||||||
|
pinned: bool = Field(False, description="Whether feed is pinned to top")
|
||||||
|
update_error_count: int = Field(
|
||||||
|
0, alias="updateErrorCount", description="Consecutive update failures"
|
||||||
|
)
|
||||||
|
last_update_error: str | None = Field(
|
||||||
|
None, alias="lastUpdateError", description="Last update error message"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_errors(self) -> bool:
|
||||||
|
"""Check if feed has update errors."""
|
||||||
|
return self.update_error_count > 0
|
||||||
|
|
||||||
|
|
||||||
|
class NewsItem(BaseModel):
|
||||||
|
"""Model for a News item (article) with full content."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
|
id: int = Field(description="Item ID")
|
||||||
|
guid: str = Field(description="Globally unique identifier")
|
||||||
|
guid_hash: str = Field(alias="guidHash", description="MD5 hash of GUID")
|
||||||
|
url: str | None = Field(None, description="Article URL")
|
||||||
|
title: str = Field(description="Article title")
|
||||||
|
author: str | None = Field(None, description="Article author")
|
||||||
|
pub_date: int | None = Field(
|
||||||
|
None, alias="pubDate", description="Publication timestamp"
|
||||||
|
)
|
||||||
|
body: str | None = Field(None, description="Article content (HTML)")
|
||||||
|
enclosure_mime: str | None = Field(
|
||||||
|
None, alias="enclosureMime", description="Enclosure MIME type"
|
||||||
|
)
|
||||||
|
enclosure_link: str | None = Field(
|
||||||
|
None, alias="enclosureLink", description="Enclosure URL"
|
||||||
|
)
|
||||||
|
media_thumbnail: str | None = Field(
|
||||||
|
None, alias="mediaThumbnail", description="Media thumbnail URL"
|
||||||
|
)
|
||||||
|
media_description: str | None = Field(
|
||||||
|
None, alias="mediaDescription", description="Media description"
|
||||||
|
)
|
||||||
|
feed_id: int = Field(alias="feedId", description="Parent feed ID")
|
||||||
|
unread: bool = Field(True, description="Whether item is unread")
|
||||||
|
starred: bool = Field(False, description="Whether item is starred")
|
||||||
|
rtl: bool = Field(False, description="Right-to-left text")
|
||||||
|
last_modified: int = Field(
|
||||||
|
alias="lastModified", description="Last modification timestamp"
|
||||||
|
)
|
||||||
|
fingerprint: str | None = Field(
|
||||||
|
None, description="Content fingerprint for deduplication"
|
||||||
|
)
|
||||||
|
content_hash: str | None = Field(
|
||||||
|
None, alias="contentHash", description="Content hash"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NewsItemSummary(BaseModel):
|
||||||
|
"""Lightweight model for News item list responses."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
|
id: int = Field(description="Item ID")
|
||||||
|
title: str = Field(description="Article title")
|
||||||
|
feed_id: int = Field(alias="feedId", description="Parent feed ID")
|
||||||
|
unread: bool = Field(True, description="Whether item is unread")
|
||||||
|
starred: bool = Field(False, description="Whether item is starred")
|
||||||
|
pub_date: int | None = Field(
|
||||||
|
None, alias="pubDate", description="Publication timestamp"
|
||||||
|
)
|
||||||
|
url: str | None = Field(None, description="Article URL")
|
||||||
|
author: str | None = Field(None, description="Article author")
|
||||||
|
|
||||||
|
|
||||||
|
class NewsStatus(BaseModel):
|
||||||
|
"""Model for News app status."""
|
||||||
|
|
||||||
|
version: str = Field(description="News app version")
|
||||||
|
warnings: dict = Field(default_factory=dict, description="Configuration warnings")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Response Models ---
|
||||||
|
|
||||||
|
|
||||||
|
class ListFoldersResponse(BaseResponse):
|
||||||
|
"""Response model for listing folders."""
|
||||||
|
|
||||||
|
results: List[NewsFolder] = Field(description="List of folders")
|
||||||
|
total_count: int = Field(description="Total number of folders")
|
||||||
|
|
||||||
|
|
||||||
|
class ListFeedsResponse(BaseResponse):
|
||||||
|
"""Response model for listing feeds."""
|
||||||
|
|
||||||
|
results: List[NewsFeed] = Field(description="List of feeds")
|
||||||
|
starred_count: int = Field(0, description="Number of starred items")
|
||||||
|
newest_item_id: int | None = Field(None, description="ID of newest item")
|
||||||
|
total_count: int = Field(description="Total number of feeds")
|
||||||
|
|
||||||
|
|
||||||
|
class ListItemsResponse(BaseResponse):
|
||||||
|
"""Response model for listing items."""
|
||||||
|
|
||||||
|
results: List[NewsItemSummary] = Field(description="List of items")
|
||||||
|
total_count: int = Field(description="Number of items returned")
|
||||||
|
has_more: bool = Field(False, description="Whether more items exist")
|
||||||
|
oldest_id: int | None = Field(None, description="Oldest item ID (for pagination)")
|
||||||
|
|
||||||
|
|
||||||
|
class GetItemResponse(BaseResponse):
|
||||||
|
"""Response model for getting a single item."""
|
||||||
|
|
||||||
|
item: NewsItem = Field(description="Full item details")
|
||||||
|
|
||||||
|
|
||||||
|
class FeedHealthResponse(BaseResponse):
|
||||||
|
"""Response model for feed health status."""
|
||||||
|
|
||||||
|
feed_id: int = Field(description="Feed ID")
|
||||||
|
title: str = Field(description="Feed title")
|
||||||
|
url: str = Field(description="Feed URL")
|
||||||
|
has_errors: bool = Field(description="Whether feed has update errors")
|
||||||
|
error_count: int = Field(description="Number of consecutive errors")
|
||||||
|
last_error: str | None = Field(None, description="Last error message")
|
||||||
|
|
||||||
|
|
||||||
|
class GetStatusResponse(BaseResponse):
|
||||||
|
"""Response model for app status."""
|
||||||
|
|
||||||
|
version: str = Field(description="News app version")
|
||||||
|
warnings: dict = Field(default_factory=dict, description="Configuration warnings")
|
||||||
@@ -60,7 +60,7 @@ class TraceContextFormatter(JsonFormatter):
|
|||||||
|
|
||||||
def add_fields(
|
def add_fields(
|
||||||
self,
|
self,
|
||||||
log_record: dict[str, Any],
|
log_data: dict[str, Any],
|
||||||
record: logging.LogRecord,
|
record: logging.LogRecord,
|
||||||
message_dict: dict[str, Any],
|
message_dict: dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -68,28 +68,28 @@ class TraceContextFormatter(JsonFormatter):
|
|||||||
Add custom fields to the log record, including trace context.
|
Add custom fields to the log record, including trace context.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
log_record: Dictionary to be serialized as JSON
|
log_data: Dictionary to be serialized as JSON
|
||||||
record: LogRecord instance
|
record: LogRecord instance
|
||||||
message_dict: Dictionary of extra fields from log call
|
message_dict: Dictionary of extra fields from log call
|
||||||
"""
|
"""
|
||||||
# Call parent to add standard fields
|
# Call parent to add standard fields
|
||||||
super().add_fields(log_record, record, message_dict)
|
super().add_fields(log_data, record, message_dict)
|
||||||
|
|
||||||
# Add trace context if available
|
# Add trace context if available
|
||||||
trace_context = get_trace_context()
|
trace_context = get_trace_context()
|
||||||
if trace_context:
|
if trace_context:
|
||||||
log_record["trace_id"] = trace_context.get("trace_id")
|
log_data["trace_id"] = trace_context.get("trace_id")
|
||||||
log_record["span_id"] = trace_context.get("span_id")
|
log_data["span_id"] = trace_context.get("span_id")
|
||||||
|
|
||||||
# Add standard fields with consistent naming
|
# Add standard fields with consistent naming
|
||||||
log_record["timestamp"] = self.formatTime(record)
|
log_data["timestamp"] = self.formatTime(record)
|
||||||
log_record["level"] = record.levelname
|
log_data["level"] = record.levelname
|
||||||
log_record["logger"] = record.name
|
log_data["logger"] = record.name
|
||||||
log_record["message"] = record.getMessage()
|
log_data["message"] = record.getMessage()
|
||||||
|
|
||||||
# Include exception info if present
|
# Include exception info if present
|
||||||
if record.exc_info:
|
if record.exc_info:
|
||||||
log_record["exception"] = self.formatException(record.exc_info)
|
log_data["exception"] = self.formatException(record.exc_info)
|
||||||
|
|
||||||
|
|
||||||
class TraceContextTextFormatter(logging.Formatter):
|
class TraceContextTextFormatter(logging.Formatter):
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ class OpenAIProvider(Provider):
|
|||||||
"Embedding not supported - no embedding_model configured"
|
"Embedding not supported - no embedding_model configured"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert self.embedding_model is not None # Type narrowing
|
||||||
response = await self.client.embeddings.create(
|
response = await self.client.embeddings.create(
|
||||||
input=text,
|
input=text,
|
||||||
model=self.embedding_model,
|
model=self.embedding_model,
|
||||||
@@ -204,6 +205,7 @@ class OpenAIProvider(Provider):
|
|||||||
@retry_on_rate_limit
|
@retry_on_rate_limit
|
||||||
async def _embed_batch_request(self, batch: list[str]) -> list[list[float]]:
|
async def _embed_batch_request(self, batch: list[str]) -> list[list[float]]:
|
||||||
"""Make a single batch embedding request with retry logic."""
|
"""Make a single batch embedding request with retry logic."""
|
||||||
|
assert self.embedding_model is not None # Type narrowing
|
||||||
response = await self.client.embeddings.create(
|
response = await self.client.embeddings.create(
|
||||||
input=batch,
|
input=batch,
|
||||||
model=self.embedding_model,
|
model=self.embedding_model,
|
||||||
|
|||||||
@@ -108,8 +108,8 @@ async def get_indexed_doc_types(user_id: str) -> set[str]:
|
|||||||
with_vectors=False, # Don't need vectors for type discovery
|
with_vectors=False, # Don't need vectors for type discovery
|
||||||
)
|
)
|
||||||
|
|
||||||
doc_types = {
|
doc_types: set[str] = {
|
||||||
point.payload.get("doc_type")
|
str(point.payload.get("doc_type"))
|
||||||
for point in scroll_results
|
for point in scroll_results
|
||||||
if point.payload.get("doc_type")
|
if point.payload.get("doc_type")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,6 +204,8 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
|
|||||||
results = []
|
results = []
|
||||||
|
|
||||||
for result in search_response.points:
|
for result in search_response.points:
|
||||||
|
if result.payload is None:
|
||||||
|
continue
|
||||||
# doc_id can be int (notes) or str (files - file paths)
|
# doc_id can be int (notes) or str (files - file paths)
|
||||||
doc_id = result.payload["doc_id"]
|
doc_id = result.payload["doc_id"]
|
||||||
doc_type = result.payload.get("doc_type", "note")
|
doc_type = result.payload.get("doc_type", "note")
|
||||||
|
|||||||
@@ -136,6 +136,8 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
|
|||||||
results = []
|
results = []
|
||||||
|
|
||||||
for result in search_response.points:
|
for result in search_response.points:
|
||||||
|
if result.payload is None:
|
||||||
|
continue
|
||||||
# doc_id can be int (notes) or str (files - file paths)
|
# doc_id can be int (notes) or str (files - file paths)
|
||||||
doc_id = result.payload["doc_id"]
|
doc_id = result.payload["doc_id"]
|
||||||
doc_type = result.payload.get("doc_type", "note")
|
doc_type = result.payload.get("doc_type", "note")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from .calendar import configure_calendar_tools
|
|||||||
from .contacts import configure_contacts_tools
|
from .contacts import configure_contacts_tools
|
||||||
from .cookbook import configure_cookbook_tools
|
from .cookbook import configure_cookbook_tools
|
||||||
from .deck import configure_deck_tools
|
from .deck import configure_deck_tools
|
||||||
|
from .news import configure_news_tools
|
||||||
from .notes import configure_notes_tools
|
from .notes import configure_notes_tools
|
||||||
from .semantic import configure_semantic_tools
|
from .semantic import configure_semantic_tools
|
||||||
from .sharing import configure_sharing_tools
|
from .sharing import configure_sharing_tools
|
||||||
@@ -13,6 +14,7 @@ __all__ = [
|
|||||||
"configure_contacts_tools",
|
"configure_contacts_tools",
|
||||||
"configure_cookbook_tools",
|
"configure_cookbook_tools",
|
||||||
"configure_deck_tools",
|
"configure_deck_tools",
|
||||||
|
"configure_news_tools",
|
||||||
"configure_notes_tools",
|
"configure_notes_tools",
|
||||||
"configure_semantic_tools",
|
"configure_semantic_tools",
|
||||||
"configure_sharing_tools",
|
"configure_sharing_tools",
|
||||||
|
|||||||
@@ -0,0 +1,360 @@
|
|||||||
|
"""MCP tools for Nextcloud News app."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from httpx import HTTPStatusError, RequestError
|
||||||
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
from mcp.shared.exceptions import McpError
|
||||||
|
from mcp.types import ErrorData
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth import require_scopes
|
||||||
|
from nextcloud_mcp_server.client.news import NewsItemType
|
||||||
|
from nextcloud_mcp_server.context import get_client
|
||||||
|
from nextcloud_mcp_server.models.news import (
|
||||||
|
FeedHealthResponse,
|
||||||
|
GetItemResponse,
|
||||||
|
GetStatusResponse,
|
||||||
|
ListFeedsResponse,
|
||||||
|
ListFoldersResponse,
|
||||||
|
ListItemsResponse,
|
||||||
|
NewsFeed,
|
||||||
|
NewsFolder,
|
||||||
|
NewsItem,
|
||||||
|
NewsItemSummary,
|
||||||
|
)
|
||||||
|
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def configure_news_tools(mcp: FastMCP):
|
||||||
|
"""Configure News app MCP tools."""
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@require_scopes("news:read")
|
||||||
|
@instrument_tool
|
||||||
|
async def nc_news_list_folders(ctx: Context) -> ListFoldersResponse:
|
||||||
|
"""List all News folders (requires news:read scope)."""
|
||||||
|
client = await get_client(ctx)
|
||||||
|
try:
|
||||||
|
folders_data = await client.news.get_folders()
|
||||||
|
folders = [NewsFolder(**f) for f in folders_data]
|
||||||
|
return ListFoldersResponse(results=folders, total_count=len(folders))
|
||||||
|
except RequestError as e:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(code=-1, message=f"Network error listing folders: {str(e)}")
|
||||||
|
)
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Failed to list folders: {e.response.status_code}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@require_scopes("news:read")
|
||||||
|
@instrument_tool
|
||||||
|
async def nc_news_list_feeds(ctx: Context) -> ListFeedsResponse:
|
||||||
|
"""List all News feeds with metadata (requires news:read scope).
|
||||||
|
|
||||||
|
Returns feeds with unread counts, error status, and overall starred count.
|
||||||
|
"""
|
||||||
|
client = await get_client(ctx)
|
||||||
|
try:
|
||||||
|
data = await client.news.get_feeds()
|
||||||
|
feeds = [NewsFeed(**f) for f in data.get("feeds", [])]
|
||||||
|
return ListFeedsResponse(
|
||||||
|
results=feeds,
|
||||||
|
starred_count=data.get("starredCount", 0),
|
||||||
|
newest_item_id=data.get("newestItemId"),
|
||||||
|
total_count=len(feeds),
|
||||||
|
)
|
||||||
|
except RequestError as e:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(code=-1, message=f"Network error listing feeds: {str(e)}")
|
||||||
|
)
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Failed to list feeds: {e.response.status_code}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@require_scopes("news:read")
|
||||||
|
@instrument_tool
|
||||||
|
async def nc_news_list_items(
|
||||||
|
ctx: Context,
|
||||||
|
feed_id: int | None = None,
|
||||||
|
folder_id: int | None = None,
|
||||||
|
starred_only: bool = False,
|
||||||
|
unread_only: bool = False,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> ListItemsResponse:
|
||||||
|
"""List News items (articles) with optional filtering (requires news:read scope).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
feed_id: Filter by specific feed ID
|
||||||
|
folder_id: Filter by specific folder ID
|
||||||
|
starred_only: Return only starred items
|
||||||
|
unread_only: Return only unread items
|
||||||
|
limit: Maximum number of items to return (default 50, -1 for all)
|
||||||
|
offset: Item ID to start after (for pagination)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ListItemsResponse with items, count, and pagination info
|
||||||
|
"""
|
||||||
|
client = await get_client(ctx)
|
||||||
|
|
||||||
|
# Determine item type filter
|
||||||
|
type_ = NewsItemType.ALL
|
||||||
|
id_ = 0
|
||||||
|
if starred_only:
|
||||||
|
type_ = NewsItemType.STARRED
|
||||||
|
elif feed_id is not None:
|
||||||
|
type_ = NewsItemType.FEED
|
||||||
|
id_ = feed_id
|
||||||
|
elif folder_id is not None:
|
||||||
|
type_ = NewsItemType.FOLDER
|
||||||
|
id_ = folder_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
items_data = await client.news.get_items(
|
||||||
|
batch_size=limit,
|
||||||
|
offset=offset,
|
||||||
|
type_=type_,
|
||||||
|
id_=id_,
|
||||||
|
get_read=not unread_only,
|
||||||
|
)
|
||||||
|
items = [NewsItemSummary(**i) for i in items_data]
|
||||||
|
|
||||||
|
# Determine pagination info
|
||||||
|
oldest_id = min((i.id for i in items), default=None) if items else None
|
||||||
|
has_more = len(items) == limit and limit > 0
|
||||||
|
|
||||||
|
return ListItemsResponse(
|
||||||
|
results=items,
|
||||||
|
total_count=len(items),
|
||||||
|
has_more=has_more,
|
||||||
|
oldest_id=oldest_id,
|
||||||
|
)
|
||||||
|
except RequestError as e:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(code=-1, message=f"Network error listing items: {str(e)}")
|
||||||
|
)
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Failed to list items: {e.response.status_code}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@require_scopes("news:read")
|
||||||
|
@instrument_tool
|
||||||
|
async def nc_news_get_item(item_id: int, ctx: Context) -> GetItemResponse:
|
||||||
|
"""Get a specific News item by ID with full content (requires news:read scope).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_id: Item ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
GetItemResponse with full item details including HTML body
|
||||||
|
"""
|
||||||
|
client = await get_client(ctx)
|
||||||
|
try:
|
||||||
|
item_data = await client.news.get_item(item_id)
|
||||||
|
item = NewsItem(**item_data)
|
||||||
|
return GetItemResponse(item=item)
|
||||||
|
except ValueError as e:
|
||||||
|
raise McpError(ErrorData(code=-1, message=str(e)))
|
||||||
|
except RequestError as e:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1, message=f"Network error getting item {item_id}: {str(e)}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 404:
|
||||||
|
raise McpError(ErrorData(code=-1, message=f"Item {item_id} not found"))
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Failed to get item {item_id}: {e.response.status_code}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@require_scopes("news:read")
|
||||||
|
@instrument_tool
|
||||||
|
async def nc_news_get_starred_items(
|
||||||
|
ctx: Context, limit: int = 50, offset: int = 0
|
||||||
|
) -> ListItemsResponse:
|
||||||
|
"""Get starred (favorited) News items (requires news:read scope).
|
||||||
|
|
||||||
|
Convenience method for retrieving user's starred articles.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Maximum number of items to return (default 50, -1 for all)
|
||||||
|
offset: Item ID to start after (for pagination)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ListItemsResponse with starred items
|
||||||
|
"""
|
||||||
|
client = await get_client(ctx)
|
||||||
|
try:
|
||||||
|
items_data = await client.news.get_items(
|
||||||
|
batch_size=limit,
|
||||||
|
offset=offset,
|
||||||
|
type_=NewsItemType.STARRED,
|
||||||
|
get_read=True, # Include read starred items
|
||||||
|
)
|
||||||
|
items = [NewsItemSummary(**i) for i in items_data]
|
||||||
|
|
||||||
|
oldest_id = min((i.id for i in items), default=None) if items else None
|
||||||
|
has_more = len(items) == limit and limit > 0
|
||||||
|
|
||||||
|
return ListItemsResponse(
|
||||||
|
results=items,
|
||||||
|
total_count=len(items),
|
||||||
|
has_more=has_more,
|
||||||
|
oldest_id=oldest_id,
|
||||||
|
)
|
||||||
|
except RequestError as e:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1, message=f"Network error getting starred items: {str(e)}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Failed to get starred items: {e.response.status_code}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@require_scopes("news:read")
|
||||||
|
@instrument_tool
|
||||||
|
async def nc_news_get_unread_items(
|
||||||
|
ctx: Context, limit: int = 50, offset: int = 0
|
||||||
|
) -> ListItemsResponse:
|
||||||
|
"""Get unread News items (requires news:read scope).
|
||||||
|
|
||||||
|
Convenience method for retrieving unread articles across all feeds.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Maximum number of items to return (default 50, -1 for all)
|
||||||
|
offset: Item ID to start after (for pagination)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ListItemsResponse with unread items
|
||||||
|
"""
|
||||||
|
client = await get_client(ctx)
|
||||||
|
try:
|
||||||
|
items_data = await client.news.get_items(
|
||||||
|
batch_size=limit,
|
||||||
|
offset=offset,
|
||||||
|
type_=NewsItemType.ALL,
|
||||||
|
get_read=False, # Only unread items
|
||||||
|
)
|
||||||
|
items = [NewsItemSummary(**i) for i in items_data]
|
||||||
|
|
||||||
|
oldest_id = min((i.id for i in items), default=None) if items else None
|
||||||
|
has_more = len(items) == limit and limit > 0
|
||||||
|
|
||||||
|
return ListItemsResponse(
|
||||||
|
results=items,
|
||||||
|
total_count=len(items),
|
||||||
|
has_more=has_more,
|
||||||
|
oldest_id=oldest_id,
|
||||||
|
)
|
||||||
|
except RequestError as e:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1, message=f"Network error getting unread items: {str(e)}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Failed to get unread items: {e.response.status_code}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@require_scopes("news:read")
|
||||||
|
@instrument_tool
|
||||||
|
async def nc_news_get_feed_health(feed_id: int, ctx: Context) -> FeedHealthResponse:
|
||||||
|
"""Get health status for a specific feed (requires news:read scope).
|
||||||
|
|
||||||
|
Returns error count and last error message if the feed has update issues.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
feed_id: Feed ID to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
FeedHealthResponse with error status
|
||||||
|
"""
|
||||||
|
client = await get_client(ctx)
|
||||||
|
try:
|
||||||
|
data = await client.news.get_feeds()
|
||||||
|
for feed_data in data.get("feeds", []):
|
||||||
|
if feed_data.get("id") == feed_id:
|
||||||
|
feed = NewsFeed(**feed_data)
|
||||||
|
return FeedHealthResponse(
|
||||||
|
feed_id=feed.id,
|
||||||
|
title=feed.title,
|
||||||
|
url=feed.url,
|
||||||
|
has_errors=feed.has_errors,
|
||||||
|
error_count=feed.update_error_count,
|
||||||
|
last_error=feed.last_update_error,
|
||||||
|
)
|
||||||
|
raise McpError(ErrorData(code=-1, message=f"Feed {feed_id} not found"))
|
||||||
|
except RequestError as e:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Network error getting feed health: {str(e)}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Failed to get feed health: {e.response.status_code}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@require_scopes("news:read")
|
||||||
|
@instrument_tool
|
||||||
|
async def nc_news_get_status(ctx: Context) -> GetStatusResponse:
|
||||||
|
"""Get News app status and version (requires news:read scope).
|
||||||
|
|
||||||
|
Returns version information and any configuration warnings.
|
||||||
|
"""
|
||||||
|
client = await get_client(ctx)
|
||||||
|
try:
|
||||||
|
status_data = await client.news.get_status()
|
||||||
|
return GetStatusResponse(
|
||||||
|
version=status_data.get("version", "unknown"),
|
||||||
|
warnings=status_data.get("warnings", {}),
|
||||||
|
)
|
||||||
|
except RequestError as e:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(code=-1, message=f"Network error getting status: {str(e)}")
|
||||||
|
)
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Failed to get status: {e.response.status_code}",
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""HTML to Markdown conversion utilities for vector sync."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from markdownify import markdownify as md
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def html_to_markdown(html_content: str | None) -> str:
|
||||||
|
"""Convert HTML content to Markdown, preserving semantic structure.
|
||||||
|
|
||||||
|
This function converts HTML (typically from RSS/Atom feed items) to Markdown
|
||||||
|
for better text embedding. Markdown preserves:
|
||||||
|
- Heading hierarchy (important for document structure)
|
||||||
|
- Lists (bullet and numbered)
|
||||||
|
- Links (as [text](url))
|
||||||
|
- Bold/italic emphasis
|
||||||
|
- Paragraphs and line breaks
|
||||||
|
|
||||||
|
Args:
|
||||||
|
html_content: HTML string to convert (may be None or empty)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Markdown string, or empty string if input is None/empty
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> html_to_markdown("<h1>Title</h1><p>Content with <b>bold</b>.</p>")
|
||||||
|
'# Title\\n\\nContent with **bold**.\\n\\n'
|
||||||
|
"""
|
||||||
|
if not html_content:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
markdown = md(
|
||||||
|
html_content,
|
||||||
|
heading_style="ATX", # Use # style headings
|
||||||
|
strip=["script", "style", "iframe", "noscript"], # Remove unsafe elements
|
||||||
|
bullets="-", # Use - for unordered lists
|
||||||
|
code_language="", # Don't add language hints to code blocks
|
||||||
|
)
|
||||||
|
return markdown.strip()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to convert HTML to Markdown: {e}")
|
||||||
|
# Fallback: strip all HTML tags as a last resort
|
||||||
|
import re
|
||||||
|
|
||||||
|
text = re.sub(r"<[^>]+>", " ", html_content)
|
||||||
|
return " ".join(text.split()) # Normalize whitespace
|
||||||
@@ -272,6 +272,45 @@ async def _index_document(
|
|||||||
file_path = None # Notes don't have file paths
|
file_path = None # Notes don't have file paths
|
||||||
content_bytes = None # Notes don't have binary content
|
content_bytes = None # Notes don't have binary content
|
||||||
content_type = None
|
content_type = None
|
||||||
|
elif doc_task.doc_type == "news_item":
|
||||||
|
from nextcloud_mcp_server.vector.html_processor import html_to_markdown
|
||||||
|
|
||||||
|
item = await nc_client.news.get_item(int(doc_task.doc_id))
|
||||||
|
# Convert HTML body to Markdown for better embedding
|
||||||
|
body_markdown = html_to_markdown(item.get("body", ""))
|
||||||
|
# Build content: title + URL + body
|
||||||
|
item_title = item.get("title", "")
|
||||||
|
item_url = item.get("url", "")
|
||||||
|
feed_title = item.get("feedTitle", "")
|
||||||
|
|
||||||
|
# Structure content for embedding
|
||||||
|
content_parts = [item_title]
|
||||||
|
if feed_title:
|
||||||
|
content_parts.append(f"Source: {feed_title}")
|
||||||
|
if item_url:
|
||||||
|
content_parts.append(f"URL: {item_url}")
|
||||||
|
content_parts.append("") # Blank line
|
||||||
|
content_parts.append(body_markdown)
|
||||||
|
content = "\n".join(content_parts)
|
||||||
|
|
||||||
|
title = item_title
|
||||||
|
etag = item.get("guidHash", "")
|
||||||
|
# Store news-specific metadata for later use in payload
|
||||||
|
file_metadata = {
|
||||||
|
"feed_id": item.get("feedId"),
|
||||||
|
"feed_title": feed_title,
|
||||||
|
"author": item.get("author"),
|
||||||
|
"pub_date": item.get("pubDate"),
|
||||||
|
"starred": item.get("starred", False),
|
||||||
|
"unread": item.get("unread", True),
|
||||||
|
"url": item_url,
|
||||||
|
"guid_hash": item.get("guidHash"),
|
||||||
|
"enclosure_link": item.get("enclosureLink"),
|
||||||
|
"enclosure_mime": item.get("enclosureMime"),
|
||||||
|
}
|
||||||
|
file_path = None
|
||||||
|
content_bytes = None
|
||||||
|
content_type = None
|
||||||
elif doc_task.doc_type == "file":
|
elif doc_task.doc_type == "file":
|
||||||
# For files, doc_id is now the numeric file ID, file_path comes from DocumentTask
|
# For files, doc_id is now the numeric file ID, file_path comes from DocumentTask
|
||||||
if not doc_task.file_path:
|
if not doc_task.file_path:
|
||||||
@@ -358,15 +397,16 @@ async def _index_document(
|
|||||||
chunks = await chunker.chunk_text(content)
|
chunks = await chunker.chunk_text(content)
|
||||||
|
|
||||||
# Assign page numbers to chunks if page boundaries are available (PDFs)
|
# Assign page numbers to chunks if page boundaries are available (PDFs)
|
||||||
if doc_task.doc_type == "file" and "page_boundaries" in file_metadata:
|
page_boundaries = file_metadata.get("page_boundaries")
|
||||||
|
if doc_task.doc_type == "file" and page_boundaries is not None:
|
||||||
with trace_operation(
|
with trace_operation(
|
||||||
"vector_sync.assign_page_numbers",
|
"vector_sync.assign_page_numbers",
|
||||||
attributes={
|
attributes={
|
||||||
"vector_sync.chunk_count": len(chunks),
|
"vector_sync.chunk_count": len(chunks),
|
||||||
"vector_sync.page_count": len(file_metadata["page_boundaries"]),
|
"vector_sync.page_count": len(page_boundaries),
|
||||||
},
|
},
|
||||||
):
|
):
|
||||||
assign_page_numbers(chunks, file_metadata["page_boundaries"])
|
assign_page_numbers(chunks, page_boundaries)
|
||||||
|
|
||||||
# Diagnostic: Verify page number assignment
|
# Diagnostic: Verify page number assignment
|
||||||
assigned_count = sum(1 for c in chunks if c.page_number is not None)
|
assigned_count = sum(1 for c in chunks if c.page_number is not None)
|
||||||
@@ -389,8 +429,8 @@ async def _index_document(
|
|||||||
f"Text length: {len(content)}, "
|
f"Text length: {len(content)}, "
|
||||||
f"Chunks: {len(chunks)}, "
|
f"Chunks: {len(chunks)}, "
|
||||||
f"Chunk offset range: [{chunks[0].start_offset}:{chunks[-1].end_offset}], "
|
f"Chunk offset range: [{chunks[0].start_offset}:{chunks[-1].end_offset}], "
|
||||||
f"Page boundaries: {len(file_metadata['page_boundaries'])} pages, "
|
f"Page boundaries: {len(page_boundaries)} pages, "
|
||||||
f"First boundary: {file_metadata['page_boundaries'][0] if file_metadata['page_boundaries'] else 'None'}"
|
f"First boundary: {page_boundaries[0] if page_boundaries else 'None'}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Extract chunk texts for embedding
|
# Extract chunk texts for embedding
|
||||||
@@ -566,6 +606,23 @@ async def _index_document(
|
|||||||
if doc_task.doc_type == "file"
|
if doc_task.doc_type == "file"
|
||||||
else {}
|
else {}
|
||||||
),
|
),
|
||||||
|
# News item-specific metadata
|
||||||
|
**(
|
||||||
|
{
|
||||||
|
"feed_id": file_metadata.get("feed_id"),
|
||||||
|
"feed_title": file_metadata.get("feed_title"),
|
||||||
|
"author": file_metadata.get("author"),
|
||||||
|
"pub_date": file_metadata.get("pub_date"),
|
||||||
|
"starred": file_metadata.get("starred"),
|
||||||
|
"unread": file_metadata.get("unread"),
|
||||||
|
"url": file_metadata.get("url"),
|
||||||
|
"guid_hash": file_metadata.get("guid_hash"),
|
||||||
|
"enclosure_link": file_metadata.get("enclosure_link"),
|
||||||
|
"enclosure_mime": file_metadata.get("enclosure_mime"),
|
||||||
|
}
|
||||||
|
if doc_task.doc_type == "news_item"
|
||||||
|
else {}
|
||||||
|
),
|
||||||
# Highlighted page image (PDF only)
|
# Highlighted page image (PDF only)
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -206,7 +206,11 @@ async def scan_user_documents(
|
|||||||
limit=10000,
|
limit=10000,
|
||||||
)
|
)
|
||||||
|
|
||||||
indexed_doc_ids = {point.payload["doc_id"] for point in scroll_result[0]}
|
indexed_doc_ids = {
|
||||||
|
point.payload["doc_id"]
|
||||||
|
for point in (scroll_result[0] or [])
|
||||||
|
if point.payload is not None
|
||||||
|
}
|
||||||
|
|
||||||
logger.debug(f"Found {len(indexed_doc_ids)} indexed documents in Qdrant")
|
logger.debug(f"Found {len(indexed_doc_ids)} indexed documents in Qdrant")
|
||||||
|
|
||||||
@@ -376,7 +380,9 @@ async def scan_user_documents(
|
|||||||
)
|
)
|
||||||
|
|
||||||
indexed_file_ids = {
|
indexed_file_ids = {
|
||||||
point.payload["doc_id"] for point in file_scroll_result[0]
|
point.payload["doc_id"]
|
||||||
|
for point in (file_scroll_result[0] or [])
|
||||||
|
if point.payload is not None
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(f"Found {len(indexed_file_ids)} indexed files in Qdrant")
|
logger.debug(f"Found {len(indexed_file_ids)} indexed files in Qdrant")
|
||||||
@@ -544,9 +550,206 @@ async def scan_user_documents(
|
|||||||
|
|
||||||
queued += file_queued
|
queued += file_queued
|
||||||
|
|
||||||
|
# Scan News items (starred + unread)
|
||||||
|
news_queued = 0
|
||||||
|
try:
|
||||||
|
news_queued = await scan_news_items(
|
||||||
|
user_id=user_id,
|
||||||
|
send_stream=send_stream,
|
||||||
|
nc_client=nc_client,
|
||||||
|
initial_sync=initial_sync,
|
||||||
|
scan_id=scan_id,
|
||||||
|
)
|
||||||
|
queued += news_queued
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to scan news items for {user_id}: {e}")
|
||||||
|
|
||||||
if queued > 0:
|
if queued > 0:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Sent {queued} documents ({file_queued} files) for incremental sync: {user_id}"
|
f"Sent {queued} documents ({file_queued} files, {news_queued} news items) for incremental sync: {user_id}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.debug(f"No changes detected for {user_id}")
|
logger.debug(f"No changes detected for {user_id}")
|
||||||
|
|
||||||
|
|
||||||
|
async def scan_news_items(
|
||||||
|
user_id: str,
|
||||||
|
send_stream: MemoryObjectSendStream[DocumentTask],
|
||||||
|
nc_client: NextcloudClient,
|
||||||
|
initial_sync: bool,
|
||||||
|
scan_id: int,
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Scan user's News items and queue changed items for indexing.
|
||||||
|
|
||||||
|
Indexes all items from the user's feeds. The News app's auto-purge
|
||||||
|
feature (default: 200 items per feed) naturally limits the total
|
||||||
|
number of items, making explicit filtering unnecessary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User to scan
|
||||||
|
send_stream: Stream to send changed documents to processors
|
||||||
|
nc_client: Authenticated Nextcloud client
|
||||||
|
initial_sync: If True, send all documents (first-time sync)
|
||||||
|
scan_id: Scan identifier for logging
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of items queued for processing
|
||||||
|
"""
|
||||||
|
from nextcloud_mcp_server.client.news import NewsItemType
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
queued = 0
|
||||||
|
|
||||||
|
# Get indexed news item IDs from Qdrant (for deletion tracking)
|
||||||
|
indexed_item_ids: set[str] = set()
|
||||||
|
if not initial_sync:
|
||||||
|
qdrant_client = await get_qdrant_client()
|
||||||
|
scroll_result = await qdrant_client.scroll(
|
||||||
|
collection_name=settings.get_collection_name(),
|
||||||
|
scroll_filter=Filter(
|
||||||
|
must=[
|
||||||
|
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
|
||||||
|
FieldCondition(key="doc_type", match=MatchValue(value="news_item")),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
with_payload=["doc_id"],
|
||||||
|
with_vectors=False,
|
||||||
|
limit=10000,
|
||||||
|
)
|
||||||
|
indexed_item_ids = {
|
||||||
|
point.payload["doc_id"]
|
||||||
|
for point in (scroll_result[0] or [])
|
||||||
|
if point.payload is not None
|
||||||
|
}
|
||||||
|
logger.debug(f"Found {len(indexed_item_ids)} indexed news items in Qdrant")
|
||||||
|
|
||||||
|
# Fetch all items (News app caps at ~200 per feed via auto-purge)
|
||||||
|
all_items = await nc_client.news.get_items(
|
||||||
|
batch_size=-1,
|
||||||
|
type_=NewsItemType.ALL,
|
||||||
|
get_read=True,
|
||||||
|
)
|
||||||
|
logger.debug(f"[SCAN-{scan_id}] Found {len(all_items)} news items")
|
||||||
|
|
||||||
|
item_count = len(all_items)
|
||||||
|
nextcloud_item_ids: set[str] = set()
|
||||||
|
|
||||||
|
for item in all_items:
|
||||||
|
doc_id = str(item["id"])
|
||||||
|
nextcloud_item_ids.add(doc_id)
|
||||||
|
|
||||||
|
# Use lastModified timestamp (microseconds in News API)
|
||||||
|
modified_at = item.get("lastModified", 0)
|
||||||
|
# Convert to seconds if needed (News API uses microseconds)
|
||||||
|
if modified_at > 10000000000: # > year 2286 in seconds
|
||||||
|
modified_at = modified_at // 1000000
|
||||||
|
|
||||||
|
if initial_sync:
|
||||||
|
# Send everything on first sync - write placeholder first
|
||||||
|
await write_placeholder_point(
|
||||||
|
doc_id=doc_id,
|
||||||
|
doc_type="news_item",
|
||||||
|
user_id=user_id,
|
||||||
|
modified_at=modified_at,
|
||||||
|
)
|
||||||
|
await send_stream.send(
|
||||||
|
DocumentTask(
|
||||||
|
user_id=user_id,
|
||||||
|
doc_id=doc_id,
|
||||||
|
doc_type="news_item",
|
||||||
|
operation="index",
|
||||||
|
modified_at=modified_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
queued += 1
|
||||||
|
else:
|
||||||
|
# Incremental sync: check if item exists and compare modified_at
|
||||||
|
doc_key = (user_id, doc_id)
|
||||||
|
if doc_key in _potentially_deleted:
|
||||||
|
logger.debug(
|
||||||
|
f"News item {doc_id} reappeared, removing from deletion grace period"
|
||||||
|
)
|
||||||
|
del _potentially_deleted[doc_key]
|
||||||
|
|
||||||
|
# Query Qdrant for existing entry
|
||||||
|
existing_metadata = await query_document_metadata(
|
||||||
|
doc_id=doc_id, doc_type="news_item", user_id=user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
needs_indexing = False
|
||||||
|
if existing_metadata is None:
|
||||||
|
needs_indexing = True
|
||||||
|
elif existing_metadata.get("modified_at", 0) < modified_at:
|
||||||
|
needs_indexing = True
|
||||||
|
elif existing_metadata.get("is_placeholder", False):
|
||||||
|
queued_at = existing_metadata.get("queued_at", 0)
|
||||||
|
placeholder_age = time.time() - queued_at
|
||||||
|
stale_threshold = settings.vector_sync_scan_interval * 5
|
||||||
|
if placeholder_age > stale_threshold:
|
||||||
|
logger.debug(
|
||||||
|
f"Found stale placeholder for news item {doc_id} "
|
||||||
|
f"(age={placeholder_age:.1f}s), requeuing"
|
||||||
|
)
|
||||||
|
needs_indexing = True
|
||||||
|
|
||||||
|
if needs_indexing:
|
||||||
|
await write_placeholder_point(
|
||||||
|
doc_id=doc_id,
|
||||||
|
doc_type="news_item",
|
||||||
|
user_id=user_id,
|
||||||
|
modified_at=modified_at,
|
||||||
|
)
|
||||||
|
await send_stream.send(
|
||||||
|
DocumentTask(
|
||||||
|
user_id=user_id,
|
||||||
|
doc_id=doc_id,
|
||||||
|
doc_type="news_item",
|
||||||
|
operation="index",
|
||||||
|
modified_at=modified_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
queued += 1
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[SCAN-{scan_id}] Found {item_count} news items (starred+unread) for {user_id}"
|
||||||
|
)
|
||||||
|
record_vector_sync_scan(item_count)
|
||||||
|
|
||||||
|
# Check for deleted items (not initial sync)
|
||||||
|
# Items become "deleted" when they are no longer starred AND become read
|
||||||
|
if not initial_sync:
|
||||||
|
grace_period = settings.vector_sync_scan_interval * 1.5
|
||||||
|
current_time = time.time()
|
||||||
|
|
||||||
|
for doc_id in indexed_item_ids:
|
||||||
|
if doc_id not in nextcloud_item_ids:
|
||||||
|
doc_key = (user_id, doc_id)
|
||||||
|
|
||||||
|
if doc_key in _potentially_deleted:
|
||||||
|
first_missing_time = _potentially_deleted[doc_key]
|
||||||
|
time_missing = current_time - first_missing_time
|
||||||
|
|
||||||
|
if time_missing >= grace_period:
|
||||||
|
logger.info(
|
||||||
|
f"News item {doc_id} missing for {time_missing:.1f}s "
|
||||||
|
f"(>{grace_period:.1f}s grace period), sending deletion"
|
||||||
|
)
|
||||||
|
await send_stream.send(
|
||||||
|
DocumentTask(
|
||||||
|
user_id=user_id,
|
||||||
|
doc_id=doc_id,
|
||||||
|
doc_type="news_item",
|
||||||
|
operation="delete",
|
||||||
|
modified_at=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
queued += 1
|
||||||
|
del _potentially_deleted[doc_key]
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
f"News item {doc_id} missing for first time, starting grace period"
|
||||||
|
)
|
||||||
|
_potentially_deleted[doc_key] = current_time
|
||||||
|
|
||||||
|
return queued
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "nextcloud-mcp-server"
|
name = "nextcloud-mcp-server"
|
||||||
version = "0.48.6"
|
version = "0.49.1"
|
||||||
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
||||||
@@ -10,7 +10,7 @@ license = {text = "AGPL-3.0-only"}
|
|||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
keywords = ["nextcloud", "mcp", "model-context-protocol", "llm", "ai", "claude", "webdav", "caldav", "carddav"]
|
keywords = ["nextcloud", "mcp", "model-context-protocol", "llm", "ai", "claude", "webdav", "caldav", "carddav"]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"mcp[cli] (>=1.23,<1.24)",
|
"mcp[cli] (>=1.22,<1.23)",
|
||||||
"httpx (>=0.28.1,<0.29.0)",
|
"httpx (>=0.28.1,<0.29.0)",
|
||||||
"pillow (>=10.3.0,<12.0.0)", # Compatible with fastembed
|
"pillow (>=10.3.0,<12.0.0)", # Compatible with fastembed
|
||||||
"icalendar (>=6.0.0,<7.0.0)",
|
"icalendar (>=6.0.0,<7.0.0)",
|
||||||
@@ -36,6 +36,7 @@ dependencies = [
|
|||||||
"python-json-logger>=3.2.0", # Structured JSON logging
|
"python-json-logger>=3.2.0", # Structured JSON logging
|
||||||
"jinja2>=3.1.6",
|
"jinja2>=3.1.6",
|
||||||
"langchain-text-splitters>=1.0.0",
|
"langchain-text-splitters>=1.0.0",
|
||||||
|
"markdownify>=0.14.1", # HTML to Markdown conversion for News items
|
||||||
"pymupdf>=1.26.6",
|
"pymupdf>=1.26.6",
|
||||||
"pymupdf4llm>=0.2.2",
|
"pymupdf4llm>=0.2.2",
|
||||||
"pymupdf-layout>=1.26.6",
|
"pymupdf-layout>=1.26.6",
|
||||||
|
|||||||
@@ -480,3 +480,222 @@ def create_mock_table_row_ocs_response(
|
|||||||
ocs_response = {"ocs": {"meta": {"status": "ok"}, "data": row_data}}
|
ocs_response = {"ocs": {"meta": {"status": "ok"}, "data": row_data}}
|
||||||
|
|
||||||
return create_mock_response(status_code=200, json_data=ocs_response)
|
return create_mock_response(status_code=200, json_data=ocs_response)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# News Mock Response Helpers
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def create_mock_news_folders_response(
|
||||||
|
folders: list[dict] | None = None,
|
||||||
|
) -> httpx.Response:
|
||||||
|
"""Create a mock response for News folders list.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folders: List of folder dictionaries. If None, returns empty list.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Mock httpx.Response with folders data
|
||||||
|
"""
|
||||||
|
if folders is None:
|
||||||
|
folders = []
|
||||||
|
|
||||||
|
return create_mock_response(status_code=200, json_data={"folders": folders})
|
||||||
|
|
||||||
|
|
||||||
|
def create_mock_news_folder_response(
|
||||||
|
folder_id: int = 1,
|
||||||
|
name: str = "Test Folder",
|
||||||
|
**kwargs,
|
||||||
|
) -> httpx.Response:
|
||||||
|
"""Create a mock response for a News folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_id: Folder ID
|
||||||
|
name: Folder name
|
||||||
|
**kwargs: Additional folder fields
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Mock httpx.Response with folder data
|
||||||
|
"""
|
||||||
|
folder_data = {
|
||||||
|
"id": folder_id,
|
||||||
|
"name": name,
|
||||||
|
**kwargs,
|
||||||
|
}
|
||||||
|
|
||||||
|
return create_mock_response(status_code=200, json_data={"folders": [folder_data]})
|
||||||
|
|
||||||
|
|
||||||
|
def create_mock_news_feeds_response(
|
||||||
|
feeds: list[dict] | None = None,
|
||||||
|
starred_count: int = 0,
|
||||||
|
newest_item_id: int | None = None,
|
||||||
|
) -> httpx.Response:
|
||||||
|
"""Create a mock response for News feeds list.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
feeds: List of feed dictionaries. If None, returns empty list.
|
||||||
|
starred_count: Number of starred items
|
||||||
|
newest_item_id: ID of newest item
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Mock httpx.Response with feeds data
|
||||||
|
"""
|
||||||
|
if feeds is None:
|
||||||
|
feeds = []
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"feeds": feeds,
|
||||||
|
"starredCount": starred_count,
|
||||||
|
}
|
||||||
|
if newest_item_id is not None:
|
||||||
|
data["newestItemId"] = newest_item_id
|
||||||
|
|
||||||
|
return create_mock_response(status_code=200, json_data=data)
|
||||||
|
|
||||||
|
|
||||||
|
def create_mock_news_feed_response(
|
||||||
|
feed_id: int = 1,
|
||||||
|
url: str = "https://example.com/feed",
|
||||||
|
title: str = "Test Feed",
|
||||||
|
favicon_link: str | None = None,
|
||||||
|
folder_id: int | None = None,
|
||||||
|
unread_count: int = 0,
|
||||||
|
**kwargs,
|
||||||
|
) -> httpx.Response:
|
||||||
|
"""Create a mock response for a News feed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
feed_id: Feed ID
|
||||||
|
url: Feed URL
|
||||||
|
title: Feed title
|
||||||
|
favicon_link: Favicon URL
|
||||||
|
folder_id: Parent folder ID
|
||||||
|
unread_count: Number of unread items
|
||||||
|
**kwargs: Additional feed fields
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Mock httpx.Response with feed data
|
||||||
|
"""
|
||||||
|
feed_data = {
|
||||||
|
"id": feed_id,
|
||||||
|
"url": url,
|
||||||
|
"title": title,
|
||||||
|
"faviconLink": favicon_link,
|
||||||
|
"folderId": folder_id,
|
||||||
|
"unreadCount": unread_count,
|
||||||
|
"link": kwargs.get("link", "https://example.com"),
|
||||||
|
"added": kwargs.get("added", 1700000000),
|
||||||
|
"updateErrorCount": kwargs.get("updateErrorCount", 0),
|
||||||
|
"lastUpdateError": kwargs.get("lastUpdateError"),
|
||||||
|
**{
|
||||||
|
k: v
|
||||||
|
for k, v in kwargs.items()
|
||||||
|
if k not in ["link", "added", "updateErrorCount", "lastUpdateError"]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return create_mock_response(status_code=200, json_data={"feeds": [feed_data]})
|
||||||
|
|
||||||
|
|
||||||
|
def create_mock_news_items_response(
|
||||||
|
items: list[dict] | None = None,
|
||||||
|
) -> httpx.Response:
|
||||||
|
"""Create a mock response for News items list.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
items: List of item dictionaries. If None, returns empty list.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Mock httpx.Response with items data
|
||||||
|
"""
|
||||||
|
if items is None:
|
||||||
|
items = []
|
||||||
|
|
||||||
|
return create_mock_response(status_code=200, json_data={"items": items})
|
||||||
|
|
||||||
|
|
||||||
|
def create_mock_news_item(
|
||||||
|
item_id: int = 1,
|
||||||
|
feed_id: int = 1,
|
||||||
|
title: str = "Test Article",
|
||||||
|
body: str = "<p>Test content</p>",
|
||||||
|
url: str = "https://example.com/article",
|
||||||
|
author: str | None = "Test Author",
|
||||||
|
pub_date: int = 1700000000,
|
||||||
|
unread: bool = True,
|
||||||
|
starred: bool = False,
|
||||||
|
**kwargs,
|
||||||
|
) -> dict:
|
||||||
|
"""Create a mock News item dictionary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_id: Item ID
|
||||||
|
feed_id: Parent feed ID
|
||||||
|
title: Article title
|
||||||
|
body: Article body (HTML)
|
||||||
|
url: Article URL
|
||||||
|
author: Article author
|
||||||
|
pub_date: Publication timestamp (Unix)
|
||||||
|
unread: Whether item is unread
|
||||||
|
starred: Whether item is starred
|
||||||
|
**kwargs: Additional item fields
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Item dictionary
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"id": item_id,
|
||||||
|
"feedId": feed_id,
|
||||||
|
"title": title,
|
||||||
|
"body": body,
|
||||||
|
"url": url,
|
||||||
|
"author": author,
|
||||||
|
"pubDate": pub_date,
|
||||||
|
"unread": unread,
|
||||||
|
"starred": starred,
|
||||||
|
"guid": kwargs.get("guid", f"guid-{item_id}"),
|
||||||
|
"guidHash": kwargs.get("guidHash", f"hash-{item_id}"),
|
||||||
|
"lastModified": kwargs.get("lastModified", pub_date * 1000000),
|
||||||
|
"enclosureLink": kwargs.get("enclosureLink"),
|
||||||
|
"enclosureMime": kwargs.get("enclosureMime"),
|
||||||
|
"fingerprint": kwargs.get("fingerprint", f"fp-{item_id}"),
|
||||||
|
"contentHash": kwargs.get("contentHash", f"ch-{item_id}"),
|
||||||
|
**{
|
||||||
|
k: v
|
||||||
|
for k, v in kwargs.items()
|
||||||
|
if k
|
||||||
|
not in [
|
||||||
|
"guid",
|
||||||
|
"guidHash",
|
||||||
|
"lastModified",
|
||||||
|
"enclosureLink",
|
||||||
|
"enclosureMime",
|
||||||
|
"fingerprint",
|
||||||
|
"contentHash",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_mock_news_status_response(
|
||||||
|
version: str = "25.0.0",
|
||||||
|
warnings: dict | None = None,
|
||||||
|
) -> httpx.Response:
|
||||||
|
"""Create a mock response for News status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
version: News app version
|
||||||
|
warnings: Warning messages
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Mock httpx.Response with status data
|
||||||
|
"""
|
||||||
|
data = {
|
||||||
|
"version": version,
|
||||||
|
"warnings": warnings or {},
|
||||||
|
}
|
||||||
|
|
||||||
|
return create_mock_response(status_code=200, json_data=data)
|
||||||
|
|||||||
@@ -0,0 +1,561 @@
|
|||||||
|
"""Unit tests for NewsClient API methods."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.client.news import NewsClient, NewsItemType
|
||||||
|
from tests.client.conftest import (
|
||||||
|
create_mock_error_response,
|
||||||
|
create_mock_news_feed_response,
|
||||||
|
create_mock_news_feeds_response,
|
||||||
|
create_mock_news_folder_response,
|
||||||
|
create_mock_news_folders_response,
|
||||||
|
create_mock_news_item,
|
||||||
|
create_mock_news_items_response,
|
||||||
|
create_mock_news_status_response,
|
||||||
|
create_mock_response,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Mark all tests in this module as unit tests
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Folder Tests
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
async def test_news_api_get_folders(mocker):
|
||||||
|
"""Test that get_folders correctly parses the API response."""
|
||||||
|
mock_response = create_mock_news_folders_response(
|
||||||
|
folders=[
|
||||||
|
{"id": 1, "name": "Tech"},
|
||||||
|
{"id": 2, "name": "News"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||||
|
mock_make_request = mocker.patch.object(
|
||||||
|
NewsClient, "_make_request", return_value=mock_response
|
||||||
|
)
|
||||||
|
|
||||||
|
client = NewsClient(mock_client, "testuser")
|
||||||
|
folders = await client.get_folders()
|
||||||
|
|
||||||
|
assert len(folders) == 2
|
||||||
|
assert folders[0]["id"] == 1
|
||||||
|
assert folders[0]["name"] == "Tech"
|
||||||
|
assert folders[1]["name"] == "News"
|
||||||
|
|
||||||
|
mock_make_request.assert_called_once_with("GET", "/apps/news/api/v1-3/folders")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_news_api_create_folder(mocker):
|
||||||
|
"""Test that create_folder correctly creates a folder."""
|
||||||
|
mock_response = create_mock_news_folder_response(folder_id=3, name="New Folder")
|
||||||
|
|
||||||
|
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||||
|
mock_make_request = mocker.patch.object(
|
||||||
|
NewsClient, "_make_request", return_value=mock_response
|
||||||
|
)
|
||||||
|
|
||||||
|
client = NewsClient(mock_client, "testuser")
|
||||||
|
folder = await client.create_folder(name="New Folder")
|
||||||
|
|
||||||
|
assert folder["id"] == 3
|
||||||
|
assert folder["name"] == "New Folder"
|
||||||
|
|
||||||
|
mock_make_request.assert_called_once_with(
|
||||||
|
"POST", "/apps/news/api/v1-3/folders", json={"name": "New Folder"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_news_api_rename_folder(mocker):
|
||||||
|
"""Test that rename_folder makes the correct API call."""
|
||||||
|
mock_response = create_mock_response(status_code=200, json_data={})
|
||||||
|
|
||||||
|
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||||
|
mock_make_request = mocker.patch.object(
|
||||||
|
NewsClient, "_make_request", return_value=mock_response
|
||||||
|
)
|
||||||
|
|
||||||
|
client = NewsClient(mock_client, "testuser")
|
||||||
|
await client.rename_folder(folder_id=1, name="Renamed")
|
||||||
|
|
||||||
|
mock_make_request.assert_called_once_with(
|
||||||
|
"PUT", "/apps/news/api/v1-3/folders/1", json={"name": "Renamed"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_news_api_delete_folder(mocker):
|
||||||
|
"""Test that delete_folder makes the correct API call."""
|
||||||
|
mock_response = create_mock_response(status_code=200, json_data={})
|
||||||
|
|
||||||
|
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||||
|
mock_make_request = mocker.patch.object(
|
||||||
|
NewsClient, "_make_request", return_value=mock_response
|
||||||
|
)
|
||||||
|
|
||||||
|
client = NewsClient(mock_client, "testuser")
|
||||||
|
await client.delete_folder(folder_id=1)
|
||||||
|
|
||||||
|
mock_make_request.assert_called_once_with("DELETE", "/apps/news/api/v1-3/folders/1")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Feed Tests
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
async def test_news_api_get_feeds(mocker):
|
||||||
|
"""Test that get_feeds correctly parses the API response."""
|
||||||
|
mock_response = create_mock_news_feeds_response(
|
||||||
|
feeds=[
|
||||||
|
{"id": 1, "url": "https://example.com/feed1", "title": "Feed 1"},
|
||||||
|
{"id": 2, "url": "https://example.com/feed2", "title": "Feed 2"},
|
||||||
|
],
|
||||||
|
starred_count=5,
|
||||||
|
newest_item_id=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||||
|
mock_make_request = mocker.patch.object(
|
||||||
|
NewsClient, "_make_request", return_value=mock_response
|
||||||
|
)
|
||||||
|
|
||||||
|
client = NewsClient(mock_client, "testuser")
|
||||||
|
result = await client.get_feeds()
|
||||||
|
|
||||||
|
assert len(result["feeds"]) == 2
|
||||||
|
assert result["starredCount"] == 5
|
||||||
|
assert result["newestItemId"] == 100
|
||||||
|
|
||||||
|
mock_make_request.assert_called_once_with("GET", "/apps/news/api/v1-3/feeds")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_news_api_create_feed(mocker):
|
||||||
|
"""Test that create_feed correctly creates a feed."""
|
||||||
|
mock_response = create_mock_news_feed_response(
|
||||||
|
feed_id=10, url="https://example.com/new-feed", title="New Feed"
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||||
|
mock_make_request = mocker.patch.object(
|
||||||
|
NewsClient, "_make_request", return_value=mock_response
|
||||||
|
)
|
||||||
|
|
||||||
|
client = NewsClient(mock_client, "testuser")
|
||||||
|
feed = await client.create_feed(url="https://example.com/new-feed")
|
||||||
|
|
||||||
|
assert feed["id"] == 10
|
||||||
|
assert feed["url"] == "https://example.com/new-feed"
|
||||||
|
|
||||||
|
mock_make_request.assert_called_once_with(
|
||||||
|
"POST",
|
||||||
|
"/apps/news/api/v1-3/feeds",
|
||||||
|
json={"url": "https://example.com/new-feed"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_news_api_create_feed_with_folder(mocker):
|
||||||
|
"""Test that create_feed correctly creates a feed in a folder."""
|
||||||
|
mock_response = create_mock_news_feed_response(
|
||||||
|
feed_id=10, url="https://example.com/feed", folder_id=5
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||||
|
mock_make_request = mocker.patch.object(
|
||||||
|
NewsClient, "_make_request", return_value=mock_response
|
||||||
|
)
|
||||||
|
|
||||||
|
client = NewsClient(mock_client, "testuser")
|
||||||
|
feed = await client.create_feed(url="https://example.com/feed", folder_id=5)
|
||||||
|
|
||||||
|
assert feed["folderId"] == 5
|
||||||
|
|
||||||
|
mock_make_request.assert_called_once_with(
|
||||||
|
"POST",
|
||||||
|
"/apps/news/api/v1-3/feeds",
|
||||||
|
json={"url": "https://example.com/feed", "folderId": 5},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_news_api_delete_feed(mocker):
|
||||||
|
"""Test that delete_feed makes the correct API call."""
|
||||||
|
mock_response = create_mock_response(status_code=200, json_data={})
|
||||||
|
|
||||||
|
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||||
|
mock_make_request = mocker.patch.object(
|
||||||
|
NewsClient, "_make_request", return_value=mock_response
|
||||||
|
)
|
||||||
|
|
||||||
|
client = NewsClient(mock_client, "testuser")
|
||||||
|
await client.delete_feed(feed_id=10)
|
||||||
|
|
||||||
|
mock_make_request.assert_called_once_with("DELETE", "/apps/news/api/v1-3/feeds/10")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_news_api_move_feed(mocker):
|
||||||
|
"""Test that move_feed makes the correct API call."""
|
||||||
|
mock_response = create_mock_response(status_code=200, json_data={})
|
||||||
|
|
||||||
|
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||||
|
mock_make_request = mocker.patch.object(
|
||||||
|
NewsClient, "_make_request", return_value=mock_response
|
||||||
|
)
|
||||||
|
|
||||||
|
client = NewsClient(mock_client, "testuser")
|
||||||
|
await client.move_feed(feed_id=10, folder_id=5)
|
||||||
|
|
||||||
|
mock_make_request.assert_called_once_with(
|
||||||
|
"POST", "/apps/news/api/v1-3/feeds/10/move", json={"folderId": 5}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_news_api_rename_feed(mocker):
|
||||||
|
"""Test that rename_feed makes the correct API call."""
|
||||||
|
mock_response = create_mock_response(status_code=200, json_data={})
|
||||||
|
|
||||||
|
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||||
|
mock_make_request = mocker.patch.object(
|
||||||
|
NewsClient, "_make_request", return_value=mock_response
|
||||||
|
)
|
||||||
|
|
||||||
|
client = NewsClient(mock_client, "testuser")
|
||||||
|
await client.rename_feed(feed_id=10, title="Renamed Feed")
|
||||||
|
|
||||||
|
mock_make_request.assert_called_once_with(
|
||||||
|
"POST",
|
||||||
|
"/apps/news/api/v1-3/feeds/10/rename",
|
||||||
|
json={"feedTitle": "Renamed Feed"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Item Tests
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
async def test_news_api_get_items(mocker):
|
||||||
|
"""Test that get_items correctly parses the API response."""
|
||||||
|
items = [
|
||||||
|
create_mock_news_item(item_id=1, title="Article 1"),
|
||||||
|
create_mock_news_item(item_id=2, title="Article 2"),
|
||||||
|
]
|
||||||
|
mock_response = create_mock_news_items_response(items=items)
|
||||||
|
|
||||||
|
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||||
|
mock_make_request = mocker.patch.object(
|
||||||
|
NewsClient, "_make_request", return_value=mock_response
|
||||||
|
)
|
||||||
|
|
||||||
|
client = NewsClient(mock_client, "testuser")
|
||||||
|
result = await client.get_items()
|
||||||
|
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[0]["title"] == "Article 1"
|
||||||
|
assert result[1]["title"] == "Article 2"
|
||||||
|
|
||||||
|
# Verify default parameters
|
||||||
|
call_args = mock_make_request.call_args
|
||||||
|
assert call_args[0] == ("GET", "/apps/news/api/v1-3/items")
|
||||||
|
params = call_args[1]["params"]
|
||||||
|
assert params["batchSize"] == 50
|
||||||
|
assert params["type"] == NewsItemType.ALL
|
||||||
|
|
||||||
|
|
||||||
|
async def test_news_api_get_items_starred(mocker):
|
||||||
|
"""Test that get_items with STARRED type filters correctly."""
|
||||||
|
items = [create_mock_news_item(item_id=1, starred=True)]
|
||||||
|
mock_response = create_mock_news_items_response(items=items)
|
||||||
|
|
||||||
|
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||||
|
mock_make_request = mocker.patch.object(
|
||||||
|
NewsClient, "_make_request", return_value=mock_response
|
||||||
|
)
|
||||||
|
|
||||||
|
client = NewsClient(mock_client, "testuser")
|
||||||
|
result = await client.get_items(type_=NewsItemType.STARRED)
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["starred"] is True
|
||||||
|
|
||||||
|
call_args = mock_make_request.call_args
|
||||||
|
params = call_args[1]["params"]
|
||||||
|
assert params["type"] == NewsItemType.STARRED
|
||||||
|
|
||||||
|
|
||||||
|
async def test_news_api_get_items_unread_only(mocker):
|
||||||
|
"""Test that get_items with get_read=False filters correctly."""
|
||||||
|
items = [create_mock_news_item(item_id=1, unread=True)]
|
||||||
|
mock_response = create_mock_news_items_response(items=items)
|
||||||
|
|
||||||
|
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||||
|
mock_make_request = mocker.patch.object(
|
||||||
|
NewsClient, "_make_request", return_value=mock_response
|
||||||
|
)
|
||||||
|
|
||||||
|
client = NewsClient(mock_client, "testuser")
|
||||||
|
result = await client.get_items(get_read=False)
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
call_args = mock_make_request.call_args
|
||||||
|
params = call_args[1]["params"]
|
||||||
|
assert params["getRead"] == "false"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_news_api_get_item(mocker):
|
||||||
|
"""Test that get_item fetches a single item by ID."""
|
||||||
|
item = create_mock_news_item(item_id=123, title="Single Item")
|
||||||
|
mock_response = create_mock_response(status_code=200, json_data=item)
|
||||||
|
|
||||||
|
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||||
|
mock_make_request = mocker.patch.object(
|
||||||
|
NewsClient, "_make_request", return_value=mock_response
|
||||||
|
)
|
||||||
|
|
||||||
|
client = NewsClient(mock_client, "testuser")
|
||||||
|
result = await client.get_item(item_id=123)
|
||||||
|
|
||||||
|
assert result["id"] == 123
|
||||||
|
assert result["title"] == "Single Item"
|
||||||
|
|
||||||
|
mock_make_request.assert_called_once_with("GET", "/apps/news/api/v1-3/items/123")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_news_api_get_updated_items(mocker):
|
||||||
|
"""Test that get_updated_items correctly calls the updated endpoint."""
|
||||||
|
items = [create_mock_news_item(item_id=1)]
|
||||||
|
mock_response = create_mock_news_items_response(items=items)
|
||||||
|
|
||||||
|
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||||
|
mock_make_request = mocker.patch.object(
|
||||||
|
NewsClient, "_make_request", return_value=mock_response
|
||||||
|
)
|
||||||
|
|
||||||
|
client = NewsClient(mock_client, "testuser")
|
||||||
|
result = await client.get_updated_items(last_modified=1700000000)
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
call_args = mock_make_request.call_args
|
||||||
|
assert call_args[0] == ("GET", "/apps/news/api/v1-3/items/updated")
|
||||||
|
params = call_args[1]["params"]
|
||||||
|
assert params["lastModified"] == 1700000000
|
||||||
|
|
||||||
|
|
||||||
|
async def test_news_api_mark_item_read(mocker):
|
||||||
|
"""Test that mark_item_read makes the correct API call."""
|
||||||
|
mock_response = create_mock_response(status_code=200, json_data={})
|
||||||
|
|
||||||
|
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||||
|
mock_make_request = mocker.patch.object(
|
||||||
|
NewsClient, "_make_request", return_value=mock_response
|
||||||
|
)
|
||||||
|
|
||||||
|
client = NewsClient(mock_client, "testuser")
|
||||||
|
await client.mark_item_read(item_id=123)
|
||||||
|
|
||||||
|
mock_make_request.assert_called_once_with(
|
||||||
|
"POST", "/apps/news/api/v1-3/items/123/read"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_news_api_mark_item_unread(mocker):
|
||||||
|
"""Test that mark_item_unread makes the correct API call."""
|
||||||
|
mock_response = create_mock_response(status_code=200, json_data={})
|
||||||
|
|
||||||
|
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||||
|
mock_make_request = mocker.patch.object(
|
||||||
|
NewsClient, "_make_request", return_value=mock_response
|
||||||
|
)
|
||||||
|
|
||||||
|
client = NewsClient(mock_client, "testuser")
|
||||||
|
await client.mark_item_unread(item_id=123)
|
||||||
|
|
||||||
|
mock_make_request.assert_called_once_with(
|
||||||
|
"POST", "/apps/news/api/v1-3/items/123/unread"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_news_api_star_item(mocker):
|
||||||
|
"""Test that star_item makes the correct API call."""
|
||||||
|
mock_response = create_mock_response(status_code=200, json_data={})
|
||||||
|
|
||||||
|
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||||
|
mock_make_request = mocker.patch.object(
|
||||||
|
NewsClient, "_make_request", return_value=mock_response
|
||||||
|
)
|
||||||
|
|
||||||
|
client = NewsClient(mock_client, "testuser")
|
||||||
|
await client.star_item(item_id=123)
|
||||||
|
|
||||||
|
mock_make_request.assert_called_once_with(
|
||||||
|
"POST", "/apps/news/api/v1-3/items/123/star"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_news_api_unstar_item(mocker):
|
||||||
|
"""Test that unstar_item makes the correct API call."""
|
||||||
|
mock_response = create_mock_response(status_code=200, json_data={})
|
||||||
|
|
||||||
|
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||||
|
mock_make_request = mocker.patch.object(
|
||||||
|
NewsClient, "_make_request", return_value=mock_response
|
||||||
|
)
|
||||||
|
|
||||||
|
client = NewsClient(mock_client, "testuser")
|
||||||
|
await client.unstar_item(item_id=123)
|
||||||
|
|
||||||
|
mock_make_request.assert_called_once_with(
|
||||||
|
"POST", "/apps/news/api/v1-3/items/123/unstar"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_news_api_mark_items_read_multiple(mocker):
|
||||||
|
"""Test that mark_items_read makes the correct API call for multiple items."""
|
||||||
|
mock_response = create_mock_response(status_code=200, json_data={})
|
||||||
|
|
||||||
|
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||||
|
mock_make_request = mocker.patch.object(
|
||||||
|
NewsClient, "_make_request", return_value=mock_response
|
||||||
|
)
|
||||||
|
|
||||||
|
client = NewsClient(mock_client, "testuser")
|
||||||
|
await client.mark_items_read(item_ids=[1, 2, 3])
|
||||||
|
|
||||||
|
mock_make_request.assert_called_once_with(
|
||||||
|
"POST", "/apps/news/api/v1-3/items/read/multiple", json={"itemIds": [1, 2, 3]}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_news_api_star_items_multiple(mocker):
|
||||||
|
"""Test that star_items makes the correct API call for multiple items."""
|
||||||
|
mock_response = create_mock_response(status_code=200, json_data={})
|
||||||
|
|
||||||
|
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||||
|
mock_make_request = mocker.patch.object(
|
||||||
|
NewsClient, "_make_request", return_value=mock_response
|
||||||
|
)
|
||||||
|
|
||||||
|
client = NewsClient(mock_client, "testuser")
|
||||||
|
await client.star_items(item_ids=[1, 2, 3])
|
||||||
|
|
||||||
|
mock_make_request.assert_called_once_with(
|
||||||
|
"POST", "/apps/news/api/v1-3/items/star/multiple", json={"itemIds": [1, 2, 3]}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Status Tests
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
async def test_news_api_get_status(mocker):
|
||||||
|
"""Test that get_status correctly parses the API response."""
|
||||||
|
mock_response = create_mock_news_status_response(
|
||||||
|
version="25.0.0",
|
||||||
|
warnings={"improperlyConfiguredCron": False},
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||||
|
mock_make_request = mocker.patch.object(
|
||||||
|
NewsClient, "_make_request", return_value=mock_response
|
||||||
|
)
|
||||||
|
|
||||||
|
client = NewsClient(mock_client, "testuser")
|
||||||
|
status = await client.get_status()
|
||||||
|
|
||||||
|
assert status["version"] == "25.0.0"
|
||||||
|
assert "warnings" in status
|
||||||
|
|
||||||
|
mock_make_request.assert_called_once_with("GET", "/apps/news/api/v1-3/status")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_news_api_get_version(mocker):
|
||||||
|
"""Test that get_version correctly parses the API response."""
|
||||||
|
mock_response = create_mock_response(
|
||||||
|
status_code=200, json_data={"version": "25.0.0"}
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||||
|
mock_make_request = mocker.patch.object(
|
||||||
|
NewsClient, "_make_request", return_value=mock_response
|
||||||
|
)
|
||||||
|
|
||||||
|
client = NewsClient(mock_client, "testuser")
|
||||||
|
version = await client.get_version()
|
||||||
|
|
||||||
|
assert version == "25.0.0"
|
||||||
|
|
||||||
|
mock_make_request.assert_called_once_with("GET", "/apps/news/api/v1-3/version")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Error Handling Tests
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
async def test_news_api_create_folder_conflict(mocker):
|
||||||
|
"""Test that create_folder raises HTTPStatusError on 409 conflict."""
|
||||||
|
error_response = create_mock_error_response(409, "Folder name already exists")
|
||||||
|
|
||||||
|
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||||
|
mock_make_request = mocker.patch.object(NewsClient, "_make_request")
|
||||||
|
mock_make_request.side_effect = httpx.HTTPStatusError(
|
||||||
|
"409 Conflict",
|
||||||
|
request=httpx.Request("POST", "http://test.local"),
|
||||||
|
response=error_response,
|
||||||
|
)
|
||||||
|
|
||||||
|
client = NewsClient(mock_client, "testuser")
|
||||||
|
|
||||||
|
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
||||||
|
await client.create_folder(name="Existing Folder")
|
||||||
|
|
||||||
|
assert excinfo.value.response.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
async def test_news_api_delete_feed_not_found(mocker):
|
||||||
|
"""Test that delete_feed raises HTTPStatusError on 404."""
|
||||||
|
error_response = create_mock_error_response(404, "Feed not found")
|
||||||
|
|
||||||
|
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||||
|
mock_make_request = mocker.patch.object(NewsClient, "_make_request")
|
||||||
|
mock_make_request.side_effect = httpx.HTTPStatusError(
|
||||||
|
"404 Not Found",
|
||||||
|
request=httpx.Request("DELETE", "http://test.local"),
|
||||||
|
response=error_response,
|
||||||
|
)
|
||||||
|
|
||||||
|
client = NewsClient(mock_client, "testuser")
|
||||||
|
|
||||||
|
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
||||||
|
await client.delete_feed(feed_id=999999)
|
||||||
|
|
||||||
|
assert excinfo.value.response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_news_api_create_feed_invalid_url(mocker):
|
||||||
|
"""Test that create_feed raises HTTPStatusError on 422 for invalid URL."""
|
||||||
|
error_response = create_mock_error_response(422, "Invalid feed URL")
|
||||||
|
|
||||||
|
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||||
|
mock_make_request = mocker.patch.object(NewsClient, "_make_request")
|
||||||
|
mock_make_request.side_effect = httpx.HTTPStatusError(
|
||||||
|
"422 Unprocessable Entity",
|
||||||
|
request=httpx.Request("POST", "http://test.local"),
|
||||||
|
response=error_response,
|
||||||
|
)
|
||||||
|
|
||||||
|
client = NewsClient(mock_client, "testuser")
|
||||||
|
|
||||||
|
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
||||||
|
await client.create_feed(url="not-a-valid-url")
|
||||||
|
|
||||||
|
assert excinfo.value.response.status_code == 422
|
||||||
@@ -189,25 +189,14 @@ async def test_get_file_info_returns_none_for_missing_file(mocker):
|
|||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
async def test_create_tag_creates_system_tag(mocker):
|
async def test_create_tag_creates_system_tag(mocker):
|
||||||
"""Test that create_tag creates a system tag via OCS API."""
|
"""Test that create_tag creates a system tag via WebDAV."""
|
||||||
mock_http_client = AsyncMock()
|
mock_http_client = AsyncMock()
|
||||||
client = WebDAVClient(mock_http_client, "testuser")
|
client = WebDAVClient(mock_http_client, "testuser")
|
||||||
|
|
||||||
# Mock OCS response
|
# Mock WebDAV response with Content-Location header
|
||||||
mock_response = AsyncMock()
|
mock_response = AsyncMock()
|
||||||
mock_response.status_code = 200
|
mock_response.status_code = 201
|
||||||
mock_response.json = mocker.Mock(
|
mock_response.headers = {"Content-Location": "/remote.php/dav/systemtags/42"}
|
||||||
return_value={
|
|
||||||
"ocs": {
|
|
||||||
"data": {
|
|
||||||
"id": 42,
|
|
||||||
"name": "vector-index",
|
|
||||||
"userVisible": True,
|
|
||||||
"userAssignable": True,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
mock_response.raise_for_status = mocker.Mock()
|
mock_response.raise_for_status = mocker.Mock()
|
||||||
|
|
||||||
mock_http_client.post = AsyncMock(return_value=mock_response)
|
mock_http_client.post = AsyncMock(return_value=mock_response)
|
||||||
@@ -224,8 +213,10 @@ async def test_create_tag_creates_system_tag(mocker):
|
|||||||
# Verify API call
|
# Verify API call
|
||||||
mock_http_client.post.assert_called_once()
|
mock_http_client.post.assert_called_once()
|
||||||
call_args = mock_http_client.post.call_args
|
call_args = mock_http_client.post.call_args
|
||||||
assert call_args[0][0] == "/ocs/v2.php/apps/systemtags/api/v1/tags"
|
assert call_args[0][0] == "/remote.php/dav/systemtags/"
|
||||||
assert call_args[1]["json"]["name"] == "vector-index"
|
assert call_args[1]["json"]["name"] == "vector-index"
|
||||||
|
assert call_args[1]["json"]["userVisible"] is True
|
||||||
|
assert call_args[1]["json"]["userAssignable"] is True
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
|
|||||||
Reference in New Issue
Block a user