Skip to main content
Glama

TriliumNext Notes' MCP Server

searchQueryBuilder.ts16.7 kB
import { logVerboseInput, logVerboseOutput, logVerboseTransform } from "../utils/verboseUtils.js"; // Unified SearchCriteria interface for all search types interface SearchCriteria { property: string; // Property name (varies by type) type: 'label' | 'relation' | 'noteProperty'; // Type of search criteria op?: string; // Operator (exists, =, !=, >=, <=, >, <, contains, starts_with, ends_with, regex) value?: string; // Value to search for (optional for exists) logic?: 'AND' | 'OR'; // Logic operator to combine with NEXT item } interface SearchStructuredParams { text?: string; limit?: number; searchCriteria?: SearchCriteria[]; } export function buildSearchQuery(params: SearchStructuredParams): string { const queryParts: string[] = []; // Verbose logging logVerboseInput("buildSearchQuery", params); // Build unified search criteria expressions const searchExpressions: string[] = []; if (params.searchCriteria && params.searchCriteria.length > 0) { searchExpressions.push(...buildUnifiedSearchExpressions(params.searchCriteria)); } // Add search expressions from unified searchCriteria if (searchExpressions.length > 0) { queryParts.push(...searchExpressions); } // Add keyword search token if (params.text) { queryParts.unshift(params.text); // Add at beginning for better query structure } // Build main query let query = queryParts.join(' '); // If only searchCriteria were provided and no other search criteria, add universal match condition if (query.trim() === '' && searchExpressions.length > 0) { // For ETAPI compatibility, we need a base search condition when only using searchCriteria query = `note.noteId != '' ${searchExpressions.join(' ')}`; } else if (query.trim() === '') { // No search criteria provided at all - this will trigger the validation error in index.ts query = ''; } // Add limit if (params.limit) { query += ` limit ${params.limit}`; } // Verbose logging logVerboseOutput("buildSearchQuery", query); return query; } /** * Builds unified search expressions with cross-type boolean logic support * Handles all search criteria types: labels, relations, and note properties * Enables previously impossible cross-type OR operations */ function buildUnifiedSearchExpressions(searchCriteria: SearchCriteria[]): string[] { const expressions: string[] = []; let currentGroup: string[] = []; let groupLogic: 'AND' | 'OR' = 'AND'; // Default to AND as per TriliumNext behavior for (let i = 0; i < searchCriteria.length; i++) { const criteria = searchCriteria[i]; const query = buildSearchCriteriaQuery(criteria); if (!query) continue; // Skip invalid criteria // Auto-clean: Ignore logic on last item (no next item to combine with) const effectiveLogic = (i === searchCriteria.length - 1) ? undefined : criteria.logic; // If this is the first item in a group, or continuing the same logic if (currentGroup.length === 0 || !effectiveLogic || effectiveLogic === groupLogic) { currentGroup.push(query); if (effectiveLogic) { groupLogic = effectiveLogic; } } else { // Logic changed, finalize current group expressions.push(finalizeGroup(currentGroup, groupLogic)); // Start new group currentGroup = [query]; groupLogic = effectiveLogic; } } // Finalize the last group if (currentGroup.length > 0) { expressions.push(finalizeGroup(currentGroup, groupLogic)); } return expressions; } /** * Builds a query for individual search criteria based on type * Dispatches to appropriate handler based on criteria type */ function buildSearchCriteriaQuery(criteria: SearchCriteria): string { const { type } = criteria; switch (type) { case 'label': case 'relation': return buildAttributeQuery(criteria); case 'noteProperty': return buildNotePropertyQuery(criteria); default: return ''; } } /** * Finalizes a group of attribute queries with the specified logic */ function finalizeGroup(queries: string[], logic: 'AND' | 'OR'): string { if (queries.length === 1) { return queries[0]; } if (logic === 'OR') { // Join with OR and add ~ prefix for Trilium parser compatibility return `~(${queries.join(' OR ')})`; } else { // AND logic - just join with spaces (Trilium's default) return queries.join(' '); } } /** * Builds an attribute query for labels and relations * Maps JSON operators to Trilium attribute search syntax */ function buildAttributeQuery(criteria: SearchCriteria): string { const { type, property: name, op = 'exists', value } = criteria; // Support both labels and relations if (type !== 'label' && type !== 'relation') { return ''; } // Auto-enhance relation properties to ensure proper TriliumNext syntax let enhancedName = name; if (type === 'relation') { // For relations, ensure property access syntax is used // TriliumNext requires ~relation.property, not ~relation = value if (!name.includes('.') && op !== 'exists' && op !== 'not_exists') { enhancedName = enhanceRelationProperty(name, op, value); // Verbose logging for auto-enhancement logVerboseTransform("relation", name, enhancedName, "TriliumNext requires property access for relations"); } } // Escape the attribute name const escapedName = enhancedName.replace(/'/g, "\\'"); // Determine the prefix based on attribute type const prefix = type === 'label' ? '#' : '~'; switch (op) { case 'exists': return `${prefix}${escapedName}`; case 'not_exists': return `${prefix}!${escapedName}`; case '=': if (!value) return ''; return `${prefix}${escapedName} = '${value.replace(/'/g, "\\'")}'`; case '!=': if (!value) return ''; return `${prefix}${escapedName} != '${value.replace(/'/g, "\\'")}'`; case '>=': if (!value) return ''; return `${prefix}${escapedName} >= '${value.replace(/'/g, "\\'")}'`; case '<=': if (!value) return ''; return `${prefix}${escapedName} <= '${value.replace(/'/g, "\\'")}'`; case '>': if (!value) return ''; return `${prefix}${escapedName} > '${value.replace(/'/g, "\\'")}'`; case '<': if (!value) return ''; return `${prefix}${escapedName} < '${value.replace(/'/g, "\\'")}'`; case 'contains': if (!value) return ''; return `${prefix}${escapedName} *=* '${value.replace(/'/g, "\\'")}'`; case 'starts_with': if (!value) return ''; return `${prefix}${escapedName} =* '${value.replace(/'/g, "\\'")}'`; case 'ends_with': if (!value) return ''; return `${prefix}${escapedName} *= '${value.replace(/'/g, "\\'")}'`; case 'regex': if (!value) return ''; return `${prefix}${escapedName} %= '${value.replace(/'/g, "\\'")}'`; default: return ''; } } /** * Enhances relation property with appropriate suffix (.noteId or .title) * Smart detection based on value patterns and relation types */ function enhanceRelationProperty(name: string, op: string, value?: string): string { // If user provides explicit property path, use it as-is if (name.includes('.')) { return name; } // For exists/not_exists operators, don't add suffix if (op === 'exists' || op === 'not_exists' || !value) { return name; } // Auto-detect noteId patterns and apply appropriate suffix if (isNoteIdPattern(value) && isTemplateOrSystemRelation(name)) { return `${name}.noteId`; } // Default to title-based search for user-friendly behavior return `${name}.title`; } /** * Detects if value matches Trilium noteId patterns */ function isNoteIdPattern(value: string): boolean { const noteIdPatterns = [ /^_[a-zA-Z0-9_]+$/, // Templates and system notes (e.g., _template_board, _calendar) /^[a-zA-Z0-9]{8,}$/, // Regular note IDs (e.g., abc123def, xyz789uvw) /^_[a-zA-Z0-9]+$/, // Simple system notes (e.g., _home, _inbox) ]; return noteIdPatterns.some(pattern => pattern.test(value)); } /** * Identifies relations that commonly use noteId targeting */ function isTemplateOrSystemRelation(property: string): boolean { const templateRelations = [ 'template', 'prototype', 'archetype', 'icon', 'cssClass', 'widget', 'launcher', 'search', 'relationMapTemplate' ]; return templateRelations.includes(property.toLowerCase()); } /** * Validates note type values */ function validateNoteType(value: string): string { const validNoteTypes = [ 'text', 'code', 'render', 'search', 'relationMap', 'book', 'noteMap', 'mermaid', 'webView' ]; if (!validNoteTypes.includes(value)) { throw new Error(`Invalid note type: '${value}'. Valid note types are: ${validNoteTypes.join(', ')}`); } return value; } /** * Validates common MIME type values */ function validateMimeType(value: string): string { // Common MIME types for TriliumNext notes const commonMimeTypes = [ // Code languages 'text/javascript', 'text/x-python', 'text/x-java', 'text/css', 'text/html', 'text/x-go', 'text/x-typescript', 'text/x-sql', 'text/x-yaml', 'text/x-markdown', 'text/x-c', 'text/x-cpp', 'text/x-csharp', 'text/x-php', 'text/x-ruby', 'text/x-shell', 'text/x-dockerfile', 'application/xml', 'application/x-httpd-php', // Special note types 'text/vnd.mermaid', 'application/json', // Generic text 'text/plain' ]; // Allow any text/* or application/* MIME type, but warn about uncommon ones if (!value.match(/^(text|application)\/[a-zA-Z0-9][a-zA-Z0-9\-_.]*$/)) { throw new Error(`Invalid MIME type format: '${value}'. Must follow pattern 'text/*' or 'application/*'`); } // Just validate format, don't restrict to specific types as TriliumNext supports many return value; } /** * Validates ISO date format for date properties */ function validateISODate(value: string, property: string): string { // ISO date formats: YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss.sssZ const isoDateRegex = /^\d{4}-\d{2}-\d{2}$/; const isoDateTimeRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/; // TriliumNext smart date expressions const smartDatePatterns = [ /^TODAY([+-]\d+)?$/, // TODAY, TODAY-7, TODAY+30 /^MONTH([+-]\d+)?$/, // MONTH, MONTH-1, MONTH+1 /^YEAR([+-]\d+)?$/, // YEAR, YEAR+1, YEAR-1 /^MONDAY([+-]\d+)?$/, // MONDAY, MONDAY-1 /^TUESDAY([+-]\d+)?$/, // TUESDAY, TUESDAY+1 /^WEDNESDAY([+-]\d+)?$/, // WEDNESDAY /^THURSDAY([+-]\d+)?$/, // THURSDAY /^FRIDAY([+-]\d+)?$/, // FRIDAY /^SATURDAY([+-]\d+)?$/, // SATURDAY /^SUNDAY([+-]\d+)?$/ // SUNDAY ]; // Check if it's a valid ISO date, ISO datetime, or smart date expression const isValidISO = isoDateRegex.test(value) || isoDateTimeRegex.test(value); const isValidSmartDate = smartDatePatterns.some(pattern => pattern.test(value.toUpperCase())); if (!isValidISO && !isValidSmartDate) { throw new Error(`Invalid date format for property '${property}'. Must use ISO format: 'YYYY-MM-DDTHH:mm:ss.sssZ' (e.g., '2024-01-01T00:00:00.000Z') or TriliumNext smart expressions like 'TODAY-7', 'MONTH-1', 'YEAR+1'.`); } // For ISO dates, check if the date is actually valid if (isValidISO) { const dateObj = new Date(value); if (isNaN(dateObj.getTime())) { throw new Error(`Invalid date value for property '${property}': '${value}'. Please provide a valid ISO date.`); } } return value; } /** * Builds a note property query based on the note property condition * Maps JSON note properties to Trilium note property search syntax */ function buildNotePropertyQuery(criteria: SearchCriteria): string { const { property, op = '=', value } = criteria; // Ensure value is provided when needed if (!value && op !== 'exists' && op !== 'not_exists') { return ''; // Skip if no value provided for operators that need one } // Map property names to Trilium note properties let triliumProperty: string; switch (property) { case 'isArchived': triliumProperty = 'note.isArchived'; break; case 'isProtected': triliumProperty = 'note.isProtected'; break; case 'type': triliumProperty = 'note.type'; break; case 'mime': triliumProperty = 'note.mime'; break; case 'title': triliumProperty = 'note.title'; break; case 'content': triliumProperty = 'note.content'; break; case 'dateCreated': triliumProperty = 'note.dateCreated'; break; case 'dateModified': triliumProperty = 'note.dateModified'; break; case 'labelCount': triliumProperty = 'note.labelCount'; break; case 'ownedLabelCount': triliumProperty = 'note.ownedLabelCount'; break; case 'attributeCount': triliumProperty = 'note.attributeCount'; break; case 'relationCount': triliumProperty = 'note.relationCount'; break; case 'parentCount': triliumProperty = 'note.parentCount'; break; case 'childrenCount': triliumProperty = 'note.childrenCount'; break; case 'contentSize': triliumProperty = 'note.contentSize'; break; case 'revisionCount': triliumProperty = 'note.revisionCount'; break; default: // Check for hierarchy navigation properties (parents.*, children.*, ancestors.*) if (property.startsWith('parents.') || property.startsWith('children.') || property.startsWith('ancestors.')) { // Dynamic hierarchy property handling - supports any depth // Examples: parents.noteId, parents.title, parents.parents.title, children.children.children.noteId triliumProperty = `note.${property}`; break; } // Invalid property, skip this filter return ''; } // Map operators to Trilium syntax let triliumOperator: string; switch (op) { case '=': triliumOperator = '='; break; case '!=': triliumOperator = '!='; break; case '>': triliumOperator = '>'; break; case '<': triliumOperator = '<'; break; case '>=': triliumOperator = '>='; break; case '<=': triliumOperator = '<='; break; case 'contains': triliumOperator = '*=*'; break; case 'starts_with': triliumOperator = '=*'; break; case 'ends_with': triliumOperator = '*='; break; case 'not_equal': triliumOperator = '!='; break; case 'regex': triliumOperator = '%='; break; default: // Invalid operator, skip this filter return ''; } // Handle boolean values for isArchived and isProtected let processedValue: string; if (property === 'isArchived' || property === 'isProtected') { // Convert string boolean to actual boolean for Trilium if (value!.toLowerCase() === 'true') { processedValue = 'true'; } else if (value!.toLowerCase() === 'false') { processedValue = 'false'; } else { // Invalid boolean value, skip this filter return ''; } } else if (property === 'labelCount' || property === 'ownedLabelCount' || property === 'attributeCount' || property === 'relationCount' || property === 'parentCount' || property === 'childrenCount' || property === 'contentSize' || property === 'revisionCount') { // Numeric properties - no quotes needed processedValue = value!; } else if (property === 'title' || property === 'content') { // Title and content properties need quotes for string operators processedValue = `'${value!.replace(/'/g, "\\'")}'`; } else if (property === 'type') { // Note type property - validate and wrap in quotes const validatedValue = validateNoteType(value!); processedValue = `'${validatedValue.replace(/'/g, "\\'")}'`; } else if (property === 'mime') { // MIME type property - validate and wrap in quotes const validatedValue = validateMimeType(value!); processedValue = `'${validatedValue.replace(/'/g, "\\'")}'`; } else if (property === 'dateCreated' || property === 'dateModified') { // Date properties - validate ISO format and wrap in quotes const validatedValue = validateISODate(value!, property); processedValue = `'${validatedValue.replace(/'/g, "\\'")}'`; } else { // For other properties, escape quotes and wrap in single quotes processedValue = `'${value!.replace(/'/g, "\\'")}'`; } return `${triliumProperty} ${triliumOperator} ${processedValue}`; }

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/tan-yong-sheng/triliumnext-mcp'

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