feat: support bookmark folders (#253)

This commit is contained in:
Nikhil
2026-01-20 14:04:50 -08:00
committed by GitHub
parent fd3cdace91
commit 3588585f20
8 changed files with 549 additions and 3 deletions

View File

@@ -0,0 +1,52 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { z } from 'zod'
import { BookmarkAdapter } from '@/adapters/BookmarkAdapter'
import { ActionHandler } from '../ActionHandler'
const CreateBookmarkFolderInputSchema = z.object({
title: z.string().describe('Folder name'),
parentId: z
.string()
.optional()
.describe('Parent folder ID (defaults to "1" = Bookmarks Bar)'),
})
const CreateBookmarkFolderOutputSchema = z.object({
id: z.string().describe('Created folder ID'),
title: z.string().describe('Folder name'),
parentId: z.string().optional().describe('Parent folder ID'),
dateAdded: z.number().optional().describe('Creation timestamp'),
})
type CreateBookmarkFolderInput = z.infer<typeof CreateBookmarkFolderInputSchema>
type CreateBookmarkFolderOutput = z.infer<
typeof CreateBookmarkFolderOutputSchema
>
export class CreateBookmarkFolderAction extends ActionHandler<
CreateBookmarkFolderInput,
CreateBookmarkFolderOutput
> {
readonly inputSchema = CreateBookmarkFolderInputSchema
private bookmarkAdapter = new BookmarkAdapter()
async execute(
input: CreateBookmarkFolderInput,
): Promise<CreateBookmarkFolderOutput> {
const created = await this.bookmarkAdapter.createBookmarkFolder({
title: input.title,
parentId: input.parentId,
})
return {
id: created.id,
title: created.title,
parentId: created.parentId,
dateAdded: created.dateAdded,
}
}
}

View File

@@ -0,0 +1,59 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { z } from 'zod'
import { BookmarkAdapter } from '@/adapters/BookmarkAdapter'
import { ActionHandler } from '../ActionHandler'
const GetBookmarkChildrenInputSchema = z.object({
folderId: z.string().describe('Folder ID to get children from'),
})
const GetBookmarkChildrenOutputSchema = z.object({
children: z.array(
z.object({
id: z.string(),
title: z.string(),
url: z.string().optional(),
parentId: z.string().optional(),
dateAdded: z.number().optional(),
isFolder: z.boolean(),
}),
),
count: z.number(),
})
type GetBookmarkChildrenInput = z.infer<typeof GetBookmarkChildrenInputSchema>
type GetBookmarkChildrenOutput = z.infer<typeof GetBookmarkChildrenOutputSchema>
export class GetBookmarkChildrenAction extends ActionHandler<
GetBookmarkChildrenInput,
GetBookmarkChildrenOutput
> {
readonly inputSchema = GetBookmarkChildrenInputSchema
private bookmarkAdapter = new BookmarkAdapter()
async execute(
input: GetBookmarkChildrenInput,
): Promise<GetBookmarkChildrenOutput> {
const results = await this.bookmarkAdapter.getBookmarkChildren(
input.folderId,
)
const children = results.map((node) => ({
id: node.id,
title: node.title,
url: node.url,
parentId: node.parentId,
dateAdded: node.dateAdded,
isFolder: !node.url,
}))
return {
children,
count: children.length,
}
}
}

View File

@@ -0,0 +1,49 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { z } from 'zod'
import { BookmarkAdapter } from '@/adapters/BookmarkAdapter'
import { ActionHandler } from '../ActionHandler'
const MoveBookmarkInputSchema = z.object({
id: z.string().describe('Bookmark or folder ID to move'),
parentId: z.string().optional().describe('New parent folder ID'),
index: z.number().int().min(0).optional().describe('Position within parent'),
})
const MoveBookmarkOutputSchema = z.object({
id: z.string().describe('Moved bookmark ID'),
title: z.string().describe('Bookmark title'),
url: z.string().optional().describe('Bookmark URL (undefined if folder)'),
parentId: z.string().optional().describe('New parent folder ID'),
index: z.number().optional().describe('New position within parent'),
})
type MoveBookmarkInput = z.infer<typeof MoveBookmarkInputSchema>
type MoveBookmarkOutput = z.infer<typeof MoveBookmarkOutputSchema>
export class MoveBookmarkAction extends ActionHandler<
MoveBookmarkInput,
MoveBookmarkOutput
> {
readonly inputSchema = MoveBookmarkInputSchema
private bookmarkAdapter = new BookmarkAdapter()
async execute(input: MoveBookmarkInput): Promise<MoveBookmarkOutput> {
const destination: { parentId?: string; index?: number } = {}
if (input.parentId !== undefined) destination.parentId = input.parentId
if (input.index !== undefined) destination.index = input.index
const moved = await this.bookmarkAdapter.moveBookmark(input.id, destination)
return {
id: moved.id,
title: moved.title,
url: moved.url,
parentId: moved.parentId,
index: moved.index,
}
}
}

View File

@@ -0,0 +1,48 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { z } from 'zod'
import { BookmarkAdapter } from '@/adapters/BookmarkAdapter'
import { ActionHandler } from '../ActionHandler'
const RemoveBookmarkTreeInputSchema = z.object({
id: z.string().describe('Folder ID to remove'),
confirm: z.boolean().describe('Must be true to confirm recursive deletion'),
})
const RemoveBookmarkTreeOutputSchema = z.object({
success: z.boolean().describe('Whether the folder was removed'),
message: z.string().describe('Result message'),
})
type RemoveBookmarkTreeInput = z.infer<typeof RemoveBookmarkTreeInputSchema>
type RemoveBookmarkTreeOutput = z.infer<typeof RemoveBookmarkTreeOutputSchema>
export class RemoveBookmarkTreeAction extends ActionHandler<
RemoveBookmarkTreeInput,
RemoveBookmarkTreeOutput
> {
readonly inputSchema = RemoveBookmarkTreeInputSchema
private bookmarkAdapter = new BookmarkAdapter()
async execute(
input: RemoveBookmarkTreeInput,
): Promise<RemoveBookmarkTreeOutput> {
if (input.confirm !== true) {
return {
success: false,
message:
'Recursive deletion requires confirm: true. This will permanently delete the folder and all its contents.',
}
}
await this.bookmarkAdapter.removeBookmarkTree(input.id)
return {
success: true,
message: `Removed folder ${input.id} and all its contents`,
}
}
}

View File

@@ -205,6 +205,128 @@ export class BookmarkAdapter {
}
}
/**
* Create a bookmark folder
*
* @param title - Folder name
* @param parentId - Parent folder ID (defaults to "1" = Bookmarks Bar)
* @returns Created folder node
*/
async createBookmarkFolder(options: {
title: string
parentId?: string
}): Promise<chrome.bookmarks.BookmarkTreeNode> {
const { title, parentId = '1' } = options
logger.debug(
`[BookmarkAdapter] Creating bookmark folder: "${title}" in parent ${parentId}`,
)
try {
const created = await chrome.bookmarks.create({
title,
parentId,
})
logger.debug(
`[BookmarkAdapter] Created folder: ${created.id} - ${created.title}`,
)
return created
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error)
logger.error(
`[BookmarkAdapter] Failed to create bookmark folder: ${errorMessage}`,
)
throw new Error(`Failed to create bookmark folder: ${errorMessage}`)
}
}
/**
* Get direct children of a folder
*
* @param folderId - Folder ID to get children from
* @returns Array of child nodes
*/
async getBookmarkChildren(
folderId: string,
): Promise<chrome.bookmarks.BookmarkTreeNode[]> {
logger.debug(`[BookmarkAdapter] Getting children of folder: ${folderId}`)
try {
const children = await chrome.bookmarks.getChildren(folderId)
logger.debug(
`[BookmarkAdapter] Found ${children.length} children in folder ${folderId}`,
)
return children
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error)
logger.error(
`[BookmarkAdapter] Failed to get bookmark children: ${errorMessage}`,
)
throw new Error(`Failed to get bookmark children: ${errorMessage}`)
}
}
/**
* Move a bookmark or folder to a new location
*
* @param id - Bookmark or folder ID to move
* @param destination - New location
* @returns Updated bookmark node
*/
async moveBookmark(
id: string,
destination: { parentId?: string; index?: number },
): Promise<chrome.bookmarks.BookmarkTreeNode> {
logger.debug(
`[BookmarkAdapter] Moving bookmark ${id} to parent ${destination.parentId}, index ${destination.index}`,
)
try {
const moved = await chrome.bookmarks.move(id, destination)
logger.debug(
`[BookmarkAdapter] Moved bookmark ${id} to ${moved.parentId}`,
)
return moved
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error)
logger.error(
`[BookmarkAdapter] Failed to move bookmark ${id}: ${errorMessage}`,
)
throw new Error(`Failed to move bookmark: ${errorMessage}`)
}
}
/**
* Remove a folder and all its contents recursively
*
* @param id - Folder ID to remove
* @throws if id is a root node ("0", "1", "2")
*/
async removeBookmarkTree(id: string): Promise<void> {
const protectedIds = ['0', '1', '2']
if (protectedIds.includes(id)) {
throw new Error(
`Cannot delete protected bookmark folder: ${id}. Root folders (Bookmarks Bar, Other Bookmarks, Mobile Bookmarks) cannot be deleted.`,
)
}
logger.debug(`[BookmarkAdapter] Removing bookmark tree: ${id}`)
try {
await chrome.bookmarks.removeTree(id)
logger.debug(`[BookmarkAdapter] Removed bookmark tree: ${id}`)
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error)
logger.error(
`[BookmarkAdapter] Failed to remove bookmark tree ${id}: ${errorMessage}`,
)
throw new Error(`Failed to remove bookmark tree: ${errorMessage}`)
}
}
/**
* Flatten bookmark tree into array
* @private

View File

@@ -5,8 +5,12 @@
*/
import { ActionRegistry } from '@/actions/ActionRegistry'
import { CreateBookmarkAction } from '@/actions/bookmark/CreateBookmarkAction'
import { CreateBookmarkFolderAction } from '@/actions/bookmark/CreateBookmarkFolderAction'
import { GetBookmarkChildrenAction } from '@/actions/bookmark/GetBookmarkChildrenAction'
import { GetBookmarksAction } from '@/actions/bookmark/GetBookmarksAction'
import { MoveBookmarkAction } from '@/actions/bookmark/MoveBookmarkAction'
import { RemoveBookmarkAction } from '@/actions/bookmark/RemoveBookmarkAction'
import { RemoveBookmarkTreeAction } from '@/actions/bookmark/RemoveBookmarkTreeAction'
import { CaptureScreenshotAction } from '@/actions/browser/CaptureScreenshotAction'
import { CaptureScreenshotPointerAction } from '@/actions/browser/CaptureScreenshotPointerAction'
import { ClearAction } from '@/actions/browser/ClearAction'
@@ -200,6 +204,19 @@ export class BrowserOSController {
this.actionRegistry.register('getBookmarks', new GetBookmarksAction())
this.actionRegistry.register('createBookmark', new CreateBookmarkAction())
this.actionRegistry.register('removeBookmark', new RemoveBookmarkAction())
this.actionRegistry.register(
'createBookmarkFolder',
new CreateBookmarkFolderAction(),
)
this.actionRegistry.register(
'getBookmarkChildren',
new GetBookmarkChildrenAction(),
)
this.actionRegistry.register('moveBookmark', new MoveBookmarkAction())
this.actionRegistry.register(
'removeBookmarkTree',
new RemoveBookmarkTreeAction(),
)
this.actionRegistry.register('searchHistory', new SearchHistoryAction())
this.actionRegistry.register(

View File

@@ -12,7 +12,15 @@ export {
sendKeys,
} from './tools/advanced'
// Bookmark Management
export { createBookmark, getBookmarks, removeBookmark } from './tools/bookmarks'
export {
createBookmark,
createBookmarkFolder,
getBookmarkChildren,
getBookmarks,
moveBookmark,
removeBookmark,
removeBookmarkTree,
} from './tools/bookmarks'
// Content Extraction
export { getPageContent } from './tools/content'
// Coordinate-based
@@ -61,7 +69,15 @@ import {
executeJavaScript,
sendKeys,
} from './tools/advanced'
import { createBookmark, getBookmarks, removeBookmark } from './tools/bookmarks'
import {
createBookmark,
createBookmarkFolder,
getBookmarkChildren,
getBookmarks,
moveBookmark,
removeBookmark,
removeBookmarkTree,
} from './tools/bookmarks'
import { getPageContent } from './tools/content'
import { clickCoordinates, typeAtCoordinates } from './tools/coordinates'
import { getRecentHistory, searchHistory } from './tools/history'
@@ -90,7 +106,7 @@ import {
} from './tools/tab-management'
import { closeWindow, createWindow } from './tools/window-management'
// Array export for convenience (33 tools total)
// Array export for convenience (37 tools total)
export const allControllerTools = [
getActiveTab,
listTabs,
@@ -122,6 +138,10 @@ export const allControllerTools = [
getBookmarks,
createBookmark,
removeBookmark,
createBookmarkFolder,
getBookmarkChildren,
moveBookmark,
removeBookmarkTree,
searchHistory,
getRecentHistory,
createWindow,

View File

@@ -115,3 +115,182 @@ export const removeBookmark = defineTool<z.ZodRawShape, Context, Response>({
response.appendResponseLine(`Removed bookmark ${bookmarkId}`)
},
})
export const createBookmarkFolder = defineTool<
z.ZodRawShape,
Context,
Response
>({
name: 'browser_create_bookmark_folder',
description: 'Create a new bookmark folder',
annotations: {
category: ToolCategories.BOOKMARKS,
readOnlyHint: false,
},
schema: {
title: z.string().describe('Folder name'),
parentId: z
.string()
.optional()
.describe('Parent folder ID (defaults to Bookmarks Bar)'),
windowId: z.number().optional().describe('Window ID for routing'),
},
handler: async (request, response, context) => {
const { title, parentId, windowId } = request.params as {
title: string
parentId?: string
windowId?: number
}
const result = await context.executeAction('createBookmarkFolder', {
title,
parentId,
windowId,
})
const data = result as {
id: string
title: string
parentId?: string
}
response.appendResponseLine(`Created folder: ${data.title}`)
response.appendResponseLine(`ID: ${data.id}`)
if (data.parentId) {
response.appendResponseLine(`Parent: ${data.parentId}`)
}
},
})
export const getBookmarkChildren = defineTool<z.ZodRawShape, Context, Response>(
{
name: 'browser_get_bookmark_children',
description: 'Get direct children of a bookmark folder',
annotations: {
category: ToolCategories.BOOKMARKS,
readOnlyHint: true,
},
schema: {
folderId: z.string().describe('Folder ID to get children from'),
windowId: z.number().optional().describe('Window ID for routing'),
},
handler: async (request, response, context) => {
const { folderId, windowId } = request.params as {
folderId: string
windowId?: number
}
const result = await context.executeAction('getBookmarkChildren', {
folderId,
windowId,
})
const data = result as {
children: Array<{
id: string
title: string
url?: string
isFolder: boolean
}>
count: number
}
response.appendResponseLine(`Folder contains ${data.count} items:`)
response.appendResponseLine('')
for (const child of data.children) {
if (child.isFolder) {
response.appendResponseLine(
`[${child.id}] 📁 ${child.title} (folder)`,
)
} else {
response.appendResponseLine(`[${child.id}] ${child.title}`)
if (child.url) {
response.appendResponseLine(` ${child.url}`)
}
}
}
},
},
)
export const moveBookmark = defineTool<z.ZodRawShape, Context, Response>({
name: 'browser_move_bookmark',
description: 'Move a bookmark or folder to a new location',
annotations: {
category: ToolCategories.BOOKMARKS,
readOnlyHint: false,
},
schema: {
bookmarkId: z.string().describe('Bookmark or folder ID to move'),
parentId: z.string().optional().describe('New parent folder ID'),
index: z
.number()
.int()
.min(0)
.optional()
.describe('Position within parent (0-based)'),
windowId: z.number().optional().describe('Window ID for routing'),
},
handler: async (request, response, context) => {
const { bookmarkId, parentId, index, windowId } = request.params as {
bookmarkId: string
parentId?: string
index?: number
windowId?: number
}
const result = await context.executeAction('moveBookmark', {
id: bookmarkId,
parentId,
index,
windowId,
})
const data = result as {
id: string
title: string
parentId?: string
index?: number
}
response.appendResponseLine(`Moved: ${data.title}`)
if (data.parentId) {
response.appendResponseLine(`New parent: ${data.parentId}`)
}
if (data.index !== undefined) {
response.appendResponseLine(`Position: ${data.index}`)
}
},
})
export const removeBookmarkTree = defineTool<z.ZodRawShape, Context, Response>({
name: 'browser_remove_bookmark_tree',
description:
'Remove a bookmark folder and all its contents recursively. Requires confirm: true.',
annotations: {
category: ToolCategories.BOOKMARKS,
readOnlyHint: false,
},
schema: {
folderId: z.string().describe('Folder ID to remove'),
confirm: z.boolean().describe('Must be true to confirm recursive deletion'),
windowId: z.number().optional().describe('Window ID for routing'),
},
handler: async (request, response, context) => {
const { folderId, confirm, windowId } = request.params as {
folderId: string
confirm: boolean
windowId?: number
}
const result = await context.executeAction('removeBookmarkTree', {
id: folderId,
confirm,
windowId,
})
const data = result as {
success: boolean
message: string
}
response.appendResponseLine(data.message)
},
})