Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 178 additions & 0 deletions ex_app/lib/all_tools/bookmarks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
from typing import Optional
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


async def get_tools(nc: AsyncNextcloudApp):
@tool
@safe_tool
async def list_bookmarks(page: int = 0, limit: int = 100, folder_id: Optional[int] = None, tags: Optional[list[str]] = None):
"""
List bookmarks with optional filtering
:param page: page number for pagination (starts at 0)
:param limit: number of bookmarks per page (default 100)
:param folder_id: filter by folder id (obtainable via list_bookmark_folders)
:param tags: filter by tags - list of tag names
:return: list of bookmarks with url, title, description, and tags
"""
params = {
'page': page,
'limit': limit
}
if folder_id is not None:
params['folder'] = folder_id
if tags:
params['tags[]'] = tags

response = await nc._session._create_adapter(True).request('GET', f"{nc.app_cfg.endpoint}/index.php/apps/bookmarks/public/rest/v2/bookmark", headers={
"Content-Type": "application/json",
}, params=params)
return response.json()

@tool
@safe_tool
async def search_bookmarks(search_term: str):
"""
Search for bookmarks by keyword
:param search_term: text to search for in bookmark titles, urls, and descriptions
:return: list of matching bookmarks
"""
response = await nc._session._create_adapter(True).request('GET', f"{nc.app_cfg.endpoint}/index.php/apps/bookmarks/public/rest/v2/bookmark", headers={
"Content-Type": "application/json",
}, params={'search': search_term})
return response.json()

@tool
@dangerous_tool
async def create_bookmark(url: str, title: Optional[str] = None, description: Optional[str] = None, tags: Optional[list[str]] = None, folder_id: Optional[int] = None):
"""
Create a new bookmark
:param url: the URL to bookmark
:param title: title for the bookmark (auto-detected if not provided)
:param description: optional description
:param tags: list of tags to add to the bookmark
:param folder_id: folder to place the bookmark in (obtainable via list_bookmark_folders)
:return: the created bookmark
"""
description_with_ai_note = f"{description or ''}\n\nBookmarked by Nextcloud AI Assistant."

payload = {
'url': url,
'description': description_with_ai_note
}
if title:
payload['title'] = title
if tags:
payload['tags'] = tags
if folder_id is not None:
payload['folders'] = [folder_id]

response = await nc._session._create_adapter(True).request('POST', f"{nc.app_cfg.endpoint}/index.php/apps/bookmarks/public/rest/v2/bookmark", headers={
"Content-Type": "application/json",
}, json=payload)
return response.json()

@tool
@dangerous_tool
async def update_bookmark(bookmark_id: int, url: Optional[str] = None, title: Optional[str] = None, description: Optional[str] = None, tags: Optional[list[str]] = None):
"""
Update an existing bookmark
:param bookmark_id: the id of the bookmark to update (obtainable via list_bookmarks)
:param url: new URL
:param title: new title
:param description: new description
:param tags: new list of tags (replaces existing tags)
:return: the updated bookmark
"""
payload = {}
if url is not None:
payload['url'] = url
if title is not None:
payload['title'] = title
if description is not None:
payload['description'] = description
if tags is not None:
payload['tags'] = tags

response = await nc._session._create_adapter(True).request('PUT', f"{nc.app_cfg.endpoint}/index.php/apps/bookmarks/public/rest/v2/bookmark/{bookmark_id}", headers={
"Content-Type": "application/json",
}, json=payload)
return response.json()

@tool
@dangerous_tool
async def delete_bookmark(bookmark_id: int):
"""
Delete a bookmark
:param bookmark_id: the id of the bookmark to delete (obtainable via list_bookmarks)
:return: confirmation of deletion
"""
response = await nc._session._create_adapter(True).request('DELETE', f"{nc.app_cfg.endpoint}/index.php/apps/bookmarks/public/rest/v2/bookmark/{bookmark_id}", headers={
"Content-Type": "application/json",
})
return response.json()

