Skip to content

Commit c8648bf

Browse files
Pavlinchenjanepie
authored andcommitted
feat(collectives): add Collectives support for page management
Add a new tool module wrapping the Collectives OCS API (under /ocs/v2.php/apps/collectives/api/v1.0) plus WebDAV for page markdown content, giving the agent 13 tools covering the full page lifecycle. Read (safe): - list_collectives: enumerate the user's collectives with permissions - list_collective_pages: flat list of pages with tree metadata (parentId + subpageOrder) for a collective - get_page: metadata for a single page - get_page_content: markdown body via WebDAV, empty string on 404 - list_page_trash: enumerate trashed pages Write (dangerous): - create_page: new page under a parent - update_page_content: overwrite markdown body via WebDAV PUT - rename_page: change title (also renames the .md file) - move_page: change parentId within the collective - set_page_emoji: set/clear page emoji - trash_page: soft-delete - restore_page: from trash - delete_page_permanently: purge a trashed page Markdown I/O uses the same WebDAV adapter pattern as files.py (nc._session._create_adapter(True)) and the page's collectivePath + filePath + fileName from the metadata. Paths are URL-encoded via urllib.parse.quote. Collectives does not register an OCS capability, so is_available() probes the API (matching the pattern in mail.py) rather than checking nc.capabilities. Unified search already exposes three Collectives providers (collectives, collectives-pages, collectives-page-content) via search.py, so no separate search tool is added here. Tested against Nextcloud 32.0.8 with Collectives 3.6.1 on the full lifecycle (create, write, read, rename, move, emoji, trash, restore, permanent delete) plus URL construction for top-level and nested pages with spaces in names. Signed-off-by: Pavlinchen <69079839+Pavlinchen@users.noreply.github.com>
1 parent bfa9280 commit c8648bf

1 file changed

Lines changed: 245 additions & 0 deletions

