Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 269da795ee | |||
| f70d743c8b | |||
| 251b8a10c0 | |||
| 3f06e2ee77 | |||
| 7f11c793ef | |||
| e28dcbff9a | |||
| 89ec0186a4 | |||
| 6e1efde8c6 | |||
| 6aa80d4210 | |||
| 4e86006b3f | |||
| 679e22a7c2 | |||
| 4d3228a4a8 | |||
| 0aa307f0b6 | |||
| 6a69ecefb1 | |||
| c05beb66e9 | |||
| 34ddb24014 | |||
| 9d69613df7 | |||
| 630f818538 | |||
| b280a720ff | |||
| 48bac9c212 | |||
| e88c49fb50 | |||
| 9e10a5a400 | |||
| 1dbea24fa2 | |||
| 0606228b40 | |||
| f35b9f0988 | |||
| c400c46672 | |||
| fbdeb2161d | |||
| 8c7d03dd29 | |||
| 135ce7b2df | |||
| 0e47ae051b | |||
| 04255473d2 | |||
| ce6bbff389 | |||
| 92c4bf36f6 | |||
| 0bedbf1877 | |||
| a5cb6e1242 | |||
| a33f6a2f15 | |||
| d79e9090e6 | |||
| 97fd660e38 | |||
| 96e168d035 | |||
| 4d2b77ecaf | |||
| e48da80a4b | |||
| 6125312f61 | |||
| 007fd0c2e3 | |||
| c4f90d6a57 | |||
| 5dd62c9466 | |||
| 4d072d7217 | |||
| b4242b1394 | |||
| fa2343dff9 | |||
| 1b1667bc2b | |||
| c2b4bf9c67 | |||
| 0845fefe6c | |||
| d911556a84 | |||
| 38be8d9401 | |||
| 9f3190f62a | |||
| 41aeb7e0f2 | |||
| f8e67519e1 | |||
| 4279dcba1e | |||
| be7e3d6b56 | |||
| 41e128190b | |||
| ba869ccde5 | |||
| 27fe066b23 | |||
| e94b8ff714 | |||
| e3a6894904 | |||
| 92b97bda00 | |||
| d5c6039296 | |||
| 3fa13c8bfd | |||
| 9d306b71fa | |||
| 38a936c120 | |||
| 86d13a7240 | |||
| 0b2d449ffa | |||
| d881373dce |
@@ -15,7 +15,7 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
github_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||
changelog_increment_filename: body.md
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
||||
with:
|
||||
body_path: "body.md"
|
||||
tag_name: v${{ env.REVISION }}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@6337623ebba10cf8c8214b507993f8062fd4ccfb # v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
prompt: |
|
||||
REPO: ${{ github.repository }}
|
||||
PR NUMBER: ${{ github.event.pull_request.number }}
|
||||
|
||||
Please review this pull request and provide feedback on:
|
||||
- Code quality and best practices
|
||||
- Potential bugs or issues
|
||||
- Performance considerations
|
||||
- Security concerns
|
||||
- Test coverage
|
||||
|
||||
Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback.
|
||||
|
||||
Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.
|
||||
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://docs.claude.com/en/docs/claude-code/cli-reference for available options
|
||||
claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@6337623ebba10cf8c8214b507993f8062fd4ccfb # v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://docs.claude.com/en/docs/claude-code/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||
|
||||
@@ -12,11 +12,11 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||
with:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -24,21 +24,22 @@ jobs:
|
||||
models: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Run docker compose with vector sync
|
||||
uses: hoverkraft-tech/compose-action@3846bcd61da338e9eaaf83e7ed0234a12b099b72 # v2.4.1
|
||||
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
|
||||
with:
|
||||
compose-file: "./docker-compose.yml"
|
||||
compose-file: |
|
||||
./docker-compose.yml
|
||||
./docker-compose.ci.yml
|
||||
up-flags: "--build"
|
||||
env:
|
||||
# Override MCP container environment for OpenAI + vector sync
|
||||
VECTOR_SYNC_ENABLED: "true"
|
||||
VECTOR_SYNC_SCAN_INTERVAL: "5"
|
||||
# Environment variables passed to docker-compose.ci.yml
|
||||
OPENAI_API_KEY: ${{ secrets.GITHUB_TOKEN }}
|
||||
OPENAI_BASE_URL: "https://models.github.ai/inference"
|
||||
OPENAI_EMBEDDING_MODEL: ${{ inputs.embedding_model }}
|
||||
OPENAI_GENERATION_MODEL: ${{ inputs.generation_model }}
|
||||
VECTOR_SYNC_SCAN_INTERVAL: "5"
|
||||
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
||||
@@ -86,11 +87,17 @@ jobs:
|
||||
OPENAI_EMBEDDING_MODEL: ${{ inputs.embedding_model }}
|
||||
OPENAI_GENERATION_MODEL: ${{ inputs.generation_model }}
|
||||
run: |
|
||||
uv run pytest tests/integration/test_rag_openai.py -v --log-cli-level=INFO --provider openai
|
||||
uv run pytest tests/integration/test_rag.py -v --log-cli-level=INFO --provider openai
|
||||
|
||||
- name: Capture MCP container logs
|
||||
if: always()
|
||||
run: |
|
||||
echo "=== MCP Container Logs ==="
|
||||
docker compose logs mcp --tail=500
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
||||
with:
|
||||
name: rag-evaluation-results
|
||||
path: |
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
||||
- name: Install Python 3.11
|
||||
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
linting:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
||||
- name: Check format
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
submodules: 'true'
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
###### Required to build OIDC App ######
|
||||
|
||||
- name: Set up php 8.4
|
||||
uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # v2
|
||||
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2
|
||||
with:
|
||||
php-version: 8.4
|
||||
coverage: none
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
|
||||
|
||||
- name: Run docker compose
|
||||
uses: hoverkraft-tech/compose-action@3846bcd61da338e9eaaf83e7ed0234a12b099b72 # v2.4.1
|
||||
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
|
||||
with:
|
||||
compose-file: "./docker-compose.yml"
|
||||
#compose-flags: "--profile qdrant"
|
||||
|
||||
@@ -1,3 +1,39 @@
|
||||
## 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)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deps**: update dependency mcp to >=1.23,<1.24
|
||||
|
||||
## v0.48.5 (2025-11-28)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deps**: update dependency pillow to v12
|
||||
|
||||
## v0.48.4 (2025-11-23)
|
||||
|
||||
### Fix
|
||||
|
||||
- Add rate limit retry logic to OpenAI provider
|
||||
|
||||
## v0.48.3 (2025-11-23)
|
||||
|
||||
### Fix
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:b43ff04d5df04ad5cabb80890b7ef74e8410e3395b19af970dcd52d7a4bff921
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.11@sha256:5aa820129de0a600924f166aec9cb51613b15b68f1dcd2a02f31a500d2ede568 /uv /uvx /bin/
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.16@sha256:ae9ff79d095a61faf534a882ad6378e8159d2ce322691153d68d2afac7422840 /uv /uvx /bin/
|
||||
|
||||
# Install dependencies
|
||||
# 1. git (required for caldav dependency from git)
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ FROM docker.io/library/python:3.12-slim-trixie@sha256:b43ff04d5df04ad5cabb80890b
|
||||
WORKDIR /app
|
||||
|
||||
# Install uv for fast dependency management
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.11@sha256:5aa820129de0a600924f166aec9cb51613b15b68f1dcd2a02f31a500d2ede568 /uv /uvx /bin/
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.16@sha256:ae9ff79d095a61faf534a882ad6378e8159d2ce322691153d68d2afac7422840 /uv /uvx /bin/
|
||||
|
||||
# Install dependencies
|
||||
# 1. git (required for caldav dependency from git)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
```markdown
|
||||
<p align="center">
|
||||
<img src="astrolabe.svg" alt="Nextcloud MCP Server" width="128" height="128">
|
||||
</p>
|
||||
|
||||
# Nextcloud MCP Server
|
||||
|
||||
[](https://github.com/cbcoutinho/nextcloud-mcp-server/pkgs/container/nextcloud-mcp-server)
|
||||
[](https://smithery.ai/server/@cbcoutinho/nextcloud-mcp-server)
|
||||
[](https://github.com/cbcoutinho/nextcloud-mcp-server/pkgs/container/nextcloud-mcp-server)
|
||||
|
||||
**A production-ready MCP server that connects AI assistants to your Nextcloud instance.**
|
||||
|
||||
@@ -223,3 +224,4 @@ This project is licensed under the AGPL-3.0 License. See [LICENSE](./LICENSE) fo
|
||||
- [Model Context Protocol](https://github.com/modelcontextprotocol)
|
||||
- [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk)
|
||||
- [Nextcloud](https://nextcloud.com/)
|
||||
```
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euox pipefail
|
||||
|
||||
php /var/www/html/occ app:enable news
|
||||
@@ -1,9 +1,9 @@
|
||||
dependencies:
|
||||
- name: qdrant
|
||||
repository: https://qdrant.github.io/qdrant-helm
|
||||
version: 1.16.0
|
||||
version: 1.16.2
|
||||
- name: ollama
|
||||
repository: https://otwld.github.io/ollama-helm
|
||||
version: 1.35.0
|
||||
digest: sha256:da8db198b12ce0252df220fabb297cfe69186edb8e67952c52e05de778189b92
|
||||
generated: "2025-11-21T11:09:07.997781541Z"
|
||||
digest: sha256:bcb0779739e4710b90bb65f6a7baeaa295bd0ba9776f8a1cf8d9b69d233c8ec0
|
||||
generated: "2025-12-05T11:11:27.999374001Z"
|
||||
|
||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
||||
name: nextcloud-mcp-server
|
||||
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
||||
type: application
|
||||
version: 0.48.3
|
||||
appVersion: "0.48.3"
|
||||
version: 0.49.0
|
||||
appVersion: "0.49.0"
|
||||
keywords:
|
||||
- nextcloud
|
||||
- mcp
|
||||
@@ -27,7 +27,7 @@ annotations:
|
||||
grafana_dashboard_folder: "Nextcloud MCP"
|
||||
dependencies:
|
||||
- name: qdrant
|
||||
version: "1.16.0"
|
||||
version: "1.16.2"
|
||||
repository: https://qdrant.github.io/qdrant-helm
|
||||
condition: qdrant.networkMode.deploySubchart
|
||||
- name: ollama
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# CI-specific overrides for RAG evaluation pipeline
|
||||
# This file is used by the rag-evaluation.yml workflow to configure the MCP
|
||||
# container with OpenAI/GitHub Models API for vector embeddings.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.ci.yml up
|
||||
#
|
||||
# Environment variables (set in CI workflow):
|
||||
# OPENAI_API_KEY - API key for embeddings (GitHub Models uses GITHUB_TOKEN)
|
||||
# OPENAI_BASE_URL - API endpoint (e.g., https://models.github.ai/inference)
|
||||
# OPENAI_EMBEDDING_MODEL - Model name (e.g., openai/text-embedding-3-small)
|
||||
# OPENAI_GENERATION_MODEL - Model name for generation (e.g., openai/gpt-4o-mini)
|
||||
|
||||
services:
|
||||
mcp:
|
||||
environment:
|
||||
# OpenAI provider configuration (required for CI vector sync)
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||
- OPENAI_BASE_URL=${OPENAI_BASE_URL:-https://models.github.ai/inference}
|
||||
- OPENAI_EMBEDDING_MODEL=${OPENAI_EMBEDDING_MODEL:-openai/text-embedding-3-small}
|
||||
- OPENAI_GENERATION_MODEL=${OPENAI_GENERATION_MODEL:-openai/gpt-4o-mini}
|
||||
# Faster sync for CI
|
||||
- VECTOR_SYNC_SCAN_INTERVAL=${VECTOR_SYNC_SCAN_INTERVAL:-5}
|
||||
# Enable document processing for PDF parsing
|
||||
- ENABLE_DOCUMENT_PROCESSING=true
|
||||
+4
-4
@@ -21,7 +21,7 @@ services:
|
||||
restart: always
|
||||
|
||||
app:
|
||||
image: docker.io/library/nextcloud:32.0.2@sha256:ac08482d73ffd85d94069ba291bbd5fb39a70ff21502030a2e3e2d89a7246a48
|
||||
image: docker.io/library/nextcloud:32.0.2@sha256:8cb1dc8c26944115469dd22f4965d2ed35bab9cf8c48d2bb052c8e9f83821ded
|
||||
restart: always
|
||||
ports:
|
||||
- 0.0.0.0:8080:80
|
||||
@@ -34,7 +34,7 @@ services:
|
||||
- ./app-hooks:/docker-entrypoint-hooks.d:ro
|
||||
# Mount OIDC development directory outside /var/www/html to avoid rsync conflicts
|
||||
# The post-installation hook will register /opt/apps as an additional app directory
|
||||
- ./third_party:/opt/apps:ro
|
||||
#- ./third_party:/opt/apps:ro
|
||||
environment:
|
||||
- NEXTCLOUD_TRUSTED_DOMAINS=app
|
||||
- NEXTCLOUD_ADMIN_USER=admin
|
||||
@@ -158,7 +158,7 @@ services:
|
||||
- oauth-tokens:/app/data
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:26.4.5@sha256:653852bfdea2be6e958b9e90a976eff1c6de34edd55f2f679bdc48ef16bc528e
|
||||
image: quay.io/keycloak/keycloak:26.4.7@sha256:9409c59bdfb65dbffa20b11e6f18b8abb9281d480c7ca402f51ed3d5977e6007
|
||||
command:
|
||||
- "start-dev"
|
||||
- "--import-realm"
|
||||
@@ -245,7 +245,7 @@ services:
|
||||
- smithery
|
||||
|
||||
qdrant:
|
||||
image: qdrant/qdrant:v1.16.0@sha256:1005201498cf927d835383d0f918b17d8c9da7db58550f169f694455e42d78f4
|
||||
image: qdrant/qdrant:v1.16.2@sha256:dab6de32f7b2cc599985a7c764db3e8b062f70508fb85ca074aa856f829bf335
|
||||
restart: always
|
||||
ports:
|
||||
- 127.0.0.1:6333:6333 # REST API
|
||||
|
||||
@@ -60,6 +60,7 @@ from nextcloud_mcp_server.server import (
|
||||
configure_contacts_tools,
|
||||
configure_cookbook_tools,
|
||||
configure_deck_tools,
|
||||
configure_news_tools,
|
||||
configure_notes_tools,
|
||||
configure_semantic_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.
|
||||
#
|
||||
# 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
|
||||
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,
|
||||
"cookbook": configure_cookbook_tools,
|
||||
"deck": configure_deck_tools,
|
||||
"news": configure_news_tools,
|
||||
}
|
||||
|
||||
# If no specific apps are specified, enable all
|
||||
|
||||
@@ -303,10 +303,13 @@ class UnifiedTokenVerifier(TokenVerifier):
|
||||
|
||||
try:
|
||||
# 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(
|
||||
self.introspection_uri,
|
||||
data={"token": token},
|
||||
auth=(self.settings.oidc_client_id, self.settings.oidc_client_secret),
|
||||
auth=(client_id, client_secret),
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
|
||||
@@ -139,6 +139,7 @@ async def _get_authenticated_client(request: Request) -> httpx.AsyncClient:
|
||||
raise RuntimeError("BasicAuth credentials not configured")
|
||||
|
||||
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(
|
||||
base_url=nextcloud_host,
|
||||
auth=(username, password),
|
||||
|
||||
@@ -18,6 +18,7 @@ from .contacts import ContactsClient
|
||||
from .cookbook import CookbookClient
|
||||
from .deck import DeckClient
|
||||
from .groups import GroupsClient
|
||||
from .news import NewsClient
|
||||
from .notes import NotesClient
|
||||
from .sharing import SharingClient
|
||||
from .tables import TablesClient
|
||||
@@ -81,6 +82,7 @@ class NextcloudClient:
|
||||
self.contacts = ContactsClient(self._client, username)
|
||||
self.cookbook = CookbookClient(self._client, username)
|
||||
self.deck = DeckClient(self._client, username)
|
||||
self.news = NewsClient(self._client, username)
|
||||
self.users = UsersClient(self._client, username)
|
||||
self.groups = GroupsClient(self._client, username)
|
||||
self.sharing = SharingClient(self._client, username)
|
||||
|
||||
@@ -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:
|
||||
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,
|
||||
"userVisible": user_visible_elem.text.lower() == "true"
|
||||
if user_visible_elem is not None
|
||||
@@ -1369,7 +1371,9 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
)
|
||||
|
||||
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,
|
||||
"name": displayname_elem.text
|
||||
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(
|
||||
self,
|
||||
log_record: dict[str, Any],
|
||||
log_data: dict[str, Any],
|
||||
record: logging.LogRecord,
|
||||
message_dict: dict[str, Any],
|
||||
) -> None:
|
||||
@@ -68,28 +68,28 @@ class TraceContextFormatter(JsonFormatter):
|
||||
Add custom fields to the log record, including trace context.
|
||||
|
||||
Args:
|
||||
log_record: Dictionary to be serialized as JSON
|
||||
log_data: Dictionary to be serialized as JSON
|
||||
record: LogRecord instance
|
||||
message_dict: Dictionary of extra fields from log call
|
||||
"""
|
||||
# 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
|
||||
trace_context = get_trace_context()
|
||||
if trace_context:
|
||||
log_record["trace_id"] = trace_context.get("trace_id")
|
||||
log_record["span_id"] = trace_context.get("span_id")
|
||||
log_data["trace_id"] = trace_context.get("trace_id")
|
||||
log_data["span_id"] = trace_context.get("span_id")
|
||||
|
||||
# Add standard fields with consistent naming
|
||||
log_record["timestamp"] = self.formatTime(record)
|
||||
log_record["level"] = record.levelname
|
||||
log_record["logger"] = record.name
|
||||
log_record["message"] = record.getMessage()
|
||||
log_data["timestamp"] = self.formatTime(record)
|
||||
log_data["level"] = record.levelname
|
||||
log_data["logger"] = record.name
|
||||
log_data["message"] = record.getMessage()
|
||||
|
||||
# Include exception info if present
|
||||
if record.exc_info:
|
||||
log_record["exception"] = self.formatException(record.exc_info)
|
||||
log_data["exception"] = self.formatException(record.exc_info)
|
||||
|
||||
|
||||
class TraceContextTextFormatter(logging.Formatter):
|
||||
|
||||
@@ -7,13 +7,48 @@ Supports:
|
||||
"""
|
||||
|
||||
import logging
|
||||
from functools import wraps
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
import anyio
|
||||
from openai import AsyncOpenAI, RateLimitError
|
||||
|
||||
from .base import Provider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Rate limit retry configuration
|
||||
MAX_RETRIES = 5
|
||||
INITIAL_RETRY_DELAY = 2.0 # seconds
|
||||
MAX_RETRY_DELAY = 60.0 # seconds
|
||||
|
||||
|
||||
def retry_on_rate_limit(func):
|
||||
"""Decorator to retry on OpenAI rate limit errors with exponential backoff."""
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
retry_delay = INITIAL_RETRY_DELAY
|
||||
last_error: Exception | None = None
|
||||
|
||||
for attempt in range(1, MAX_RETRIES + 1):
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except RateLimitError as e:
|
||||
last_error = e
|
||||
if attempt < MAX_RETRIES:
|
||||
logger.warning(
|
||||
f"Rate limit hit (attempt {attempt}/{MAX_RETRIES}), "
|
||||
f"retrying in {retry_delay:.1f}s..."
|
||||
)
|
||||
await anyio.sleep(retry_delay)
|
||||
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
|
||||
|
||||
logger.error(f"Rate limit exceeded after {MAX_RETRIES} attempts")
|
||||
raise last_error # type: ignore[misc]
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
# Well-known embedding dimensions for OpenAI models
|
||||
OPENAI_EMBEDDING_DIMENSIONS: dict[str, int] = {
|
||||
"text-embedding-3-small": 1536,
|
||||
@@ -86,6 +121,7 @@ class OpenAIProvider(Provider):
|
||||
"""Whether this provider supports text generation."""
|
||||
return self.generation_model is not None
|
||||
|
||||
@retry_on_rate_limit
|
||||
async def embed(self, text: str) -> list[float]:
|
||||
"""
|
||||
Generate embedding vector for text.
|
||||
@@ -104,6 +140,7 @@ class OpenAIProvider(Provider):
|
||||
"Embedding not supported - no embedding_model configured"
|
||||
)
|
||||
|
||||
assert self.embedding_model is not None # Type narrowing
|
||||
response = await self.client.embeddings.create(
|
||||
input=text,
|
||||
model=self.embedding_model,
|
||||
@@ -151,14 +188,8 @@ class OpenAIProvider(Provider):
|
||||
for i in range(0, len(texts), batch_size):
|
||||
batch = texts[i : i + batch_size]
|
||||
|
||||
response = await self.client.embeddings.create(
|
||||
input=batch,
|
||||
model=self.embedding_model,
|
||||
)
|
||||
|
||||
# Sort by index to maintain order
|
||||
sorted_data = sorted(response.data, key=lambda x: x.index)
|
||||
batch_embeddings = [item.embedding for item in sorted_data]
|
||||
# Use helper method with retry logic for each batch
|
||||
batch_embeddings = await self._embed_batch_request(batch)
|
||||
all_embeddings.extend(batch_embeddings)
|
||||
|
||||
# Update dimension if not set
|
||||
@@ -171,6 +202,18 @@ class OpenAIProvider(Provider):
|
||||
|
||||
return all_embeddings
|
||||
|
||||
@retry_on_rate_limit
|
||||
async def _embed_batch_request(self, batch: list[str]) -> list[list[float]]:
|
||||
"""Make a single batch embedding request with retry logic."""
|
||||
assert self.embedding_model is not None # Type narrowing
|
||||
response = await self.client.embeddings.create(
|
||||
input=batch,
|
||||
model=self.embedding_model,
|
||||
)
|
||||
# Sort by index to maintain order
|
||||
sorted_data = sorted(response.data, key=lambda x: x.index)
|
||||
return [item.embedding for item in sorted_data]
|
||||
|
||||
def get_dimension(self) -> int:
|
||||
"""
|
||||
Get embedding dimension.
|
||||
@@ -194,6 +237,7 @@ class OpenAIProvider(Provider):
|
||||
)
|
||||
return self._dimension
|
||||
|
||||
@retry_on_rate_limit
|
||||
async def generate(self, prompt: str, max_tokens: int = 500) -> str:
|
||||
"""
|
||||
Generate text from a prompt.
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
doc_types = {
|
||||
point.payload.get("doc_type")
|
||||
doc_types: set[str] = {
|
||||
str(point.payload.get("doc_type"))
|
||||
for point in scroll_results
|
||||
if point.payload.get("doc_type")
|
||||
}
|
||||
|
||||
@@ -204,6 +204,8 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
|
||||
results = []
|
||||
|
||||
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 = result.payload["doc_id"]
|
||||
doc_type = result.payload.get("doc_type", "note")
|
||||
|
||||
@@ -136,6 +136,8 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
|
||||
results = []
|
||||
|
||||
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 = result.payload["doc_id"]
|
||||
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 .cookbook import configure_cookbook_tools
|
||||
from .deck import configure_deck_tools
|
||||
from .news import configure_news_tools
|
||||
from .notes import configure_notes_tools
|
||||
from .semantic import configure_semantic_tools
|
||||
from .sharing import configure_sharing_tools
|
||||
@@ -13,6 +14,7 @@ __all__ = [
|
||||
"configure_contacts_tools",
|
||||
"configure_cookbook_tools",
|
||||
"configure_deck_tools",
|
||||
"configure_news_tools",
|
||||
"configure_notes_tools",
|
||||
"configure_semantic_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
|
||||
content_bytes = None # Notes don't have binary content
|
||||
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":
|
||||
# For files, doc_id is now the numeric file ID, file_path comes from DocumentTask
|
||||
if not doc_task.file_path:
|
||||
@@ -358,15 +397,16 @@ async def _index_document(
|
||||
chunks = await chunker.chunk_text(content)
|
||||
|
||||
# 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(
|
||||
"vector_sync.assign_page_numbers",
|
||||
attributes={
|
||||
"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
|
||||
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"Chunks: {len(chunks)}, "
|
||||
f"Chunk offset range: [{chunks[0].start_offset}:{chunks[-1].end_offset}], "
|
||||
f"Page boundaries: {len(file_metadata['page_boundaries'])} pages, "
|
||||
f"First boundary: {file_metadata['page_boundaries'][0] if file_metadata['page_boundaries'] else 'None'}"
|
||||
f"Page boundaries: {len(page_boundaries)} pages, "
|
||||
f"First boundary: {page_boundaries[0] if page_boundaries else 'None'}"
|
||||
)
|
||||
|
||||
# Extract chunk texts for embedding
|
||||
@@ -566,6 +606,23 @@ async def _index_document(
|
||||
if doc_task.doc_type == "file"
|
||||
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)
|
||||
**(
|
||||
{
|
||||
|
||||
@@ -206,7 +206,11 @@ async def scan_user_documents(
|
||||
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")
|
||||
|
||||
@@ -376,7 +380,9 @@ async def scan_user_documents(
|
||||
)
|
||||
|
||||
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")
|
||||
@@ -544,9 +550,206 @@ async def scan_user_documents(
|
||||
|
||||
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:
|
||||
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:
|
||||
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]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.48.3"
|
||||
version = "0.49.0"
|
||||
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
||||
authors = [
|
||||
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
||||
@@ -10,7 +10,7 @@ license = {text = "AGPL-3.0-only"}
|
||||
requires-python = ">=3.11"
|
||||
keywords = ["nextcloud", "mcp", "model-context-protocol", "llm", "ai", "claude", "webdav", "caldav", "carddav"]
|
||||
dependencies = [
|
||||
"mcp[cli] (>=1.22,<1.23)",
|
||||
"mcp[cli] (>=1.23,<1.24)",
|
||||
"httpx (>=0.28.1,<0.29.0)",
|
||||
"pillow (>=10.3.0,<12.0.0)", # Compatible with fastembed
|
||||
"icalendar (>=6.0.0,<7.0.0)",
|
||||
@@ -36,6 +36,7 @@ dependencies = [
|
||||
"python-json-logger>=3.2.0", # Structured JSON logging
|
||||
"jinja2>=3.1.6",
|
||||
"langchain-text-splitters>=1.0.0",
|
||||
"markdownify>=0.14.1", # HTML to Markdown conversion for News items
|
||||
"pymupdf>=1.26.6",
|
||||
"pymupdf4llm>=0.2.2",
|
||||
"pymupdf-layout>=1.26.6",
|
||||
|
||||
+7
-1
@@ -4,5 +4,11 @@
|
||||
"config:best-practices",
|
||||
"mergeConfidence:all-badges"
|
||||
],
|
||||
"dependencyDashboard": true
|
||||
"dependencyDashboard": true,
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPackageNames": ["pillow"],
|
||||
"allowedVersions": "<12.0.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -480,3 +480,222 @@ def create_mock_table_row_ocs_response(
|
||||
ocs_response = {"ocs": {"meta": {"status": "ok"}, "data": row_data}}
|
||||
|
||||
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
|
||||
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()
|
||||
client = WebDAVClient(mock_http_client, "testuser")
|
||||
|
||||
# Mock OCS response
|
||||
# Mock WebDAV response with Content-Location header
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json = mocker.Mock(
|
||||
return_value={
|
||||
"ocs": {
|
||||
"data": {
|
||||
"id": 42,
|
||||
"name": "vector-index",
|
||||
"userVisible": True,
|
||||
"userAssignable": True,
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
mock_response.status_code = 201
|
||||
mock_response.headers = {"Content-Location": "/remote.php/dav/systemtags/42"}
|
||||
mock_response.raise_for_status = mocker.Mock()
|
||||
|
||||
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
|
||||
mock_http_client.post.assert_called_once()
|
||||
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"]["userVisible"] is True
|
||||
assert call_args[1]["json"]["userAssignable"] is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
|
||||
Reference in New Issue
Block a user