Skip to content

Commit 69d2e1f

Browse files
authored
Merge pull request #159 from Pavlinchen/feat/collectives-support
feat(collectives): Add Collectives support (13 tools for page management)
2 parents 865a01a + be856f5 commit 69d2e1f

1 file changed

Lines changed: 286 additions & 0 deletions

File tree

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
2+
# SPDX-License-Identifier: AGPL-3.0-or-later
3+
import json
4+
import re
5+
from urllib.parse import quote
6+
from langchain_core.tools import tool
7+
from nc_py_api import AsyncNextcloudApp
8+
9+
from ex_app.lib.all_tools.lib.decorator import safe_tool, dangerous_tool
10+
11+
# Unlike the other write tools, which append their AI note to a value they create,
12+
# update_page_content replaces a whole page the agent usually read back first - so the
13+
# note has to be stripped before it is re-appended, or it stacks once per edit.
14+
_AI_DISCLAIMER = '> ℹ️ **This page was edited with the help of Nextcloud AI Assistant.**'
15+
16+
# Matches only our own note, on a line of its own, together with the newlines around it.
17+
# Anchored to line starts so a blockquote elsewhere on the page is never touched, and
18+
# tolerant of the emoji or its variation selector being dropped and of the emphasis
19+
# markers being backslash-escaped, since the page may come back through a markdown
20+
# serializer between two edits.
21+
_DISCLAIMER_RE = re.compile(
22+
r'(?P<before>\n*)'
23+
r'^[ \t]*>[ \t]*(?:\u2139\ufe0f?[ \t]*)?\\?\*\\?\*'
24+
r'This page was edited with the help of Nextcloud AI Assistant\.?'
25+
r'\\?\*\\?\*[ \t]*\r?$'
26+
r'(?P<after>\n*)',
27+
re.MULTILINE,
28+
)
29+
30+
31+
def _close_gap(match) -> str:
32+
# Removing the note leaves the blank lines from both of its sides stacked together.
33+
# Rejoin with one blank line when it sat between two blocks, and with nothing when it
34+
# sat at the very start or end. Only this seam is touched: blank runs elsewhere on the
35+
# page are left alone, since they are significant inside fenced code blocks.
36+
if match.group('before') and match.group('after'):
37+
return '\n\n'
38+
return ''
39+
40+
41+
def _strip_ai_disclaimer(markdown: str) -> str:
42+
return _DISCLAIMER_RE.sub(_close_gap, markdown)
43+
44+
45+
async def get_tools(nc: AsyncNextcloudApp):
46+
47+
async def _page_webdav_url(user_id: str, page: dict) -> str:
48+
# A page's markdown file lives at:
49+
# /remote.php/dav/files/{user}/{collectivePath}/{filePath}/{fileName}
50+
# filePath is empty for top-level pages.
51+
parts = [page['collectivePath'], page.get('filePath') or '', page['fileName']]
52+
segments = '/'.join(p for p in parts if p).split('/')
53+
encoded = [quote(s, safe='') for s in segments if s]
54+
return f"{nc.app_cfg.endpoint}/remote.php/dav/files/{user_id}/{'/'.join(encoded)}"
55+
56+
# --- Collectives ---
57+
58+
@tool
59+
@safe_tool
60+
async def list_collectives():
61+
"""
62+
List all Collectives (wiki-like knowledge bases) the current user is a member of.
63+
Each collective contains pages of Markdown content, organized in a tree.
64+
:return: list of collectives with id, name, emoji, slug, and the user's permissions (canEdit, canShare)
65+
"""
66+
return json.dumps(await nc.ocs('GET', '/ocs/v2.php/apps/collectives/api/v1.0/collectives'))
67+
68+
# --- Pages (read) ---
69+
70+
@tool
71+
@safe_tool
72+
async def list_collective_pages(collective_id: int):
73+
"""
74+
List all pages in a Collective as a flat list with tree information.
75+
Pages form a tree via parentId (0 = top-level / landing page). Each page has an id needed
76+
by every other page tool, a title, and metadata (emoji, tags, last editor, trashed status).
77+
Markdown content is not included - fetch it with get_page_content.
78+
:param collective_id: the id of the collective (obtainable with list_collectives)
79+
:return: list of pages with id, title, emoji, parentId, subpageOrder, tags, lastUserId, timestamp, size, trashTimestamp
80+
"""
81+
return json.dumps(await nc.ocs('GET', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages'))
82+
83+
@tool
84+
@safe_tool
85+
async def get_page(collective_id: int, page_id: int):
86+
"""
87+
Get metadata for a single Collectives page (without the markdown body).
88+
Use get_page_content for the markdown body.
89+
:param collective_id: the id of the collective (obtainable with list_collectives)
90+
:param page_id: the id of the page (obtainable with list_collective_pages)
91+
:return: page metadata including title, emoji, parentId, subpageOrder, tags, lastUserId, timestamp, size
92+
"""
93+
return json.dumps(await nc.ocs('GET', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/{page_id}'))
94+
95+
@tool
96+
@safe_tool
97+
async def get_page_content(collective_id: int, page_id: int):
98+
"""
99+
Get the Markdown content of a Collectives page.
100+
Fetches the underlying .md file via WebDAV. Returns an empty string for pages that have
101+
never been written to (newly created pages materialize their file on first write).
102+
Pages last written by update_page_content end with its AI-authored note; that note is
103+
part of the returned content and is replaced, not duplicated, on the next write.
104+
:param collective_id: the id of the collective (obtainable with list_collectives)
105+
:param page_id: the id of the page (obtainable with list_collective_pages)
106+
:return: the markdown content of the page, or empty string if the file has not been written yet
107+
"""
108+
page_resp = await nc.ocs('GET', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/{page_id}')
109+
page = page_resp['page'] if isinstance(page_resp, dict) and 'page' in page_resp else page_resp
110+
user_id = await nc.user
111+
url = await _page_webdav_url(user_id, page)
112+
response = await nc._session._create_adapter(True).request('GET', url, headers={
113+
'Accept': 'text/markdown',
114+
})
115+
if response.status_code == 404:
116+
return ''
117+
response.raise_for_status()
118+
return response.text
119+
120+
@tool
121+
@safe_tool
122+
async def list_page_trash(collective_id: int):
123+
"""
124+
List trashed pages in a Collective. Trashed pages can be restored with restore_page or
125+
removed permanently with delete_page_permanently. Trashed pages are eventually removed by
126+
a background job after an admin-configured retention period.
127+
:param collective_id: the id of the collective (obtainable with list_collectives)
128+
:return: list of trashed pages with id, title, trashTimestamp, parentId, and the rest of the page metadata
129+
"""
130+
return json.dumps(await nc.ocs('GET', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/trash'))
131+
132+
# --- Pages (write) ---
133+
134+
@tool
135+
@dangerous_tool
136+
async def create_page(collective_id: int, parent_id: int, title: str):
137+
"""
138+
Create a new page in a Collective as a child of an existing page.
139+
Use parent_id = landing page id (from list_collective_pages, the page with parentId=0) for
140+
a top-level page. The page is created with an empty body; call update_page_content afterward
141+
to write markdown.
142+
:param collective_id: the id of the collective (obtainable with list_collectives)
143+
:param parent_id: the id of the parent page (obtainable with list_collective_pages)
144+
:param title: the title for the new page
145+
:return: the created page's metadata including its id
146+
"""
147+
return json.dumps(await nc.ocs('POST', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/{parent_id}', json={
148+
'title': title,
149+
}))
150+
151+
@tool
152+
@dangerous_tool
153+
async def update_page_content(collective_id: int, page_id: int, content: str):
154+
"""
155+
Overwrite the Markdown content of a Collectives page.
156+
Replaces the entire page body. To append, first read with get_page_content and concatenate.
157+
If another user has the page open in the real-time editor, their session may overwrite this
158+
write on save - consider rename_page or trash_page for destructive intent instead.
159+
A note saying the page was edited with the help of the AI Assistant is appended
160+
automatically - do not write one yourself, and leave any existing one in the content
161+
you pass; it is replaced rather than duplicated.
162+
:param collective_id: the id of the collective (obtainable with list_collectives)
163+
:param page_id: the id of the page (obtainable with list_collective_pages)
164+
:param content: the new markdown body for the page (replaces existing content)
165+
:return: success confirmation with the page id
166+
"""
167+
page_resp = await nc.ocs('GET', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/{page_id}')
168+
page = page_resp['page'] if isinstance(page_resp, dict) and 'page' in page_resp else page_resp
169+
user_id = await nc.user
170+
url = await _page_webdav_url(user_id, page)
171+
body = _strip_ai_disclaimer(content).rstrip()
172+
stamped = f"{body}\n\n{_AI_DISCLAIMER}\n" if body else f"{_AI_DISCLAIMER}\n"
173+
response = await nc._session._create_adapter(True).request('PUT', url, headers={
174+
'Content-Type': 'text/markdown',
175+
}, data=stamped)
176+
response.raise_for_status()
177+
return json.dumps({'status': 'success', 'page_id': page_id})
178+
179+
@tool
180+
@dangerous_tool
181+
async def rename_page(collective_id: int, page_id: int, title: str):
182+
"""
183+
Change the title of a Collectives page. Also renames the underlying .md file on disk.
184+
:param collective_id: the id of the collective (obtainable with list_collectives)
185+
:param page_id: the id of the page (obtainable with list_collective_pages)
186+
:param title: the new title
187+
:return: the updated page metadata
188+
"""
189+
return json.dumps(await nc.ocs('PUT', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/{page_id}', json={
190+
'title': title,
191+
}))
192+
193+
@tool
194+
@dangerous_tool
195+
async def move_page(collective_id: int, page_id: int, parent_id: int):
196+
"""
197+
Move a page under a different parent within the same collective.
198+
Use parent_id = landing page id to move the page to top-level.
199+
:param collective_id: the id of the collective (obtainable with list_collectives)
200+
:param page_id: the id of the page to move (obtainable with list_collective_pages)
201+
:param parent_id: the id of the new parent page (obtainable with list_collective_pages)
202+
:return: the updated page metadata
203+
"""
204+
return json.dumps(await nc.ocs('PUT', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/{page_id}', json={
205+
'parentId': parent_id,
206+
}))
207+
208+
@tool
209+
@dangerous_tool
210+
async def set_page_emoji(collective_id: int, page_id: int, emoji: str):
211+
"""
212+
Set or clear the emoji icon for a Collectives page.
213+
The emoji is displayed in the page tree and title bar. Pass an empty string to clear.
214+
:param collective_id: the id of the collective (obtainable with list_collectives)
215+
:param page_id: the id of the page (obtainable with list_collective_pages)
216+
:param emoji: a single emoji character (e.g. "📝"), or empty string to clear
217+
:return: the updated page metadata
218+
"""
219+
return json.dumps(await nc.ocs('PUT', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/{page_id}/emoji', json={
220+
'emoji': emoji,
221+
}))
222+
223+
@tool
224+
@dangerous_tool
225+
async def trash_page(collective_id: int, page_id: int):
226+
"""
227+
Soft-delete a page by moving it to the collective's page trash.
228+
Trashed pages can be restored with restore_page until a background job purges them after
229+
the admin-configured retention period. Use delete_page_permanently on a trashed page to
230+
remove it immediately.
231+
:param collective_id: the id of the collective (obtainable with list_collectives)
232+
:param page_id: the id of the page (obtainable with list_collective_pages)
233+
:return: the trashed page metadata with trashTimestamp set
234+
"""
235+
return json.dumps(await nc.ocs('DELETE', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/{page_id}'))
236+
237+
@tool
238+
@dangerous_tool
239+
async def restore_page(collective_id: int, page_id: int):
240+
"""
241+
Restore a previously trashed page back to the collective.
242+
:param collective_id: the id of the collective (obtainable with list_collectives)
243+
:param page_id: the id of the trashed page (obtainable with list_page_trash)
244+
:return: the restored page metadata with trashTimestamp cleared
245+
"""
246+
return json.dumps(await nc.ocs('PATCH', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/trash/{page_id}'))
247+
248+
@tool
249+
@dangerous_tool
250+
async def delete_page_permanently(collective_id: int, page_id: int):
251+
"""
252+
Permanently delete a page that is already in the trash. This cannot be undone.
253+
To delete a live page, call trash_page first, then this tool.
254+
:param collective_id: the id of the collective (obtainable with list_collectives)
255+
:param page_id: the id of the trashed page (obtainable with list_page_trash)
256+
:return: confirmation of permanent deletion
257+
"""
258+
return json.dumps(await nc.ocs('DELETE', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/trash/{page_id}'))
259+
260+
return [
261+
list_collectives,
262+
list_collective_pages,
263+
get_page,
264+
get_page_content,
265+
list_page_trash,
266+
create_page,
267+
update_page_content,
268+
rename_page,
269+
move_page,
270+
set_page_emoji,
271+
trash_page,
272+
restore_page,
273+
delete_page_permanently,
274+
]
275+
276+
277+
def get_category_name():
278+
return "Collectives"
279+
280+
281+
async def is_available(nc: AsyncNextcloudApp):
282+
try:
283+
await nc.ocs('GET', '/ocs/v2.php/apps/collectives/api/v1.0/collectives')
284+
except Exception:
285+
return False
286+
return True

0 commit comments

Comments
 (0)