@tool
@safe_tool
async def list_bookmark_folders():
"""
List all bookmark folders
:return: list of folders with their id, title, and parent folder
"""
response = await nc._session._create_adapter(True).request('GET', f"{nc.app_cfg.endpoint}/index.php/apps/bookmarks/public/rest/v2/folder", headers={
"Content-Type": "application/json",
})
return response.json()

@tool
@dangerous_tool
async def create_bookmark_folder(title: str, parent_folder_id: Optional[int] = None):
"""
Create a new bookmark folder
:param title: name for the folder
:param parent_folder_id: optional parent folder id to create a subfolder (obtainable via list_bookmark_folders)
:return: the created folder
"""
payload = {
'title': title
}
if parent_folder_id is not None:
payload['parent_folder'] = parent_folder_id

response = await nc._session._create_adapter(True).request('POST', f"{nc.app_cfg.endpoint}/index.php/apps/bookmarks/public/rest/v2/folder", headers={
"Content-Type": "application/json",
}, json=payload)
return response.json()

@tool
@safe_tool
async def list_bookmark_tags():
"""
List all bookmark tags with usage counts
:return: list of tags with the number of bookmarks using each tag
"""
response = await nc._session._create_adapter(True).request('GET', f"{nc.app_cfg.endpoint}/index.php/apps/bookmarks/public/rest/v2/tag", headers={
"Content-Type": "application/json",
})
return response.json()

return [
list_bookmarks,
search_bookmarks,
create_bookmark,
update_bookmark,
delete_bookmark,
list_bookmark_folders,
create_bookmark_folder,
list_bookmark_tags
]

def get_category_name():
return "Bookmarks"

