Skip to content

Commit 001ba0f

Browse files
committed
feat: Add an nc link parser tool
Assisted-by: ClaudeCode:claude-opus-4-8 Signed-off-by: Marcel Klehr <mklehr@gmx.net>
1 parent 865a01a commit 001ba0f

1 file changed

Lines changed: 216 additions & 0 deletions

File tree

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
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

Comments
 (0)