|
| 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