-
Notifications
You must be signed in to change notification settings - Fork 15
feat(collectives): Add Collectives support (13 tools for page management) #159
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
marcelklehr
merged 6 commits into
nextcloud:main
from
Pavlinchen:feat/collectives-support
Sep 7, 2026
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
c8648bf
feat(collectives): add Collectives support for page management
Pavlinchen 3d764e2
fix(collectives): Make update_page_content ensure there is an AI-edit…
marcelklehr 2df6b3d
fix(collectives): let update_page_content append the AI-edit note itself
Pavlinchen 9dd7962
fix(collectives): surface failed requests instead of passing them on
Pavlinchen b965b77
refactor(collectives): read the user id from the session instead of OCS
Pavlinchen be856f5
fix(collectives): encode DAV path segments individually, send Accept …
Pavlinchen File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,286 @@ | ||
| # SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors | ||
| # SPDX-License-Identifier: AGPL-3.0-or-later | ||
| import json | ||
| import re | ||
| from urllib.parse import quote | ||
| from langchain_core.tools import tool | ||
| from nc_py_api import AsyncNextcloudApp | ||
|
|
||
| from ex_app.lib.all_tools.lib.decorator import safe_tool, dangerous_tool | ||
|
|
||
| # Unlike the other write tools, which append their AI note to a value they create, | ||
| # update_page_content replaces a whole page the agent usually read back first - so the | ||
| # note has to be stripped before it is re-appended, or it stacks once per edit. | ||
| _AI_DISCLAIMER = '> ℹ️ **This page was edited with the help of Nextcloud AI Assistant.**' | ||
|
|
||
| # Matches only our own note, on a line of its own, together with the newlines around it. | ||
| # Anchored to line starts so a blockquote elsewhere on the page is never touched, and | ||
| # tolerant of the emoji or its variation selector being dropped and of the emphasis | ||
| # markers being backslash-escaped, since the page may come back through a markdown | ||
| # serializer between two edits. | ||
| _DISCLAIMER_RE = re.compile( | ||
| r'(?P<before>\n*)' | ||
| r'^[ \t]*>[ \t]*(?:\u2139\ufe0f?[ \t]*)?\\?\*\\?\*' | ||
| r'This page was edited with the help of Nextcloud AI Assistant\.?' | ||
| r'\\?\*\\?\*[ \t]*\r?$' | ||
| r'(?P<after>\n*)', | ||
| re.MULTILINE, | ||
| ) | ||
|
|
||
|
|
||
| def _close_gap(match) -> str: | ||
| # Removing the note leaves the blank lines from both of its sides stacked together. | ||
| # Rejoin with one blank line when it sat between two blocks, and with nothing when it | ||
| # sat at the very start or end. Only this seam is touched: blank runs elsewhere on the | ||
| # page are left alone, since they are significant inside fenced code blocks. | ||
| if match.group('before') and match.group('after'): | ||
| return '\n\n' | ||
| return '' | ||
|
|
||
|
|
||
| def _strip_ai_disclaimer(markdown: str) -> str: | ||
| return _DISCLAIMER_RE.sub(_close_gap, markdown) | ||
|
|
||
|
|
||
| async def get_tools(nc: AsyncNextcloudApp): | ||
|
|
||
| async def _page_webdav_url(user_id: str, page: dict) -> str: | ||
| # A page's markdown file lives at: | ||
| # /remote.php/dav/files/{user}/{collectivePath}/{filePath}/{fileName} | ||
| # filePath is empty for top-level pages. | ||
| parts = [page['collectivePath'], page.get('filePath') or '', page['fileName']] | ||
| segments = '/'.join(p for p in parts if p).split('/') | ||
| encoded = [quote(s, safe='') for s in segments if s] | ||
| return f"{nc.app_cfg.endpoint}/remote.php/dav/files/{user_id}/{'/'.join(encoded)}" | ||
|
marcelklehr marked this conversation as resolved.
|
||
|
|
||
| # --- Collectives --- | ||
|
|
||
| @tool | ||
| @safe_tool | ||
| async def list_collectives(): | ||
| """ | ||
| List all Collectives (wiki-like knowledge bases) the current user is a member of. | ||
| Each collective contains pages of Markdown content, organized in a tree. | ||
| :return: list of collectives with id, name, emoji, slug, and the user's permissions (canEdit, canShare) | ||
| """ | ||
| return json.dumps(await nc.ocs('GET', '/ocs/v2.php/apps/collectives/api/v1.0/collectives')) | ||
|
|
||
| # --- Pages (read) --- | ||
|
|
||
| @tool | ||
| @safe_tool | ||
| async def list_collective_pages(collective_id: int): | ||
| """ | ||
| List all pages in a Collective as a flat list with tree information. | ||
| Pages form a tree via parentId (0 = top-level / landing page). Each page has an id needed | ||
| by every other page tool, a title, and metadata (emoji, tags, last editor, trashed status). | ||
| Markdown content is not included - fetch it with get_page_content. | ||
| :param collective_id: the id of the collective (obtainable with list_collectives) | ||
| :return: list of pages with id, title, emoji, parentId, subpageOrder, tags, lastUserId, timestamp, size, trashTimestamp | ||
| """ | ||
| return json.dumps(await nc.ocs('GET', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages')) | ||
|
|
||
| @tool | ||
| @safe_tool | ||
| async def get_page(collective_id: int, page_id: int): | ||
| """ | ||
| Get metadata for a single Collectives page (without the markdown body). | ||
| Use get_page_content for the markdown body. | ||
| :param collective_id: the id of the collective (obtainable with list_collectives) | ||
| :param page_id: the id of the page (obtainable with list_collective_pages) | ||
| :return: page metadata including title, emoji, parentId, subpageOrder, tags, lastUserId, timestamp, size | ||
| """ | ||
| return json.dumps(await nc.ocs('GET', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/{page_id}')) | ||
|
|
||
| @tool | ||
| @safe_tool | ||
| async def get_page_content(collective_id: int, page_id: int): | ||
| """ | ||
| Get the Markdown content of a Collectives page. | ||
| Fetches the underlying .md file via WebDAV. Returns an empty string for pages that have | ||
| never been written to (newly created pages materialize their file on first write). | ||
| Pages last written by update_page_content end with its AI-authored note; that note is | ||
| part of the returned content and is replaced, not duplicated, on the next write. | ||
| :param collective_id: the id of the collective (obtainable with list_collectives) | ||
| :param page_id: the id of the page (obtainable with list_collective_pages) | ||
| :return: the markdown content of the page, or empty string if the file has not been written yet | ||
| """ | ||
| page_resp = await nc.ocs('GET', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/{page_id}') | ||
| page = page_resp['page'] if isinstance(page_resp, dict) and 'page' in page_resp else page_resp | ||
| user_id = await nc.user | ||
| url = await _page_webdav_url(user_id, page) | ||
| response = await nc._session._create_adapter(True).request('GET', url, headers={ | ||
| 'Accept': 'text/markdown', | ||
| }) | ||
| if response.status_code == 404: | ||
| return '' | ||
| response.raise_for_status() | ||
| return response.text | ||
|
|
||
| @tool | ||
| @safe_tool | ||
| async def list_page_trash(collective_id: int): | ||
| """ | ||
| List trashed pages in a Collective. Trashed pages can be restored with restore_page or | ||
| removed permanently with delete_page_permanently. Trashed pages are eventually removed by | ||
| a background job after an admin-configured retention period. | ||
| :param collective_id: the id of the collective (obtainable with list_collectives) | ||
| :return: list of trashed pages with id, title, trashTimestamp, parentId, and the rest of the page metadata | ||
| """ | ||
| return json.dumps(await nc.ocs('GET', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/trash')) | ||
|
|
||
| # --- Pages (write) --- | ||
|
|
||
| @tool | ||
| @dangerous_tool | ||
| async def create_page(collective_id: int, parent_id: int, title: str): | ||
| """ | ||
| Create a new page in a Collective as a child of an existing page. | ||
| Use parent_id = landing page id (from list_collective_pages, the page with parentId=0) for | ||
| a top-level page. The page is created with an empty body; call update_page_content afterward | ||
| to write markdown. | ||
| :param collective_id: the id of the collective (obtainable with list_collectives) | ||
| :param parent_id: the id of the parent page (obtainable with list_collective_pages) | ||
| :param title: the title for the new page | ||
| :return: the created page's metadata including its id | ||
| """ | ||
| return json.dumps(await nc.ocs('POST', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/{parent_id}', json={ | ||
| 'title': title, | ||
| })) | ||
|
|
||
| @tool | ||
| @dangerous_tool | ||
| async def update_page_content(collective_id: int, page_id: int, content: str): | ||
|
marcelklehr marked this conversation as resolved.
|
||
| """ | ||
| Overwrite the Markdown content of a Collectives page. | ||
| Replaces the entire page body. To append, first read with get_page_content and concatenate. | ||
| If another user has the page open in the real-time editor, their session may overwrite this | ||
| write on save - consider rename_page or trash_page for destructive intent instead. | ||
| A note saying the page was edited with the help of the AI Assistant is appended | ||
| automatically - do not write one yourself, and leave any existing one in the content | ||
| you pass; it is replaced rather than duplicated. | ||
|
marcelklehr marked this conversation as resolved.
|
||
| :param collective_id: the id of the collective (obtainable with list_collectives) | ||
| :param page_id: the id of the page (obtainable with list_collective_pages) | ||
| :param content: the new markdown body for the page (replaces existing content) | ||
| :return: success confirmation with the page id | ||
| """ | ||
| page_resp = await nc.ocs('GET', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/{page_id}') | ||
| page = page_resp['page'] if isinstance(page_resp, dict) and 'page' in page_resp else page_resp | ||
| user_id = await nc.user | ||
| url = await _page_webdav_url(user_id, page) | ||
| body = _strip_ai_disclaimer(content).rstrip() | ||
| stamped = f"{body}\n\n{_AI_DISCLAIMER}\n" if body else f"{_AI_DISCLAIMER}\n" | ||
|
marcelklehr marked this conversation as resolved.
marcelklehr marked this conversation as resolved.
|
||
| response = await nc._session._create_adapter(True).request('PUT', url, headers={ | ||
| 'Content-Type': 'text/markdown', | ||
| }, data=stamped) | ||
| response.raise_for_status() | ||
| return json.dumps({'status': 'success', 'page_id': page_id}) | ||
|
|
||
| @tool | ||
| @dangerous_tool | ||
| async def rename_page(collective_id: int, page_id: int, title: str): | ||
| """ | ||
| Change the title of a Collectives page. Also renames the underlying .md file on disk. | ||
| :param collective_id: the id of the collective (obtainable with list_collectives) | ||
| :param page_id: the id of the page (obtainable with list_collective_pages) | ||
| :param title: the new title | ||
| :return: the updated page metadata | ||
| """ | ||
| return json.dumps(await nc.ocs('PUT', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/{page_id}', json={ | ||
| 'title': title, | ||
| })) | ||
|
|
||
| @tool | ||
| @dangerous_tool | ||
| async def move_page(collective_id: int, page_id: int, parent_id: int): | ||
| """ | ||
| Move a page under a different parent within the same collective. | ||
| Use parent_id = landing page id to move the page to top-level. | ||
| :param collective_id: the id of the collective (obtainable with list_collectives) | ||
| :param page_id: the id of the page to move (obtainable with list_collective_pages) | ||
| :param parent_id: the id of the new parent page (obtainable with list_collective_pages) | ||
| :return: the updated page metadata | ||
| """ | ||
| return json.dumps(await nc.ocs('PUT', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/{page_id}', json={ | ||
| 'parentId': parent_id, | ||
| })) | ||
|
marcelklehr marked this conversation as resolved.
|
||
|
|
||
| @tool | ||
| @dangerous_tool | ||
| async def set_page_emoji(collective_id: int, page_id: int, emoji: str): | ||
| """ | ||
| Set or clear the emoji icon for a Collectives page. | ||
| The emoji is displayed in the page tree and title bar. Pass an empty string to clear. | ||
| :param collective_id: the id of the collective (obtainable with list_collectives) | ||
| :param page_id: the id of the page (obtainable with list_collective_pages) | ||
| :param emoji: a single emoji character (e.g. "📝"), or empty string to clear | ||
| :return: the updated page metadata | ||
| """ | ||
| return json.dumps(await nc.ocs('PUT', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/{page_id}/emoji', json={ | ||
| 'emoji': emoji, | ||
| })) | ||
|
|
||
| @tool | ||
| @dangerous_tool | ||
| async def trash_page(collective_id: int, page_id: int): | ||
| """ | ||
| Soft-delete a page by moving it to the collective's page trash. | ||
| Trashed pages can be restored with restore_page until a background job purges them after | ||
| the admin-configured retention period. Use delete_page_permanently on a trashed page to | ||
| remove it immediately. | ||
| :param collective_id: the id of the collective (obtainable with list_collectives) | ||
| :param page_id: the id of the page (obtainable with list_collective_pages) | ||
| :return: the trashed page metadata with trashTimestamp set | ||
| """ | ||
| return json.dumps(await nc.ocs('DELETE', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/{page_id}')) | ||
|
|
||
| @tool | ||
| @dangerous_tool | ||
| async def restore_page(collective_id: int, page_id: int): | ||
| """ | ||
| Restore a previously trashed page back to the collective. | ||
| :param collective_id: the id of the collective (obtainable with list_collectives) | ||
| :param page_id: the id of the trashed page (obtainable with list_page_trash) | ||
| :return: the restored page metadata with trashTimestamp cleared | ||
| """ | ||
| return json.dumps(await nc.ocs('PATCH', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/trash/{page_id}')) | ||
|
|
||
| @tool | ||
| @dangerous_tool | ||
| async def delete_page_permanently(collective_id: int, page_id: int): | ||
| """ | ||
| Permanently delete a page that is already in the trash. This cannot be undone. | ||
| To delete a live page, call trash_page first, then this tool. | ||
| :param collective_id: the id of the collective (obtainable with list_collectives) | ||
| :param page_id: the id of the trashed page (obtainable with list_page_trash) | ||
| :return: confirmation of permanent deletion | ||
| """ | ||
| return json.dumps(await nc.ocs('DELETE', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/trash/{page_id}')) | ||
|
|
||
| return [ | ||
| list_collectives, | ||
| list_collective_pages, | ||
| get_page, | ||
| get_page_content, | ||
| list_page_trash, | ||
| create_page, | ||
| update_page_content, | ||
| rename_page, | ||
| move_page, | ||
| set_page_emoji, | ||
| trash_page, | ||
| restore_page, | ||
| delete_page_permanently, | ||
| ] | ||
|
|
||
|
|
||
| def get_category_name(): | ||
| return "Collectives" | ||
|
|
||
|
|
||
| async def is_available(nc: AsyncNextcloudApp): | ||
| try: | ||
| await nc.ocs('GET', '/ocs/v2.php/apps/collectives/api/v1.0/collectives') | ||
| except Exception: | ||
| return False | ||
| return True | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.