async def is_available(nc: AsyncNextcloudApp):
return 'bookmarks' in await nc.capabilities
190 changes: 189 additions & 1 deletion ex_app/lib/all_tools/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,11 +254,199 @@ async def add_task(calendar_name: str, title: str, description: str, due_date: O

return True

def list_tasks_sync(calendar_name: Optional[str] = None):
principal = ncSync.cal.principal()
calendars = principal.calendars()

tasks = []
if calendar_name:
calendar = {cal.name: cal for cal in calendars}[calendar_name]
calendars_to_check = [calendar]
else:
calendars_to_check = calendars

for cal in calendars_to_check:
todos = cal.todos()
for todo in todos:
# Parse the todo data using ics library
try:
ical_data = todo.data
parsed_cal = Calendar(ical_data)

for ics_todo in parsed_cal.todos:
task_data = {
'calendar': cal.name,
'summary': ics_todo.name or '',
'uid': ics_todo.uid or '',
'status': ics_todo.status or 'NEEDS-ACTION',
'due': str(ics_todo.due) if ics_todo.due else None,
'priority': ics_todo.priority,
'description': ics_todo.description or '',
}
tasks.append(task_data)
except:
# Fallback if parsing fails
continue

return tasks

@tool
@safe_tool
async def list_tasks(calendar_name: Optional[str] = None, filter_status: Optional[str] = None):
"""
List tasks from calendars. Can filter by calendar name and status.
:param calendar_name: Optional name of the calendar to list tasks from (obtainable via list_calendars). If not provided, lists from all calendars.
:param filter_status: Optional filter by status - one of: 'NEEDS-ACTION', 'COMPLETED', 'IN-PROCESS', 'CANCELLED'
:return: list of tasks with their details
"""
tasks = await asyncio.to_thread(list_tasks_sync, calendar_name)

if filter_status:
tasks = [t for t in tasks if t.get('status') == filter_status]

return tasks

def complete_task_sync(calendar_name: str, task_uid: str):
principal = ncSync.cal.principal()
calendars = principal.calendars()
calendar = {cal.name: cal for cal in calendars}[calendar_name]

todos = calendar.todos()
for todo in todos:
# Parse the todo data using ics library
try:
ical_data = todo.data
parsed_cal = Calendar(ical_data)

for ics_todo in parsed_cal.todos:
if ics_todo.uid == task_uid:
# Mark as completed
ics_todo.status = 'COMPLETED'
ics_todo.completed = datetime.now(timezone.utc)

# Serialize and save
todo.data = str(parsed_cal)
todo.save()
return True
except:
continue

return False

@tool
@dangerous_tool
async def complete_task(calendar_name: str, task_uid: str):
"""
Mark a task as completed
:param calendar_name: The name of the calendar containing the task (obtainable via list_calendars)
:param task_uid: The UID of the task to complete (obtainable via list_tasks)
:return: bool indicating success
"""
return await asyncio.to_thread(complete_task_sync, calendar_name, task_uid)

def update_task_sync(calendar_name: str, task_uid: str, title: Optional[str] = None, description: Optional[str] = None, due_date: Optional[str] = None, due_time: Optional[str] = None, timezone_str: Optional[str] = None, priority: Optional[int] = None):
principal = ncSync.cal.principal()
calendars = principal.calendars()
calendar = {cal.name: cal for cal in calendars}[calendar_name]

todos = calendar.todos()
for todo in todos:
# Parse the todo data using ics library
try:
ical_data = todo.data
parsed_cal = Calendar(ical_data)

for ics_todo in parsed_cal.todos:
if ics_todo.uid == task_uid:
# Update fields if provided
if title:
ics_todo.name = title
if description:
ics_todo.description = description
if priority is not None:
ics_todo.priority = priority
if due_date:
parsed_date = datetime.strptime(due_date, "%Y-%m-%d")
if due_time:
parsed_time = datetime.strptime(due_time, "%I:%M %p").time()
due_datetime = datetime.combine(parsed_date, parsed_time)
else:
due_datetime = parsed_date

if timezone_str:
tz = pytz.timezone(timezone_str)
due_datetime = tz.localize(due_datetime)

ics_todo.due = due_datetime

# Serialize and save
todo.data = str(parsed_cal)
todo.save()
return True
except:
continue

return False

@tool
@dangerous_tool
async def update_task(calendar_name: str, task_uid: str, title: Optional[str] = None, description: Optional[str] = None, due_date: Optional[str] = None, due_time: Optional[str] = None, timezone: Optional[str] = None, priority: Optional[int] = None):
"""
Update an existing task
:param calendar_name: The name of the calendar containing the task (obtainable via list_calendars)
:param task_uid: The UID of the task to update (obtainable via list_tasks)
:param title: New title for the task
:param description: New description for the task
:param due_date: New due date in the form: YYYY-MM-DD e.g. '2024-12-01'
:param due_time: New due time in the form: HH:MM AM/PM e.g. '3:00 PM'
:param timezone: Timezone (e.g., 'America/New_York')
:param priority: Priority from 0 (undefined) to 9 (lowest), where 1 is highest priority
:return: bool indicating success
"""
return await asyncio.to_thread(update_task_sync, calendar_name, task_uid, title, description, due_date, due_time, timezone, priority)

def delete_task_sync(calendar_name: str, task_uid: str):
principal = ncSync.cal.principal()
calendars = principal.calendars()
calendar = {cal.name: cal for cal in calendars}[calendar_name]

todos = calendar.todos()
for todo in todos:
# Parse the todo data using ics library to find the right one
try:
ical_data = todo.data
parsed_cal = Calendar(ical_data)

for ics_todo in parsed_cal.todos:
if ics_todo.uid == task_uid:
# Delete the todo
todo.delete()
return True
except:
continue

return False

@tool
@dangerous_tool
async def delete_task(calendar_name: str, task_uid: str):
"""
Delete a task
:param calendar_name: The name of the calendar containing the task (obtainable via list_calendars)
:param task_uid: The UID of the task to delete (obtainable via list_tasks)
:return: bool indicating success
"""
return await asyncio.to_thread(delete_task_sync, calendar_name, task_uid)

return [
list_calendars,
schedule_event,
find_free_time_slot_in_calendar,
add_task
add_task,
list_tasks,
complete_task,
update_task,
delete_task
]

def get_category_name():
Expand Down
Loading
Loading