diff --git a/ex_app/lib/agent.py b/ex_app/lib/agent.py index 9ee8bdc..a1a3711 100644 --- a/ex_app/lib/agent.py +++ b/ex_app/lib/agent.py @@ -14,6 +14,7 @@ from nc_py_api import AsyncNextcloudApp from nc_py_api.ex_app import persistent_storage +from ex_app.lib.all_tools.nextcloud_links import get_absolute_base_url from ex_app.lib.all_tools.skills import list_skills_metadata from ex_app.lib.graph import AgentState, get_graph from ex_app.lib.jsonplus import JsonPlusSerializer @@ -156,6 +157,10 @@ async def call_model( system_prompt_text += "Always check for the mail account id before requesting a folder list.\n" if tool_enabled("web_fetch"): system_prompt_text += "Use the web_fetch tool to fetch web content. You can fetch the complete page content of a duckduckgo search result using web_fetch as well.\n" + if tool_enabled("parse_nextcloud_url"): + # app_cfg.endpoint can be an instance-internal address, so it is only a last resort + base_url = await get_absolute_base_url(nc) or nc.app_cfg.endpoint.rstrip('/') + system_prompt_text += f"This Nextcloud instance is reachable at {base_url}. URLs starting with this root belong to this Nextcloud instance; use the parse_nextcloud_url tool to turn such a link the user pasted into concrete ids before calling the relevant app tools.\n" if task['input'].get('memories'): system_prompt_text += "You can remember things from other conversations with the user. If relevant, take into account the following memories:\n\n" + "\n".join(task['input']['memories']) + "\n\n" diff --git a/ex_app/lib/all_tools/nextcloud_links.py b/ex_app/lib/all_tools/nextcloud_links.py new file mode 100644 index 0000000..26d752f --- /dev/null +++ b/ex_app/lib/all_tools/nextcloud_links.py @@ -0,0 +1,543 @@ +# SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +import base64 +import binascii +import re +import time +from urllib.parse import parse_qs, unquote, urlparse + +from langchain_core.tools import tool +from nc_py_api import AsyncNextcloudApp +from nc_py_api.ex_app import LogLvl + +from ex_app.lib.all_tools.lib.decorator import safe_tool +from ex_app.lib.logger import log + +# The user-facing absolute base URL is stable for the app's lifetime; resolve it once. +_absolute_base_url: str | None = None +# When the lookup fails (e.g. `overwrite.cli.url` is unset) retrying on every conversation +# turn only burns a round-trip that cannot succeed, so back off for a while. +_absolute_base_url_retry_after: float = 0.0 +_FAILED_LOOKUP_TTL = 3600 + + +async def get_absolute_base_url(nc: AsyncNextcloudApp) -> str | None: + """ + Return the user-facing absolute base URL of the Nextcloud instance, without a trailing slash. + + ``nc.app_cfg.endpoint`` may be an internal address (reverse-proxy / docker host), so + ask app_api for the public URL. A successful lookup is cached module-wide to avoid an + HTTP request on every conversation turn. Returns ``None`` when the public URL cannot be + determined; a failed lookup is only retried after ``_FAILED_LOOKUP_TTL`` seconds, so a + later reconfiguration of the instance is still picked up. + """ + global _absolute_base_url, _absolute_base_url_retry_after + if _absolute_base_url is None and time.monotonic() >= _absolute_base_url_retry_after: + try: + absolute_url = (await nc.ocs( + 'GET', '/ocs/v2.php/apps/app_api/api/v1/info/nextcloud_url/absolute', params={'url': '/'} + ))['absolute_url'].rstrip('/') + # app_api builds this from `overwrite.cli.url`, which may be unset - then we + # only get back the '/' we passed in. + if absolute_url.startswith(('http://', 'https://')): + _absolute_base_url = absolute_url + else: + _absolute_base_url_retry_after = time.monotonic() + _FAILED_LOOKUP_TTL + await log(nc, LogLvl.WARNING, + f"app_api returned no usable absolute Nextcloud URL: {absolute_url!r}") + except Exception as e: + _absolute_base_url_retry_after = time.monotonic() + _FAILED_LOOKUP_TTL + await log(nc, LogLvl.WARNING, f"Could not resolve the absolute Nextcloud URL: {e}") + return _absolute_base_url + + +# Hint telling the agent which tools can act on the parsed entity. +_TOOL_HINTS = { + 'files': 'Use get_file_content_by_file_link / get_file_path_by_id with the file_id.', + 'talk': 'Use the Talk tools; resolve the token via list_talk_conversations (match on token).', + 'collectives': 'Collective pages are Markdown files; if a file_id is present use the Files tools, otherwise open the collective by name.', + 'deck': 'Use the Deck tools with board_id / card_id.', + 'mail': 'Use the Mail tools; mailbox_id maps to a folder_id, thread_id identifies the conversation.', + 'calendar': 'Use the Calendar tools; the object is identified by calendar + object href/token.', + 'bookmarks': 'Use the Bookmarks tools; filter by folder_id if present.', + 'cookbook': 'Use get_recipe_details with recipe_id.', + 'forms': 'Call list_forms and match its `hash` field to map a form hash to a form_id; a share_hash does not appear there.', + 'tables': 'Use the Tables tools with table_id (or list rows of the view_id).', +} + + +def _clean_path(path: str) -> str: + """Strip the optional /index.php segment from a Nextcloud path.""" + return re.sub(r'/index\.php(?=/|$)', '', path or '') + + +def _int(value): + """ + Coerce an id to ``int``, or to ``None`` when it is not numeric. + + Ids are handed to tools that are typed ``int`` (``get_file_path_by_id(file_id: int)``, + ...), so a junk query param such as ``?fileid=abc`` must not be passed on as an id - + ``done()`` drops ``None`` values. + """ + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _decode_calendar_object(object_id: str) -> dict: + """ + Decode a Calendar app ``object`` param into its CalDAV path parts. + + The param is ``base64("/remote.php/dav/calendars///.ics")``. + Returns a dict with ``dav_path``, ``calendar_uri`` and ``event_uid``, or ``{}`` when it + does not decode to a plausible CalDAV object path. The raw ``object_id`` is kept by the + caller so nothing is lost when decoding fails. + + Validation is deliberately strict: arbitrary base64 happily decodes to text containing a + ``/``, and inventing a ``calendar_uri`` / ``event_uid`` out of that would hand the agent + ids that look authoritative but denote nothing. + """ + if not object_id: + return {} + padded = object_id + '=' * (-len(object_id) % 4) + try: + path = base64.b64decode(padded, validate=True).decode('utf-8') + except (binascii.Error, UnicodeDecodeError, ValueError): + return {} + # A real object path is absolute, sits below a calendar collection and points at a .ics + # file: /remote.php/dav/calendars///.ics for an own calendar, + # /remote.php/dav/public-calendars//.ics for a public share - so match on + # the shape rather than on a fixed depth. + segments = path.split('/') + if (not path.startswith('/') or not path.endswith('.ics') or not path.isprintable() + or len(segments) < 3 or not all(segments[1:]) + or not any(segment.endswith('calendars') for segment in segments)): + return {} + return {'dav_path': path, 'calendar_uri': segments[-2], 'event_uid': segments[-1][:-len('.ics')]} + + +_CAL_ROUTE_MODES = frozenset(('popover', 'full', 'sidebar')) + + +def _find_calendar_object(raw_scope: str, route: str): + """ + Locate the ``{object}[/{recurrenceId}]`` pair that follows a Calendar app route. + + ``route`` is a regex for the route marker (``/view/``, ``/edit/``). The segment right + after it may be a view/editor mode (``popover``, ``full``, ``sidebar``, ...) instead of + the object, and that mode list is not closed - the app keeps adding routes. So try + every offset and keep the first segment that actually decodes to a CalDAV object path. + Only when none does fall back to skipping a *known* mode, so the raw value is still + reported rather than dropped. + + Returns ``(object_id, recurrence_id, decoded)`` or ``None`` when the route is absent. + ``decoded`` is empty when the object could not be decoded, which is what tells the + caller not to claim the object is base64 of a DAV path. + + ``raw_scope`` must still be percent-encoded: the base64 object may contain ``/`` as + ``%2F``, so segments are split off before unquoting. + """ + m = re.search(route + r'([^?#]+)', raw_scope) + if not m: + return None + segments = [unquote(s) for s in m.group(1).split('/') if s] + if not segments: + return None + for index, segment in enumerate(segments): + decoded = _decode_calendar_object(segment) + if decoded: + return segment, segments[index + 1] if index + 1 < len(segments) else None, decoded + if segments[0] in _CAL_ROUTE_MODES: + segments = segments[1:] + if not segments: + return None + return segments[0], segments[1] if len(segments) > 1 else None, {} + + +# Nextcloud root entrypoints that are never a webroot directory. A path going through one +# of them is not an app route, so unanchored app-route patterns must not claim it. +_NON_APP_ENTRYPOINTS = frozenset( + ('remote.php', 'public.php', 'cron.php', 'status.php', 'ocs', 'ocs-provider', 'ocm-provider')) + +_DEFAULT_PORTS = {'http': 80, 'https': 443} +_SCHEME_PREFIX = re.compile(r'^[a-zA-Z][a-zA-Z0-9+.\-]*://') + + +def _names_host_without_scheme(value: str) -> bool: + """ + Whether ``value`` omits the scheme but still names a host (``cloud.example.com/f/1``). + + urlparse puts such a value entirely in ``path``, and reads a ``host:port`` prefix as a + *scheme*, so both have to be recognised before parsing. A colon followed by something + other than a port number is a real scheme (``javascript:``, ``mailto:``), not a host. + """ + if value.startswith('/') or _SCHEME_PREFIX.match(value): + return False + host, _, port = value.split('/', 1)[0].partition(':') + if port and not port.isdigit(): + return False + return '.' in host + + +def _split_url(value: str): + """Parse ``value``, treating a scheme-less ``host/...`` as network-relative.""" + return urlparse(f'//{value}' if _names_host_without_scheme(value) else value) + + +def _origin(value: str) -> tuple[str, int | None] | None: + """ + The (host, port) a URL points at, or ``None`` when it names no host at all. + + ``port`` is the explicit port, or the scheme's default port, or ``None`` when neither + is known (a scheme-less URL) - so ``https://host`` and ``https://host:443`` compare + equal while an unknown port stays unknown instead of being guessed. + """ + parsed = _split_url(value.strip()) + try: + host, port = parsed.hostname, parsed.port + except ValueError: # malformed port + return None + if not host: + return None + return host.lower(), port if port is not None else _DEFAULT_PORTS.get(parsed.scheme.lower()) + + +def _same_host(url: str, base_url: str) -> bool: + """ + Whether ``url`` is relative or points at the same origin as ``base_url``. + + Ports only have to agree when both are actually known - a same-host URL pasted without + its scheme should not be reported as foreign. + """ + origin, base_origin = _origin(url), _origin(base_url) + if origin is None or base_origin is None: + return True + if origin[0] != base_origin[0]: + return False + return None in (origin[1], base_origin[1]) or origin[1] == base_origin[1] + + +# A URL longer than this is not a real Nextcloud deep link; it is the practical ceiling +# most browsers and proxies enforce anyway. +_MAX_URL_LENGTH = 2048 + + +def _validated_url(url) -> str: + """ + Return ``url`` stripped of surrounding whitespace, or raise ``ValueError``. + + Rejects input that cannot be a Nextcloud deep link, so that junk fails loudly with an + actionable message instead of being parsed into confident-looking ids: ``ftp://host/f/1`` + would otherwise report file 1, and two URLs pasted into one string would silently be + read as the second one. Errors raised here reach the model via the tool-node fallback. + + Accepted forms are an absolute ``http(s)://host/...`` URL, a host-relative + ``host/...`` one, and a root-relative ``/...`` path. + """ + if not isinstance(url, str) or not url.strip(): + raise ValueError('A non-empty URL string is required') + url = url.strip() + if len(url) > _MAX_URL_LENGTH: + raise ValueError(f'URL is too long ({len(url)} characters, maximum {_MAX_URL_LENGTH})') + if re.search(r'[\s\x00-\x1f\x7f]', url): + raise ValueError( + 'URL must not contain whitespace or control characters - pass exactly one URL, ' + 'without any surrounding prose' + ) + + parsed = _split_url(url) + scheme, netloc = parsed.scheme.lower(), parsed.netloc + try: + if netloc: + parsed.port # noqa: B018 - raises on a malformed or out-of-range port + except ValueError as e: + raise ValueError(f'URL is malformed: {e}') from e + + if scheme and scheme not in ('http', 'https'): + raise ValueError(f'Unsupported URL scheme {scheme!r}: only http(s) Nextcloud URLs can be parsed') + if scheme and not netloc: + raise ValueError('URL names a scheme but no host') + if not netloc and not url.startswith('/'): + raise ValueError( + f'{url!r} is not a URL - expected an absolute http(s) URL, a host-relative URL, ' + 'or a path starting with "/"' + ) + return url + + +def _first(query: dict, *keys): + """Return the first value of the first present key in a parsed query string.""" + for key in keys: + if key in query and query[key]: + return query[key][0] + return None + + +def _parse_nextcloud_url(url: str) -> dict: + """ + Parse a Nextcloud deep-link URL into its app, entity type and identifiers. + + Returns a dict with at least ``app`` and ``entity_type`` and, when found, an + ``ids`` mapping. ``entity_type`` is ``None`` (and app may be ``unknown``) when + the URL does not match any known Nextcloud app route. + """ + url = _validated_url(url) + + parsed = urlparse(url) + path = _clean_path(unquote(parsed.path or '')) + fragment = unquote(parsed.fragment or '') + query = parse_qs(parsed.query or '') + + result = {'app': 'unknown', 'entity_type': None, 'ids': {}, 'url': url} + + def done(app, entity_type, ids=None, **extra): + result.update(app=app, entity_type=entity_type, ids={k: v for k, v in (ids or {}).items() if v is not None}) + result.update({k: v for k, v in extra.items() if v is not None}) + hint = _TOOL_HINTS.get(app) + if hint: + result['hint'] = hint + return result + + # --- Files (files) ------------------------------------------------------ + # https://host/f/123 | /index.php/f/123 + # https://host/apps/files/files/123?dir=/&editing=false&openfile=true + # https://host/apps/files/?dir=/path&fileid=123 + m = re.search(r'/f/(\d+)/?$', path) + if m: + return done('files', 'file', {'file_id': _int(m.group(1))}) + m = re.search(r'/apps/files/files/(\d+)', path) + if m: + return done('files', 'file', {'file_id': _int(m.group(1))}) + # openfile is a boolean flag (openfile=true), not an id + file_id = _int(_first(query, 'fileid', 'fileId')) + + # public share of a file/folder: /s/{token} (optionally /s/{token}/download etc.) + # Other apps have their own /s/{hash} routes below /apps/, don't claim those. + m = re.search(r'/s/([A-Za-z0-9_-]+)(?:/|$)', path) + if m and '/apps/' not in path: + return done('files', 'public_share', {'token': m.group(1)}) + + # --- Which app does the URL address? /apps/{app}/... ------------------- + # Not anchored: Nextcloud may be installed in a webroot subdirectory + # (https://host/nextcloud/apps/deck/...). + app_match = re.search(r'/apps/([^/?#]+)(/.*)?$', path) + app = app_match.group(1) if app_match else None + app_rest = (app_match.group(2) if app_match else '') or '' + # Hash-router apps (deck, tables, cookbook, ...) carry the real route in the + # fragment, e.g. /apps/tables#/table/3 . Search the app path and fragment together. + scope = f'{app_rest}#{fragment}' # everything after the app name + + # --- Talk (spreed) ------------------------------------------------------ + # /call/{token} optionally #message_{id} + # Only claim this when the URL is neither scoped to some *other* app nor routed through + # a non-app entrypoint: `/call/` is not anchored (the webroot may be a subdirectory), so + # an unguarded match would read a collectives page titled "call", or a WebDAV path such + # as /remote.php/dav/files/alice/call/notes/, as a Talk room. + if app in (None, 'spreed') and _NON_APP_ENTRYPOINTS.isdisjoint(path.split('/')): + m = re.search(r'/call/([A-Za-z0-9]+)(?:/|$)', path) + if m: + msg = re.search(r'message_(\d+)', fragment) + return done('talk', 'conversation', {'token': m.group(1)}, + message_id=_int(msg.group(1)) if msg else None) + + if app == 'collectives': + # Routes (relative to /apps/collectives), see collectives/src/router.js: + # /{collective}[/{page...}] authenticated + # /p/{token}/{collective}[/{page...}] public share + # /_/print/{collective} , /p/{token}/print/... print views (stripped) + # {collective} and each {page} segment is either a verbatim encodeURIComponent'd + # name/title or the modern "{slug}-{id}" form; the trailing {id} is authoritative + # (for a page it equals its file id). Work on the still-encoded path so an encoded + # slash inside a title does not split it, then unquote each segment. + raw_collectives = re.search(r'/apps/collectives(/.*)?$', _clean_path(parsed.path or '')) + raw_rest = (raw_collectives.group(1) if raw_collectives else '') or '' + token = None + m = re.match(r'^/p/([^/]+)(/.*)?$', raw_rest) + if m: + token = unquote(m.group(1)) + raw_rest = m.group(2) or '' + # print prefix: /_/print (authenticated) or /print (public, after the token) + raw_rest = re.sub(r'^/(?:_/)?print(?=/|$)', '', raw_rest) + segments = [unquote(s) for s in raw_rest.split('/') if s] + + ids = {'token': token} + if segments: + ids['collective'] = segments[0] + m = re.match(r'^(.+)-(\d+)$', segments[0]) + if m: + ids['collective_id'] = _int(m.group(2)) + page_segs = segments[1:] + page_id = None + if page_segs: + ids['page_path'] = '/'.join(page_segs) + m = re.match(r'^(.+)-(\d+)$', page_segs[-1]) + page_id = _int(m.group(2)) if m else None + # a collectives page is a file: its id (from ?fileId= or the "{slug}-{id}" page + # segment) is the file_id usable with the Files tools. + ids['file_id'] = file_id if file_id is not None else page_id + + entity_type = 'page' if page_segs else 'collective' if segments else ('public_share' if token else None) + return done('collectives', entity_type, ids) + + if app == 'deck': + # /board/{id}[/card/{id}] , plus the legacy /boards/{id}[/cards/{id}] and + # /!/board/{id} aliases that the deck router redirects. + board = re.search(r'boards?/(\d+)', scope) + card = re.search(r'cards?/(\d+)', scope) + if card and board: + return done('deck', 'card', {'board_id': _int(board.group(1)), 'card_id': _int(card.group(1))}) + if card: + return done('deck', 'card', {'card_id': _int(card.group(1))}) + if board: + return done('deck', 'board', {'board_id': _int(board.group(1))}) + return done('deck', None) + + if app == 'mail': + # an optional filter segment may sit between box/ and the id, e.g. box/starred/345 + box = re.search(r'box/(?:[a-zA-Z][^/]*/)?(\d+)', scope) + thread = re.search(r'thread/(\d+)', scope) + if thread: + return done('mail', 'thread', {'mailbox_id': _int(box.group(1)) if box else None, + 'thread_id': _int(thread.group(1))}) + if box: + return done('mail', 'mailbox', {'mailbox_id': _int(box.group(1))}) + return done('mail', None) + + _CAL_OBJECT_NOTE = ('object_id is base64("/remote.php/dav/calendars///.ics"), ' + 'also decoded into dav_path, calendar_uri and event_uid. Match calendar_uri / ' + 'event_uid against list_calendars / search_calendar_events (calendar_uri is the URL ' + 'slug and may differ from the display name). recurrence_id is a unix timestamp ' + '(seconds) or the literal "next".') + if app == 'calendar': + # The base64 {object} param may contain '/' and '+', which arrive percent-encoded + # (%2F, %2B) in the real URL. Match against the still-encoded path so a '/' inside + # the object does not truncate it, then unquote only the captured object segment. + raw_scope = f'{_clean_path(parsed.path or "")}#{parsed.fragment or ""}' + # public share / embed: /p/{tokens}/... , /public/{tokens}/... , /embed/{tokens}/... + # {tokens} may be several share tokens joined with '-'. + # Anchored: the base64 {object} of an event link may itself contain '/p/'. + share = re.match(r'^/(p|public|embed)/([^/?#]+)', scope) + if share: + ids = {'token': share.group(2)} + # optional event object: .../{view|edit}/{mode}/{object}/{recurrenceId} - a + # shared link may open the event in the editor as well as in a view. + ev = _find_calendar_object(raw_scope, r'/(?:view|edit)/') + decoded = {} + if ev: + ids['object_id'], ids['recurrence_id'], decoded = ev + ids.update(decoded) + extra = {'embed': True} if share.group(1) == 'embed' else {} + return done('calendar', 'public_share', ids, + note=_CAL_OBJECT_NOTE if decoded else None, **extra) + # event editor: .../{view}/{firstDay}/edit/{popover|full|sidebar}/{object}/{recurrenceId} + # or the short redirect link: /edit/{object}[/{recurrenceId}] + obj = _find_calendar_object(raw_scope, r'/edit/') + if obj: + object_id, recurrence_id, decoded = obj + ids = {'object_id': object_id, 'recurrence_id': recurrence_id} + ids.update(decoded) + # Without a decoded DAV path the object_id is whatever segment followed /edit/, + # so do not tell the agent it is base64 of one. + return done('calendar', 'event', ids, note=_CAL_OBJECT_NOTE if decoded else None) + # plain calendar view: /{view}/{firstDay} (firstDay is 'now' or an ISO date) + date = re.search(r'/(\d{4}-\d{2}-\d{2})', app_rest) + view = re.match(r'^/([a-zA-Z]+)', app_rest) + ids = {'view': view.group(1) if view else None, + 'date': date.group(1) if date else None} + # A bare /apps/calendar addresses no entity: report entity_type None, as the other + # apps do, rather than a 'view' with no ids. + return done('calendar', 'view' if any(v is not None for v in ids.values()) else None, ids) + + if app == 'bookmarks': + folder_match = re.search(r'/folders?/(\d+)', scope) + folder_id = _int(_first(query, 'folder') or (folder_match.group(1) if folder_match else None)) + token = re.search(r'/public/([^/?#]+)', scope) + return done('bookmarks', 'public_share' if token else 'folder' if folder_id is not None else None, + {'folder_id': folder_id, + 'token': token.group(1) if token else None}) + + if app == 'cookbook': + recipe = re.search(r'recipe/(\d+)', scope) + if recipe: + return done('cookbook', 'recipe', {'recipe_id': _int(recipe.group(1))}) + category = re.search(r'category/([^/?#]+)', scope) + if category: + return done('cookbook', 'category', {'category': category.group(1)}) + return done('cookbook', None) + + if app == 'forms': + # /apps/forms/{hash} (fill) | /apps/forms/{hash}/{edit,results,submit} + # public link: /apps/forms/s/{share_hash} | embedded: /apps/forms/embed/{share_hash} + m = re.match(r'^/(s|embed)/([^/?#]+)', app_rest) + if m: + return done('forms', 'public_share', {'share_hash': m.group(2)}, + note='share_hash identifies the public share, not the form itself.', + **({'embed': True} if m.group(1) == 'embed' else {})) + m = re.match(r'^/([^/?#]+)(?:/(edit|results|submit))?', app_rest) + if m: + return done('forms', 'form', {'hash': m.group(1)}, view=m.group(2)) + return done('forms', None) + + if app == 'tables': + view = re.search(r'view/(\d+)', scope) + table = re.search(r'table/(\d+)', scope) + if table or view: + # a view may be addressed inside its table (#/table/3/view/8) - keep both ids + # and name the leaf as the entity. + return done('tables', 'view' if view else 'table', + {'table_id': _int(table.group(1)) if table else None, + 'view_id': _int(view.group(1)) if view else None}) + return done('tables', None) + + # --- Fallbacks ---------------------------------------------------------- + if file_id is not None: + return done('files', 'file', {'file_id': file_id}) + if app: + return done(app, None) + return result + + +async def get_tools(nc: AsyncNextcloudApp): + + @tool + @safe_tool + async def parse_nextcloud_url(url: str): + """ + Parse a Nextcloud deep-link URL and extract which app it belongs to, the + entity type it points at, and the identifiers needed to fetch that entity + with the matching tools. Use this to turn a link a user pasted (e.g. a Talk + room, a Deck card, a Collectives page, a Tables table, a recipe, ...) into + concrete ids before calling the relevant app tools. + + Supports the Files, Talk (spreed), Collectives, Deck, Mail, Calendar, + Bookmarks, Cookbook, Forms and Tables apps. + :param url: exactly one Nextcloud URL (with or without the /index.php prefix), with + no surrounding text. Must be an absolute http(s) URL, a host-relative URL, or a + path starting with "/"; anything else is rejected with an error explaining why. + :return: a dict with keys `app`, `entity_type`, `ids`, and a `hint` on which + tools to use. `entity_type` names the entity itself; when the link addresses + a particular screen of it (e.g. a form's `results`) that is reported + separately as `view`. `entity_type` is null when the app is recognized but + the URL points at no specific entity, or when the URL matches no known route + at all. `ids` only ever contains identifiers actually present in the URL. + """ + result = _parse_nextcloud_url(url) + # Only known-foreign hosts are flagged: without a confirmed public URL we cannot + # tell an external link from a legitimate one and stay quiet instead. + base_url = await get_absolute_base_url(nc) + if base_url and not _same_host(url, base_url): + result['warning'] = (f'This URL is not on this Nextcloud instance ({base_url}). The ids below ' + 'were read off the URL shape alone and may denote unrelated entities here. ' + 'Do not look them up without checking back with the user.') + return result + + return [parse_nextcloud_url] + + +def get_category_name(): + return "Nextcloud Links" + + +async def is_available(nc: AsyncNextcloudApp): + return True