File tree

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
2+
# SPDX-License-Identifier: AGPL-3.0-or-later
3+
import json
4+
from urllib.parse import quote
5+
from langchain_core.tools import tool
6+
from nc_py_api import AsyncNextcloudApp
7+
8+
from ex_app.lib.all_tools.lib.decorator import safe_tool, dangerous_tool
9+
10+
11+
async def get_tools(nc: AsyncNextcloudApp):
12+
13+
async def _user_id() -> str:
14+
return (await nc.ocs('GET', '/ocs/v2.php/cloud/user'))['id']
15+
16+
async def _page_webdav_url(user_id: str, page: dict) -> str:
17+
# A page's markdown file lives at:
18+
# /remote.php/dav/files/{user}/{collectivePath}/{filePath}/{fileName}
19+
# filePath is empty for top-level pages.
20+
parts = [page['collectivePath'], page.get('filePath') or '', page['fileName']]
21+
encoded = [quote(p) for p in parts if p]
22+
return f"{nc.app_cfg.endpoint}/remote.php/dav/files/{user_id}/{'/'.join(encoded)}"
23+
24+
# --- Collectives ---
25+
26+
@tool
27+
@safe_tool
28+
async def list_collectives():
29+
"""
30+
List all Collectives (wiki-like knowledge bases) the current user is a member of.
31+
Each collective contains pages of Markdown content, organized in a tree.
32+
:return: list of collectives with id, name, emoji, slug, and the user's permissions (canEdit, canShare)
33+
"""
34+
return json.dumps(await nc.ocs('GET', '/ocs/v2.php/apps/collectives/api/v1.0/collectives'))
35+
36+
# --- Pages (read) ---
37+
38+
@tool
39+
@safe_tool
40+
async def list_collective_pages(collective_id: int):
41+
"""
42+
List all pages in a Collective as a flat list with tree information.
43+
Pages form a tree via parentId (0 = top-level / landing page). Each page has an id needed
44+
by every other page tool, a title, and metadata (emoji, tags, last editor, trashed status).
45+
Markdown content is not included - fetch it with get_page_content.
46+
:param collective_id: the id of the collective (obtainable with list_collectives)
47+
:return: list of pages with id, title, emoji, parentId, subpageOrder, tags, lastUserId, timestamp, size, trashTimestamp
48+
"""
49+
return json.dumps(await nc.ocs('GET', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages'))
50+
51+
@tool
52+
@safe_tool
53+
async def get_page(collective_id: int, page_id: int):
54+
"""
55+
Get metadata for a single Collectives page (without the markdown body).
56+
Use get_page_content for the markdown body.
57+
:param collective_id: the id of the collective (obtainable with list_collectives)
58+
:param page_id: the id of the page (obtainable with list_collective_pages)
59+
:return: page metadata including title, emoji, parentId, subpageOrder, tags, lastUserId, timestamp, size
60+
"""
61+
return json.dumps(await nc.ocs('GET', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/{page_id}'))
62+
63+
@tool
64+
@safe_tool
65+
async def get_page_content(collective_id: int, page_id: int):
66+
"""
67+
Get the Markdown content of a Collectives page.
68+
Fetches the underlying .md file via WebDAV. Returns an empty string for pages that have
69+
never been written to (newly created pages materialize their file on first write).
70+
:param collective_id: the id of the collective (obtainable with list_collectives)
71+
:param page_id: the id of the page (obtainable with list_collective_pages)
72+
:return: the markdown content of the page, or empty string if the file has not been written yet
73+
"""
74+
page_resp = await nc.ocs('GET', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/{page_id}')
75+
page = page_resp['page'] if isinstance(page_resp, dict) and 'page' in page_resp else page_resp
76+
user_id = await _user_id()
77+
url = await _page_webdav_url(user_id, page)
78+
response = await nc._session._create_adapter(True).request('GET', url, headers={
79+
'Content-Type': 'application/json',
80+
})
81+
if response.status_code == 404:
82+
return ''
83+
return response.text
84+
85+
@tool
86+
@safe_tool
87+
async def list_page_trash(collective_id: int):
88+
"""
89+
List trashed pages in a Collective. Trashed pages can be restored with restore_page or
90+
removed permanently with delete_page_permanently. Trashed pages are eventually removed by
91+
a background job after an admin-configured retention period.
92+
:param collective_id: the id of the collective (obtainable with list_collectives)
93+
:return: list of trashed pages with id, title, trashTimestamp, parentId, and the rest of the page metadata
94+
"""
95+
return json.dumps(await nc.ocs('GET', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/trash'))
96+
97+
# --- Pages (write) ---
98+
99+
@tool
100+
@dangerous_tool
101+
async def create_page(collective_id: int, parent_id: int, title: str):
102+
"""
103+
Create a new page in a Collective as a child of an existing page.
104+
Use parent_id = landing page id (from list_collective_pages, the page with parentId=0) for
105+
a top-level page. The page is created with an empty body; call update_page_content afterward
106+
to write markdown.
107+
:param collective_id: the id of the collective (obtainable with list_collectives)
108+
:param parent_id: the id of the parent page (obtainable with list_collective_pages)
109+
:param title: the title for the new page
110+
:return: the created page's metadata including its id
111+
"""
112+
return json.dumps(await nc.ocs('POST', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/{parent_id}', json={
113+
'title': title,
114+
}))
115+
116+
@tool
117+
@dangerous_tool
118+
async def update_page_content(collective_id: int, page_id: int, content: str):
119+
"""
120+
Overwrite the Markdown content of a Collectives page.
121+
Replaces the entire page body. To append, first read with get_page_content and concatenate.
122+
If another user has the page open in the real-time editor, their session may overwrite this
123+
write on save - consider rename_page or trash_page for destructive intent instead.
124+
:param collective_id: the id of the collective (obtainable with list_collectives)
125+
:param page_id: the id of the page (obtainable with list_collective_pages)
126+
:param content: the new markdown body for the page (replaces existing content)
127+
:return: success confirmation with the page id
128+
"""
129+
page_resp = await nc.ocs('GET', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/{page_id}')
130+
page = page_resp['page'] if isinstance(page_resp, dict) and 'page' in page_resp else page_resp
131+
user_id = await _user_id()
132+
url = await _page_webdav_url(user_id, page)
133+
await nc._session._create_adapter(True).request('PUT', url, headers={
134+
'Content-Type': 'text/markdown',
135+
}, data=content)
136+
return json.dumps({'status': 'success', 'page_id': page_id})
137+
138+
@tool
139+
@dangerous_tool
140+
async def rename_page(collective_id: int, page_id: int, title: str):
141+
"""
142+
Change the title of a Collectives page. Also renames the underlying .md file on disk.
143+
:param collective_id: the id of the collective (obtainable with list_collectives)
144+
:param page_id: the id of the page (obtainable with list_collective_pages)
145+
:param title: the new title
146+
:return: the updated page metadata
147+
"""
148+
return json.dumps(await nc.ocs('PUT', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/{page_id}', json={
149+
'title': title,
150+
}))
151+
152+
@tool
153+
@dangerous_tool
154+
async def move_page(collective_id: int, page_id: int, parent_id: int):
155+
"""
156+
Move a page under a different parent within the same collective.
157+
Use parent_id = landing page id to move the page to top-level.
158+
:param collective_id: the id of the collective (obtainable with list_collectives)
159+
:param page_id: the id of the page to move (obtainable with list_collective_pages)
160+
:param parent_id: the id of the new parent page (obtainable with list_collective_pages)
161+
:return: the updated page metadata
162+
"""
163+
return json.dumps(await nc.ocs('PUT', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/{page_id}', json={
164+
'parentId': parent_id,
165+
}))
166+
167+
@tool
168+
@dangerous_tool
169+
async def set_page_emoji(collective_id: int, page_id: int, emoji: str):
170+
"""
171+
Set or clear the emoji icon for a Collectives page.
172+
The emoji is displayed in the page tree and title bar. Pass an empty string to clear.
173+
:param collective_id: the id of the collective (obtainable with list_collectives)
174+
:param page_id: the id of the page (obtainable with list_collective_pages)
175+
:param emoji: a single emoji character (e.g. "📝"), or empty string to clear
176+
:return: the updated page metadata
177+
"""
178+
return json.dumps(await nc.ocs('PUT', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/{page_id}/emoji', json={
179+
'emoji': emoji,
180+
}))
181+
182+
@tool
183+
@dangerous_tool
184+
async def trash_page(collective_id: int, page_id: int):
185+
"""
186+
Soft-delete a page by moving it to the collective's page trash.
187+
Trashed pages can be restored with restore_page until a background job purges them after
188+
the admin-configured retention period. Use delete_page_permanently on a trashed page to
189+
remove it immediately.
190+
:param collective_id: the id of the collective (obtainable with list_collectives)
191+
:param page_id: the id of the page (obtainable with list_collective_pages)
192+
:return: the trashed page metadata with trashTimestamp set
193+
"""
194+
return json.dumps(await nc.ocs('DELETE', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/{page_id}'))
195+
196+
@tool
197+
@dangerous_tool
198+
async def restore_page(collective_id: int, page_id: int):
199+
"""
200+
Restore a previously trashed page back to the collective.
201+
:param collective_id: the id of the collective (obtainable with list_collectives)
202+
:param page_id: the id of the trashed page (obtainable with list_page_trash)
203+
:return: the restored page metadata with trashTimestamp cleared
204+
"""
205+
return json.dumps(await nc.ocs('PATCH', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/trash/{page_id}'))
206+
207+
@tool
208+
@dangerous_tool
209+
async def delete_page_permanently(collective_id: int, page_id: int):
210+
"""
211+
Permanently delete a page that is already in the trash. This cannot be undone.
212+
To delete a live page, call trash_page first, then this tool.
213+
:param collective_id: the id of the collective (obtainable with list_collectives)
214+
:param page_id: the id of the trashed page (obtainable with list_page_trash)
215+
:return: confirmation of permanent deletion
216+
"""
217+
return json.dumps(await nc.ocs('DELETE', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/trash/{page_id}'))
218+
219+
return [
220+
list_collectives,
221+
list_collective_pages,
222+
get_page,
223+
get_page_content,
224+
list_page_trash,
225+
create_page,
226+
update_page_content,
227+
rename_page,
228+
move_page,
229+
set_page_emoji,
230+
trash_page,
231+
restore_page,
232+
delete_page_permanently,
233+
]
234+
235+
236+
def get_category_name():
237+
return "Collectives"
238+
239+
240+
async def is_available(nc: AsyncNextcloudApp):
241+
try:
242+
await nc.ocs('GET', '/ocs/v2.php/apps/collectives/api/v1.0/collectives')
243+
except:
244+
return False
245+
return True

0 commit comments

Comments
 (0)