|
| 1 | +# SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors |
| 2 | +# SPDX-License-Identifier: AGPL-3.0-or-later |
| 3 | +import re |
| 4 | +from urllib.parse import parse_qs, unquote, urlparse |
| 5 | + |
| 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 |
| 10 | + |
| 11 | +# Hint telling the agent which tools can act on the parsed entity. |
| 12 | +_TOOL_HINTS = { |
| 13 | + 'files': 'Use get_file_content_by_file_link / get_file_path_by_id with the file_id.', |
| 14 | + 'talk': 'Use the Talk tools; resolve the token via list_talk_conversations (match on token).', |
| 15 | + 'collectives': 'Collective pages are Markdown files; if a file_id is present use the Files tools, otherwise open the collective by name.', |
| 16 | + 'deck': 'Use the Deck tools with board_id / card_id.', |
| 17 | + 'mail': 'Use the Mail tools; mailbox_id maps to a folder_id, thread_id identifies the conversation.', |
| 18 | + 'calendar': 'Use the Calendar tools; the object is identified by calendar + object href/token.', |
| 19 | + 'bookmarks': 'Use the Bookmarks tools; filter by folder_id if present.', |
| 20 | + 'cookbook': 'Use get_recipe_details with recipe_id.', |
| 21 | + 'forms': 'Forms are identified by a share hash; call list_forms to map the hash to a form_id.', |
| 22 | + 'tables': 'Use the Tables tools with table_id (or list rows of the view_id).', |
| 23 | +} |
| 24 | + |
| 25 | + |
| 26 | +def _clean_path(path: str) -> str: |
| 27 | + """Strip the optional /index.php prefix from a Nextcloud path.""" |
| 28 | + return re.sub(r'^/index\.php', '', path or '') |
| 29 | + |
| 30 | + |
| 31 | +def _int(value): |
| 32 | + try: |
| 33 | + return int(value) |
| 34 | + except (TypeError, ValueError): |
| 35 | + return value |
| 36 | + |
| 37 | + |
| 38 | +def _first(query: dict, *keys): |
| 39 | + """Return the first value of the first present key in a parsed query string.""" |
| 40 | + for key in keys: |
| 41 | + if key in query and query[key]: |
| 42 | + return query[key][0] |
| 43 | + return None |
| 44 | + |
| 45 | + |
| 46 | +def _parse_nextcloud_url(url: str) -> dict: |
| 47 | + """ |
| 48 | + Parse a Nextcloud deep-link URL into its app, entity type and identifiers. |
| 49 | +
|
| 50 | + Returns a dict with at least ``app`` and ``entity_type`` and, when found, an |
| 51 | + ``ids`` mapping. ``entity_type`` is ``None`` (and app may be ``unknown``) when |
| 52 | + the URL does not match any known Nextcloud app route. |
| 53 | + """ |
| 54 | + if not isinstance(url, str) or not url.strip(): |
| 55 | + raise ValueError('A non-empty URL string is required') |
| 56 | + |
| 57 | + parsed = urlparse(url.strip()) |
| 58 | + path = _clean_path(unquote(parsed.path or '')) |
| 59 | + fragment = unquote(parsed.fragment or '') |
| 60 | + query = parse_qs(parsed.query or '') |
| 61 | + # Hash-router apps (deck, tables, cookbook, ...) carry the real route in the |
| 62 | + # fragment, e.g. /apps/tables#/table/3 . Search path and fragment together. |
| 63 | + hay = f'{path}#{fragment}' |
| 64 | + |
| 65 | + result = {'app': 'unknown', 'entity_type': None, 'ids': {}, 'url': url} |
| 66 | + |
| 67 | + def done(app, entity_type, ids=None, **extra): |
| 68 | + result.update(app=app, entity_type=entity_type, ids={k: v for k, v in (ids or {}).items() if v is not None}) |
| 69 | + result.update(extra) |
| 70 | + hint = _TOOL_HINTS.get(app) |
| 71 | + if hint: |
| 72 | + result['hint'] = hint |
| 73 | + return result |
| 74 | + |
| 75 | + # --- Files (files) ------------------------------------------------------ |
| 76 | + # https://host/f/123 | /index.php/f/123 | ?fileid=123 / ?openfile=123 |
| 77 | + m = re.search(r'/f/(\d+)/?$', path) |
| 78 | + if m: |
| 79 | + return done('files', 'file', {'file_id': _int(m.group(1))}) |
| 80 | + file_id = _first(query, 'fileid', 'fileId', 'openfile') |
| 81 | + |
| 82 | + # public share of a file/folder: /s/{token} |
| 83 | + m = re.search(r'/s/([A-Za-z0-9]+)/?$', path) |
| 84 | + if m and not path.startswith('/apps/'): |
| 85 | + return done('files', 'public_share', {'token': m.group(1)}) |
| 86 | + |
| 87 | + # --- Talk (spreed) ------------------------------------------------------ |
| 88 | + # /call/{token} optionally #message_{id} |
| 89 | + m = re.search(r'/(?:apps/spreed/)?call/([A-Za-z0-9]+)', path) |
| 90 | + if m: |
| 91 | + msg = re.search(r'message_(\d+)', fragment) |
| 92 | + return done('talk', 'conversation', {'token': m.group(1)}, |
| 93 | + message_id=_int(msg.group(1)) if msg else None) |
| 94 | + |
| 95 | + # --- App-scoped routes: /apps/{app}/... -------------------------------- |
| 96 | + app_match = re.match(r'^/apps/([^/?#]+)(/.*)?$', path) |
| 97 | + app = app_match.group(1) if app_match else None |
| 98 | + app_rest = (app_match.group(2) if app_match else '') or '' |
| 99 | + scope = f'{app_rest}#{fragment}' # everything after the app name |
| 100 | + |
| 101 | + if app == 'collectives': |
| 102 | + # /apps/collectives/{Collective Name}/{Page/Sub Page}?fileId=123 |
| 103 | + segments = [s for s in app_rest.split('/') if s] |
| 104 | + collective = segments[0] if segments else None |
| 105 | + page_path = '/'.join(segments[1:]) or None |
| 106 | + return done('collectives', 'page' if page_path else 'collective', { |
| 107 | + 'collective': collective, |
| 108 | + 'page_path': page_path, |
| 109 | + 'file_id': _int(file_id) if file_id else None, |
| 110 | + }) |
| 111 | + |
| 112 | + if app == 'deck': |
| 113 | + board = re.search(r'board/(\d+)', scope) |
| 114 | + card = re.search(r'card/(\d+)', scope) |
| 115 | + if card and board: |
| 116 | + return done('deck', 'card', {'board_id': _int(board.group(1)), 'card_id': _int(card.group(1))}) |
| 117 | + if card: |
| 118 | + return done('deck', 'card', {'card_id': _int(card.group(1))}) |
| 119 | + if board: |
| 120 | + return done('deck', 'board', {'board_id': _int(board.group(1))}) |
| 121 | + return done('deck', None) |
| 122 | + |
| 123 | + if app == 'mail': |
| 124 | + box = re.search(r'box/(\d+)', scope) |
| 125 | + thread = re.search(r'thread/(\d+)', scope) |
| 126 | + if thread: |
| 127 | + return done('mail', 'thread', {'mailbox_id': _int(box.group(1)) if box else None, |
| 128 | + 'thread_id': _int(thread.group(1))}) |
| 129 | + if box: |
| 130 | + return done('mail', 'mailbox', {'mailbox_id': _int(box.group(1))}) |
| 131 | + return done('mail', None) |
| 132 | + |
| 133 | + if app == 'calendar': |
| 134 | + public = re.search(r'/p/([A-Za-z0-9]+)', app_rest) |
| 135 | + if public: |
| 136 | + return done('calendar', 'public_share', {'token': public.group(1)}) |
| 137 | + # .../{view}/{date}/edit/sidebar/{objectId}/{recurrenceId} |
| 138 | + obj = re.search(r'/edit/[^/]+/([^/?#]+)(?:/([^/?#]+))?', scope) |
| 139 | + if obj: |
| 140 | + return done('calendar', 'event', {'object_id': obj.group(1), 'recurrence_id': obj.group(2)}, |
| 141 | + note='object_id is a base64-encoded "<calendarId>/<filename>.ics" reference') |
| 142 | + date = re.search(r'/(\d{4}-\d{2}-\d{2})', app_rest) |
| 143 | + view = re.match(r'^/([a-zA-Z]+)', app_rest) |
| 144 | + return done('calendar', 'view', {'view': view.group(1) if view else None, |
| 145 | + 'date': date.group(1) if date else None}) |
| 146 | + |
| 147 | + if app == 'bookmarks': |
| 148 | + folder = _first(query, 'folder') or (re.search(r'folders?/(\d+)', scope).group(1) |
| 149 | + if re.search(r'folders?/(\d+)', scope) else None) |
| 150 | + return done('bookmarks', 'folder' if folder else 'app', {'folder_id': _int(folder) if folder else None}) |
| 151 | + |
| 152 | + if app == 'cookbook': |
| 153 | + recipe = re.search(r'recipe/(\d+)', scope) |
| 154 | + if recipe: |
| 155 | + return done('cookbook', 'recipe', {'recipe_id': _int(recipe.group(1))}) |
| 156 | + category = re.search(r'category/([^/?#]+)', scope) |
| 157 | + if category: |
| 158 | + return done('cookbook', 'category', {'category': category.group(1)}) |
| 159 | + return done('cookbook', None) |
| 160 | + |
| 161 | + if app == 'forms': |
| 162 | + # /apps/forms/{hash} (fill) | /apps/forms/{edit,results,submit,embed}/{hash} |
| 163 | + m = re.match(r'^/(edit|results|submit|embed)/([^/?#]+)', app_rest) |
| 164 | + if m: |
| 165 | + return done('forms', m.group(1), {'hash': m.group(2)}) |
| 166 | + m = re.match(r'^/([^/?#]+)', app_rest) |
| 167 | + if m: |
| 168 | + return done('forms', 'form', {'hash': m.group(1)}) |
| 169 | + return done('forms', None) |
| 170 | + |
| 171 | + if app == 'tables': |
| 172 | + view = re.search(r'view/(\d+)', scope) |
| 173 | + table = re.search(r'table/(\d+)', scope) |
| 174 | + if table: |
| 175 | + return done('tables', 'table', {'table_id': _int(table.group(1))}) |
| 176 | + if view: |
| 177 | + return done('tables', 'view', {'view_id': _int(view.group(1))}) |
| 178 | + return done('tables', None) |
| 179 | + |
| 180 | + # --- Fallbacks ---------------------------------------------------------- |
| 181 | + if file_id: |
| 182 | + return done('files', 'file', {'file_id': _int(file_id)}) |
| 183 | + if app: |
| 184 | + return done(app, None) |
| 185 | + return result |
| 186 | + |
| 187 | + |
| 188 | +async def get_tools(nc: AsyncNextcloudApp): |
| 189 | + |
| 190 | + @tool |
| 191 | + @safe_tool |
| 192 | + async def parse_nextcloud_url(url: str): |
| 193 | + """ |
| 194 | + Parse a Nextcloud deep-link URL and extract which app it belongs to, the |
| 195 | + entity type it points at, and the identifiers needed to fetch that entity |
| 196 | + with the matching tools. Use this to turn a link a user pasted (e.g. a Talk |
| 197 | + room, a Deck card, a Collectives page, a Tables table, a recipe, ...) into |
| 198 | + concrete ids before calling the relevant app tools. |
| 199 | +
|
| 200 | + Supports the Files, Talk (spreed), Collectives, Deck, Mail, Calendar, |
| 201 | + Bookmarks, Cookbook, Forms and Tables apps. |
| 202 | + :param url: a Nextcloud URL (with or without the /index.php prefix) |
| 203 | + :return: a dict with keys `app`, `entity_type`, `ids`, and a `hint` on which |
| 204 | + tools to use. `entity_type` is null when the URL is not a recognized route. |
| 205 | + """ |
| 206 | + return _parse_nextcloud_url(url) |
| 207 | + |
| 208 | + return [parse_nextcloud_url] |
| 209 | + |
| 210 | + |
| 211 | +def get_category_name(): |
| 212 | + return "Nextcloud Links" |
| 213 | + |
| 214 | + |
| 215 | +async def is_available(nc: AsyncNextcloudApp): |
| 216 | + return True |
0 commit comments