logic.tsā¢12.2 kB
/**
 * @fileoverview Logic for managing Obsidian block references and heading operations.
 */
import { RequestContext } from "../../../utils/index.js";
import { ObsidianRestApiService } from "../../../services/obsidianRestAPI/index.js";
import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
export interface BlockReferenceOperation {
  operation: "insert_under_heading" | "create_block_reference" | "get_heading_content" | "list_headings" | "get_block_content" | "append_to_heading" | "prepend_to_heading";
  filePath: string;
  heading?: string;
  headingLevel?: number;
  content?: string;
  blockId?: string;
  position?: "start" | "end" | "after_heading" | "before_next_heading";
  createHeading?: boolean;
  includeSubheadings?: boolean;
}
export interface BlockReferenceResult {
  success: boolean;
  operation: string;
  filePath: string;
  heading?: string;
  blockId?: string;
  content?: string;
  headings?: Array<{ level: number; text: string; line: number }>;
  created?: boolean;
  inserted?: boolean;
  message?: string;
}
/**
 * Executes block reference and heading operations.
 */
export async function executeBlockReferenceOperation(
  operation: BlockReferenceOperation,
  obsidianService: ObsidianRestApiService,
  context: RequestContext,
): Promise<BlockReferenceResult> {
  const { 
    operation: op, 
    filePath, 
    heading, 
    headingLevel, 
    content, 
    blockId, 
    position = "end", 
    createHeading = false, 
    includeSubheadings = false 
  } = operation;
  try {
    // Validate operation-specific requirements
    if (["insert_under_heading", "get_heading_content", "append_to_heading", "prepend_to_heading"].includes(op) && !heading) {
      throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Heading is required for ${op} operation`);
    }
    if (["insert_under_heading", "append_to_heading", "prepend_to_heading", "create_block_reference"].includes(op) && !content) {
      throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Content is required for ${op} operation`);
    }
    if (["get_block_content", "create_block_reference"].includes(op) && !blockId) {
      throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Block ID is required for ${op} operation`);
    }
    switch (op) {
      case "list_headings":
        return await listHeadings(filePath, obsidianService, context);
      case "get_heading_content":
        return await getHeadingContent(filePath, heading!, includeSubheadings, obsidianService, context);
      case "get_block_content":
        return await getBlockContent(filePath, blockId!, obsidianService, context);
      case "insert_under_heading":
        return await insertUnderHeading(filePath, heading!, content!, position, createHeading, obsidianService, context, headingLevel);
      case "append_to_heading":
        return await appendToHeading(filePath, heading!, content!, createHeading, obsidianService, context, headingLevel);
      case "prepend_to_heading":
        return await prependToHeading(filePath, heading!, content!, createHeading, obsidianService, context, headingLevel);
      case "create_block_reference":
        return await createBlockReference(filePath, content!, blockId!, obsidianService, context);
      default:
        throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Unknown operation: ${op}`);
    }
  } catch (error) {
    if (error instanceof McpError) {
      throw error;
    }
    throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Block reference operation failed: ${error instanceof Error ? error.message : String(error)}`);
  }
}
/**
 * List all headings in a file.
 */
async function listHeadings(
  filePath: string,
  obsidianService: ObsidianRestApiService,
  context: RequestContext,
): Promise<BlockReferenceResult> {
  const fileContent = await obsidianService.getFileContent(filePath, "markdown", context) as string;
  const lines = fileContent.split('\n');
  const headings: Array<{ level: number; text: string; line: number }> = [];
  lines.forEach((line, index) => {
    const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
    if (headingMatch) {
      headings.push({
        level: headingMatch[1].length,
        text: headingMatch[2].trim(),
        line: index + 1,
      });
    }
  });
  return {
    success: true,
    operation: "list_headings",
    filePath,
    headings,
    message: `Found ${headings.length} headings`,
  };
}
/**
 * Get content under a specific heading.
 */
async function getHeadingContent(
  filePath: string,
  heading: string,
  includeSubheadings: boolean,
  obsidianService: ObsidianRestApiService,
  context: RequestContext,
): Promise<BlockReferenceResult> {
  const fileContent = await obsidianService.getFileContent(filePath, "markdown", context) as string;
  const lines = fileContent.split('\n');
  
  const headingInfo = findHeading(lines, heading);
  if (!headingInfo) {
    throw new McpError(BaseErrorCode.NOT_FOUND, `Heading "${heading}" not found in file`);
  }
  const content = extractHeadingContent(lines, headingInfo.line, headingInfo.level, includeSubheadings);
  return {
    success: true,
    operation: "get_heading_content",
    filePath,
    heading,
    content,
    message: `Retrieved content under heading "${heading}"`,
  };
}
/**
 * Get content of a specific block reference.
 */
async function getBlockContent(
  filePath: string,
  blockId: string,
  obsidianService: ObsidianRestApiService,
  context: RequestContext,
): Promise<BlockReferenceResult> {
  const fileContent = await obsidianService.getFileContent(filePath, "markdown", context) as string;
  const lines = fileContent.split('\n');
  
  const blockLine = lines.findIndex(line => line.includes(`^${blockId}`));
  if (blockLine === -1) {
    throw new McpError(BaseErrorCode.NOT_FOUND, `Block reference "^${blockId}" not found in file`);
  }
  // Get the content of the line containing the block reference
  const content = lines[blockLine].replace(/\s*\^[\w-]+\s*$/, '').trim();
  return {
    success: true,
    operation: "get_block_content",
    filePath,
    blockId,
    content,
    message: `Retrieved content for block "^${blockId}"`,
  };
}
/**
 * Insert content under a specific heading.
 */
async function insertUnderHeading(
  filePath: string,
  heading: string,
  content: string,
  position: string,
  createHeading: boolean,
  obsidianService: ObsidianRestApiService,
  context: RequestContext,
  headingLevel?: number,
): Promise<BlockReferenceResult> {
  const fileContent = await obsidianService.getFileContent(filePath, "markdown", context) as string;
  const lines = fileContent.split('\n');
  
  let headingInfo = findHeading(lines, heading);
  let created = false;
  if (!headingInfo && createHeading) {
    // Create the heading at the end of the file
    const level = headingLevel || 2;
    const headingText = `${'#'.repeat(level)} ${heading}`;
    lines.push('', headingText, '');
    headingInfo = { line: lines.length - 2, level, text: heading };
    created = true;
  } else if (!headingInfo) {
    throw new McpError(BaseErrorCode.NOT_FOUND, `Heading "${heading}" not found. Set createHeading=true to create it.`);
  }
  const insertionPoint = getInsertionPoint(lines, headingInfo!, position);
  lines.splice(insertionPoint, 0, content);
  const newContent = lines.join('\n');
  await obsidianService.updateFileContent(filePath, newContent, context);
  return {
    success: true,
    operation: "insert_under_heading",
    filePath,
    heading,
    created,
    inserted: true,
    message: `Inserted content under heading "${heading}"${created ? " (created heading)" : ""}`,
  };
}
/**
 * Append content to a heading section.
 */
async function appendToHeading(
  filePath: string,
  heading: string,
  content: string,
  createHeading: boolean,
  obsidianService: ObsidianRestApiService,
  context: RequestContext,
  headingLevel?: number,
): Promise<BlockReferenceResult> {
  return await insertUnderHeading(filePath, heading, content, "before_next_heading", createHeading, obsidianService, context, headingLevel);
}
/**
 * Prepend content to a heading section.
 */
async function prependToHeading(
  filePath: string,
  heading: string,
  content: string,
  createHeading: boolean,
  obsidianService: ObsidianRestApiService,
  context: RequestContext,
  headingLevel?: number,
): Promise<BlockReferenceResult> {
  return await insertUnderHeading(filePath, heading, content, "after_heading", createHeading, obsidianService, context, headingLevel);
}
/**
 * Create a block reference for specific content.
 */
async function createBlockReference(
  filePath: string,
  content: string,
  blockId: string,
  obsidianService: ObsidianRestApiService,
  context: RequestContext,
): Promise<BlockReferenceResult> {
  const fileContent = await obsidianService.getFileContent(filePath, "markdown", context) as string;
  const lines = fileContent.split('\n');
  
  // Find if block ID already exists
  const existingBlock = lines.findIndex(line => line.includes(`^${blockId}`));
  if (existingBlock !== -1) {
    throw new McpError(BaseErrorCode.CONFLICT, `Block reference "^${blockId}" already exists in file`);
  }
  // Add the content with block reference
  const contentWithBlock = `${content} ^${blockId}`;
  lines.push('', contentWithBlock);
  const newContent = lines.join('\n');
  await obsidianService.updateFileContent(filePath, newContent, context);
  return {
    success: true,
    operation: "create_block_reference",
    filePath,
    blockId,
    content,
    inserted: true,
    message: `Created block reference "^${blockId}" with content`,
  };
}
/**
 * Helper function to find a heading in lines.
 */
function findHeading(lines: string[], targetHeading: string): { line: number; level: number; text: string } | null {
  for (let i = 0; i < lines.length; i++) {
    const line = lines[i];
    const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
    if (headingMatch) {
      const headingText = headingMatch[2].trim();
      if (headingText.toLowerCase() === targetHeading.toLowerCase()) {
        return {
          line: i,
          level: headingMatch[1].length,
          text: headingText,
        };
      }
    }
  }
  return null;
}
/**
 * Extract content under a heading.
 */
function extractHeadingContent(lines: string[], headingLine: number, headingLevel: number, includeSubheadings: boolean): string {
  const contentLines: string[] = [];
  
  for (let i = headingLine + 1; i < lines.length; i++) {
    const line = lines[i];
    const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
    
    if (headingMatch) {
      const currentLevel = headingMatch[1].length;
      if (currentLevel <= headingLevel) {
        // Found a heading at the same level or higher, stop here
        break;
      }
      if (!includeSubheadings && currentLevel > headingLevel) {
        // Skip subheadings if not including them
        continue;
      }
    }
    
    contentLines.push(line);
  }
  
  return contentLines.join('\n').trim();
}
/**
 * Get the insertion point for content based on position.
 */
function getInsertionPoint(lines: string[], headingInfo: { line: number; level: number }, position: string): number {
  switch (position) {
    case "after_heading":
      return headingInfo.line + 1;
    
    case "before_next_heading":
      // Find the next heading at the same level or higher
      for (let i = headingInfo.line + 1; i < lines.length; i++) {
        const line = lines[i];
        const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
        if (headingMatch && headingMatch[1].length <= headingInfo.level) {
          return i;
        }
      }
      return lines.length; // Insert at end if no next heading found
    
    case "start":
      return headingInfo.line + 1;
    
    case "end":
    default:
      // Find the end of this heading's section
      for (let i = headingInfo.line + 1; i < lines.length; i++) {
        const line = lines[i];
        const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
        if (headingMatch && headingMatch[1].length <= headingInfo.level) {
          return i;
        }
      }
      return lines.length; // Insert at end if no next heading found
  }
}