Skip to main content
Glama
search.ts7.01 kB
/** * 思源笔记搜索相关 API */ import type { SiyuanClient } from './client.js'; import type { Block, SearchOptions, SearchResultResponse, TagResponse } from '../types/index.js'; import { extractTitle, truncateContent } from '../utils/format.js'; export class SiyuanSearchApi { constructor(private client: SiyuanClient) {} /** * 根据文件名搜索文档 * @param fileName 文件名关键词 * @param options 搜索选项 * @returns 搜索结果响应 */ async searchByFileName(fileName: string, options: SearchOptions = {}): Promise<SearchResultResponse[]> { const { limit = 10, notebook } = options; let stmt = `SELECT * FROM blocks WHERE type='d' AND content LIKE '%${this.escapeSql(fileName)}%'`; if (notebook) { stmt += ` AND box='${this.escapeSql(notebook)}'`; } stmt += ` LIMIT ${limit}`; const response = await this.client.request<Block[]>('/api/query/sql', { stmt }); const blocks = response.data || []; return this.toSearchResultResponse(blocks); } /** * 根据文件内容搜索块 * @param content 内容关键词 * @param options 搜索选项 * @returns 搜索结果响应 */ async searchByContent(content: string, options: SearchOptions = {}): Promise<SearchResultResponse[]> { const { limit = 10, notebook, types } = options; let stmt = `SELECT * FROM blocks WHERE content LIKE '%${this.escapeSql(content)}%'`; if (notebook) { stmt += ` AND box='${this.escapeSql(notebook)}'`; } if (types && types.length > 0) { const typeConditions = types.map((t) => `'${this.escapeSql(t)}'`).join(','); stmt += ` AND type IN (${typeConditions})`; } stmt += ` LIMIT ${limit}`; const response = await this.client.request<Block[]>('/api/query/sql', { stmt }); const blocks = response.data || []; return this.toSearchResultResponse(blocks); } /** * 使用 SQL 查询 * @param sql SQL 语句 * @returns 查询结果 */ async query(sql: string): Promise<Block[]> { const response = await this.client.request<Block[]>('/api/query/sql', { stmt: sql }); return response.data || []; } /** * 将Block数组转换为搜索结果响应 */ private toSearchResultResponse(blocks: Block[]): SearchResultResponse[] { return blocks.map(block => ({ id: block.id, name: block.name || extractTitle(block.content), path: block.hpath || block.path, content: truncateContent(block.content, 200), // 截取前200字符作为摘要 type: block.type, updated: block.updated })); } /** * 列出所有标签 * @param prefix 可选的标签前缀过滤 * @param depth 可选的层级限制(从1开始计数,例如 depth=1 只返回顶层标签) * @returns 标签数组,包含标签名和使用次数 */ async listAllTags(prefix?: string, depth?: number): Promise<TagResponse[]> { // 使用思源官方的标签 API,传递 sort 参数 // 注意:此 API 有 512 条的软限制(Conf.FileTree.MaxListCount) // 但对于大多数用户来说足够使用 const response = await this.client.request<Array<{ name: string; label: string; children?: any[]; type: string; depth: number; count: number; }>>('/api/tag/getTag', { sort: 4 }); const tags = response.data || []; // 递归提取所有标签的 label 和 count(包括子标签) const tagMap = new Map<string, number>(); const extractTags = (tagList: any[]) => { for (const tag of tagList) { if (tag.label) { tagMap.set(tag.label, tag.count || 0); } // 递归处理子标签 if (tag.children && tag.children.length > 0) { extractTags(tag.children); } } }; extractTags(tags); // 转换为 TagResponse 数组 let result: TagResponse[] = Array.from(tagMap.entries()).map(([label, count]) => ({ label, document_count: count })); // 应用前缀过滤 if (prefix) { result = result.filter(tag => tag.label.startsWith(prefix)); } // 应用层级限制 if (depth && depth > 0) { result = result.filter(tag => { // 计算标签的层级(通过分隔符 '/' 来判断) const level = tag.label.split('/').length; return level <= depth; }); } // 按标签名排序 return result.sort((a, b) => a.label.localeCompare(b.label)); } /** * 根据标签查找相关文档 * @param tag 标签名(不需要包含#符号) * @param limit 返回结果数量限制,默认 50 * @returns 搜索结果响应 */ async searchByTag(tag: string, limit: number = 50): Promise<SearchResultResponse[]> { // 查询包含指定标签的块(文档类型) const cleanTag = tag.replace(/#/g, '').trim(); const stmt = `SELECT * FROM blocks WHERE type='d' AND tag LIKE '%#${this.escapeSql(cleanTag)}#%' LIMIT ${limit}`; const response = await this.client.request<Block[]>('/api/query/sql', { stmt }); const blocks = response.data || []; return this.toSearchResultResponse(blocks); } /** * 统一搜索接口:支持按内容、标签、文件名等多种条件搜索 * @param options 搜索选项 * @returns 搜索结果响应 */ async search(options: { content?: string; tag?: string; filename?: string; limit?: number; notebook?: string; types?: string[]; }): Promise<SearchResultResponse[]> { const { content, tag, filename, limit = 10, notebook, types } = options; // 构建SQL查询条件 const conditions: string[] = []; // 如果指定了文件名,搜索文档类型 if (filename) { conditions.push(`type='d'`); conditions.push(`content LIKE '%${this.escapeSql(filename)}%'`); } // 如果指定了内容,搜索内容 if (content) { conditions.push(`content LIKE '%${this.escapeSql(content)}%'`); } // 如果指定了标签,搜索标签 if (tag) { const cleanTag = tag.replace(/#/g, '').trim(); conditions.push(`tag LIKE '%#${this.escapeSql(cleanTag)}#%'`); } // 如果指定了笔记本 if (notebook) { conditions.push(`box='${this.escapeSql(notebook)}'`); } // 如果指定了类型 if (types && types.length > 0) { const typeConditions = types.map((t) => `'${this.escapeSql(t)}'`).join(','); conditions.push(`type IN (${typeConditions})`); } // 如果没有任何条件,返回空数组 if (conditions.length === 0) { return []; } // 构建完整的SQL语句 const stmt = `SELECT * FROM blocks WHERE ${conditions.join(' AND ')} LIMIT ${limit}`; const response = await this.client.request<Block[]>('/api/query/sql', { stmt }); const blocks = response.data || []; return this.toSearchResultResponse(blocks); } /** * 转义 SQL 特殊字符 */ private escapeSql(str: string): string { return str.replace(/'/g, "''"); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/porkll/siyuan-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server