UNPKG

roam-research-mcp

Version:
274 lines (273 loc) • 9.79 kB
/** * Shared validation functions for Roam MCP tools. * Provides consistent validation across all write operations. */ // Regex to match UID placeholders like {{uid:parent1}}, {{uid:section-a}}, etc. const UID_PLACEHOLDER_REGEX = /^\{\{uid:[^}]+\}\}$/; /** * Validates a block string content. * @param str The string to validate * @param allowEmpty If true, allows empty strings (for intentional blank blocks) * @returns Error message if invalid, null if valid */ export function validateBlockString(str, allowEmpty = false) { if (str === undefined || str === null) { return 'string is required'; } // Only reject truly empty strings '', not whitespace-only strings like ' ' // Whitespace-only strings are valid in Roam (e.g., empty table cells) if (!allowEmpty && typeof str === 'string' && str === '') { return 'string cannot be empty (use " " for intentional whitespace)'; } return null; } /** * Validates a Roam UID. * UIDs must be either: * - 9 alphanumeric characters (standard Roam block UID) * - A date format like MM-DD-YYYY (daily page UID) * - A placeholder like {{uid:name}} * @param uid The UID to validate * @param required If true, UID is required * @returns Error message if invalid, null if valid */ export function validateUid(uid, required = true) { if (!uid) { return required ? 'uid is required' : null; } // Check if it's a placeholder if (UID_PLACEHOLDER_REGEX.test(uid)) { return null; } // Check if it's a valid Roam block UID (9 alphanumeric characters) if (/^[a-zA-Z0-9_-]{9}$/.test(uid)) { return null; } // Check if it's a daily page UID (MM-DD-YYYY format) if (/^\d{2}-\d{2}-\d{4}$/.test(uid)) { return null; } return 'uid must be 9 alphanumeric characters, MM-DD-YYYY date, or a {{uid:name}} placeholder'; } /** * Validates an outline level. * @param level The level to validate * @returns Error message if invalid, null if valid */ export function validateOutlineLevel(level) { if (level === undefined || level === null) { return 'level is required'; } if (!Number.isInteger(level) || level < 1 || level > 10) { return 'level must be an integer between 1 and 10'; } return null; } /** * Validates a location object for block creation/movement. * @param location The location object to validate * @returns Error message if invalid, null if valid */ export function validateLocation(location) { if (!location) { return 'location is required'; } if (!location['parent-uid']) { return 'location.parent-uid is required'; } const uidError = validateUid(location['parent-uid'], true); if (uidError) { return `location.parent-uid: ${uidError}`; } return null; } /** * Validates a heading level. * @param heading The heading level to validate * @returns Error message if invalid, null if valid */ export function validateHeading(heading) { if (heading === undefined || heading === null || heading === 0) { return null; // Heading is optional } if (!Number.isInteger(heading) || heading < 1 || heading > 3) { return 'heading must be 1, 2, or 3'; } return null; } /** * Validates all batch actions before execution. * @param actions Array of batch actions to validate * @returns ValidationResult with errors if any */ export function validateBatchActions(actions) { const errors = []; if (!Array.isArray(actions)) { errors.push({ field: 'actions', message: 'actions must be an array' }); return { valid: false, errors }; } if (actions.length === 0) { errors.push({ field: 'actions', message: 'actions array cannot be empty' }); return { valid: false, errors }; } // Track defined placeholders for forward-reference validation const definedPlaceholders = new Set(); for (let i = 0; i < actions.length; i++) { const action = actions[i]; // Validate action type if (!action.action) { errors.push({ actionIndex: i, field: 'action', message: 'action type is required', expected: 'create-block | update-block | move-block | delete-block' }); continue; } const validActions = ['create-block', 'update-block', 'move-block', 'delete-block']; if (!validActions.includes(action.action)) { errors.push({ actionIndex: i, field: 'action', message: `invalid action type: ${action.action}`, expected: 'create-block | update-block | move-block | delete-block', received: action.action }); continue; } // Track placeholder definitions if (action.uid && UID_PLACEHOLDER_REGEX.test(action.uid)) { const placeholderMatch = action.uid.match(/\{\{uid:([^}]+)\}\}/); if (placeholderMatch) { definedPlaceholders.add(placeholderMatch[1]); } } // Validate based on action type switch (action.action) { case 'create-block': { // create-block requires string and location const stringError = validateBlockString(action.string); if (stringError) { errors.push({ actionIndex: i, field: 'string', message: stringError }); } const locationError = validateLocation(action.location); if (locationError) { errors.push({ actionIndex: i, field: 'location', message: locationError }); } // Check for forward-reference to undefined placeholder if (action.location?.['parent-uid']) { const parentUid = action.location['parent-uid']; const placeholderMatch = parentUid.match(/\{\{uid:([^}]+)\}\}/); if (placeholderMatch && !definedPlaceholders.has(placeholderMatch[1])) { errors.push({ actionIndex: i, field: 'location.parent-uid', message: `Placeholder {{uid:${placeholderMatch[1]}}} referenced before definition` }); } } const headingError = validateHeading(action.heading); if (headingError) { errors.push({ actionIndex: i, field: 'heading', message: headingError }); } break; } case 'update-block': { // update-block requires uid const uidError = validateUid(action.uid); if (uidError) { errors.push({ actionIndex: i, field: 'uid', message: uidError }); } // string is optional for update but if provided, validate it if (action.string !== undefined) { const stringError = validateBlockString(action.string); if (stringError) { errors.push({ actionIndex: i, field: 'string', message: stringError }); } } const headingError = validateHeading(action.heading); if (headingError) { errors.push({ actionIndex: i, field: 'heading', message: headingError }); } break; } case 'move-block': { // move-block requires uid and location const uidError = validateUid(action.uid); if (uidError) { errors.push({ actionIndex: i, field: 'uid', message: uidError }); } const locationError = validateLocation(action.location); if (locationError) { errors.push({ actionIndex: i, field: 'location', message: locationError }); } break; } case 'delete-block': { // delete-block requires uid const uidError = validateUid(action.uid); if (uidError) { errors.push({ actionIndex: i, field: 'uid', message: uidError }); } break; } } } return { valid: errors.length === 0, errors }; } /** * Formats validation errors into a human-readable string. */ export function formatValidationErrors(errors) { return errors .map(err => { const prefix = err.actionIndex !== undefined ? `Action ${err.actionIndex}: ` : ''; return `${prefix}[${err.field}] ${err.message}`; }) .join('; '); }