bookwiz.io / lib / services / file-operations.ts
file-operations.ts
Raw
import { supabase } from '@/lib/supabase'
import type { FileSystemItem } from '@/lib/types/database'

export class FileOperationsService {
  private supabase: any

  constructor(customSupabaseClient?: any) {
    // Use custom Supabase client if provided (for server-side), otherwise use client-side
    this.supabase = customSupabaseClient || supabase
  }

  async searchFiles(bookId: string, query: string) {
    const { data: files } = await this.supabase
      .from('file_system_items')
      .select('*')
      .eq('book_id', bookId)
      .eq('type', 'file')
      .or(`name.ilike.%${query}%,content.ilike.%${query}%`)
      .limit(10)

    const result = files?.map((file: any) => ({
      id: file.id,
      name: file.name,
      file_extension: file.file_extension,
      content_preview: file.content ? file.content.substring(0, 200) + (file.content.length > 200 ? '...' : '') : ''
    })) || []
    
    console.log(`Search for "${query}" found ${result.length} files:`, result.map((f: any) => ({ id: f.id, name: f.name })))
    return result
  }

  async readFile(bookId: string, fileId: string) {
    const { data: file } = await this.supabase
      .from('file_system_items')
      .select('*')
      .eq('book_id', bookId)
      .eq('id', fileId)
      .eq('type', 'file')
      .single()

    if (!file) {
      throw new Error('File not found')
    }

    return {
      id: file.id,
      name: file.name,
      content: file.content || '',
      file_extension: file.file_extension
    }
  }

  async updateFile(bookId: string, fileId: string, newContent: string, changeSummary: string) {
    // First verify the file exists and is unique
    const { data: existingFile, error: checkError } = await this.supabase
      .from('file_system_items')
      .select('id, name')
      .eq('book_id', bookId)
      .eq('id', fileId)
      .eq('type', 'file')

    if (checkError) {
      throw new Error(`Failed to verify file: ${checkError.message}`)
    }

    if (!existingFile || existingFile.length === 0) {
      throw new Error('File not found')
    }

    if (existingFile.length > 1) {
      throw new Error(`Multiple files found with ID ${fileId}`)
    }

    // Update the file
    const { data: file, error } = await this.supabase
      .from('file_system_items')
      .update({ content: newContent })
      .eq('book_id', bookId)
      .eq('id', fileId)
      .eq('type', 'file')
      .select('id, name')
      .single()

    if (error) {
      throw new Error(`Failed to update file: ${error.message}`)
    }

    return {
      id: file.id,
      name: file.name,
      change_summary: changeSummary,
      success: true
    }
  }

  async listFiles(bookId: string, folderId?: string) {
    const query = this.supabase
      .from('file_system_items')
      .select('*')
      .eq('book_id', bookId)
      .order('type', { ascending: false }) // folders first
      .order('name', { ascending: true })

    if (folderId) {
      query.eq('parent_id', folderId)
    } else {
      query.is('parent_id', null) // root level
    }

    const { data: items } = await query

    return items?.map((item: any) => ({
      id: item.id,
      name: item.name,
      type: item.type,
      file_extension: item.file_extension,
      created_at: item.created_at,
      updated_at: item.updated_at,
      content_preview: item.type === 'file' && item.content 
        ? item.content.substring(0, 100) + (item.content.length > 100 ? '...' : '')
        : null
    })) || []
  }

  async createFile(bookId: string, name: string, content: string, parentFolderId?: string) {
    const { data: file, error } = await this.supabase
      .from('file_system_items')
      .insert({
        book_id: bookId,
        name,
        type: 'file',
        file_extension: 'md',
        content: content,
        parent_id: parentFolderId || null
      })
      .select()
      .single()

    if (error) {
      throw new Error(`Failed to create file: ${error.message}`)
    }

    return {
      id: file.id,
      name: file.name,
      type: file.type,
      file_extension: file.file_extension,
      success: true
    }
  }

  async createFolder(bookId: string, name: string, parentFolderId?: string) {
    const { data: folder, error } = await this.supabase
      .from('file_system_items')
      .insert({
        book_id: bookId,
        name,
        type: 'folder',
        expanded: false,
        parent_id: parentFolderId || null
      })
      .select()
      .single()

    if (error) {
      throw new Error(`Failed to create folder: ${error.message}`)
    }

    return {
      id: folder.id,
      name: folder.name,
      type: folder.type,
      success: true
    }
  }

  async deleteFile(bookId: string, fileId: string) {
    // First check if the item exists and get its info
    const { data: item } = await this.supabase
      .from('file_system_items')
      .select('name, type')
      .eq('book_id', bookId)
      .eq('id', fileId)
      .single()

    if (!item) {
      throw new Error('File or folder not found')
    }

    // Delete the item - CASCADE DELETE will handle children automatically
    const { error } = await this.supabase
      .from('file_system_items')
      .delete()
      .eq('book_id', bookId)
      .eq('id', fileId)

    if (error) {
      throw new Error(`Failed to delete ${item.type}: ${error.message}`)
    }

    return {
      name: item.name,
      type: item.type,
      success: true
    }
  }

  async getBookTitle(bookId: string): Promise<string> {
    const { data: book } = await this.supabase
      .from('books')
      .select('title')
      .eq('id', bookId)
      .single()
    
    return book?.title || 'your book'
  }

  async searchRelevantFiles(bookId: string, query: string, mentionedFiles: string[] = []): Promise<FileSystemItem[]> {
    const relevantFiles: FileSystemItem[] = []
    
    // First, get specifically mentioned files
    if (mentionedFiles.length > 0) {
      for (const mention of mentionedFiles) {
        const { data: files } = await this.supabase
          .from('file_system_items')
          .select('*')
          .eq('book_id', bookId)
          .eq('type', 'file')
          .or(`name.ilike.%${mention}%,content.ilike.%${mention}%`)
          .limit(5)
        
        if (files) {
          relevantFiles.push(...files)
        }
      }
    }
    
    // Try semantic search first (if available)
    try {
      const { semanticIndexingService } = await import('./semantic-indexing-service')
      const semanticResults = await semanticIndexingService.semanticSearch(
        bookId, 
        query, 
        { maxResults: 6, similarityThreshold: 0.01 }
      )
      
      if (semanticResults.length > 0) {
        // Get full file data for semantic results
        const fileIds = Array.from(new Set(semanticResults.map(r => r.file_id)))
        const { data: semanticFiles } = await this.supabase
          .from('file_system_items')
          .select('*')
          .in('id', fileIds)
        
        if (semanticFiles) {
          relevantFiles.push(...semanticFiles)
        }
      }
    } catch (error) {
      console.log('Semantic search not available, falling back to keyword search')
      
      // Fallback to keyword search
      const searchTerms = this.extractSearchTerms(query)
      if (searchTerms.length > 0) {
        for (const term of searchTerms.slice(0, 3)) { // Limit to top 3 terms
          const { data: files } = await this.supabase
            .from('file_system_items')
            .select('*')
            .eq('book_id', bookId)
            .eq('type', 'file')
            .ilike('content', `%${term}%`)
            .limit(3)
          
          if (files) {
            relevantFiles.push(...files)
          }
        }
      }
    }
    
    // Remove duplicates and limit total files
    const uniqueFiles = relevantFiles.filter((file, index, self) => 
      index === self.findIndex(f => f.id === file.id)
    )
    
    return uniqueFiles.slice(0, 8) // Limit to 8 files max
  }

  async getFileByName(bookId: string, fileName: string): Promise<FileSystemItem | null> {
    const { data: file } = await this.supabase
      .from('file_system_items')
      .select('*')
      .eq('book_id', bookId)
      .eq('name', fileName)
      .eq('type', 'file')
      .single()

    return file || null
  }

  async grepFiles(bookId: string, pattern: string, fileFilter?: string, caseSensitive: boolean = false, useRegex: boolean = false): Promise<any[]> {
    // First get all files, potentially filtered
    let query = this.supabase
      .from('file_system_items')
      .select('*')
      .eq('book_id', bookId)
      .eq('type', 'file')
      .not('content', 'is', null)

    // Apply file filter if provided
    if (fileFilter) {
      if (fileFilter.startsWith('*.')) {
        // Handle file extension filter
        const extension = fileFilter.substring(2)
        query = query.eq('file_extension', extension)
      } else {
        // Handle name pattern filter
        query = query.ilike('name', `%${fileFilter}%`)
      }
    }

    const { data: files } = await query.limit(50)

    if (!files || files.length === 0) {
      return []
    }

    const results: any[] = []
    let searchRegex: RegExp
    
    try {
      if (useRegex) {
        // Use the pattern as a regex
        const flags = caseSensitive ? 'g' : 'gi'
        searchRegex = new RegExp(pattern, flags)
      } else {
        // Simple string search - escape regex special characters
        const escapedPattern = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
        const flags = caseSensitive ? 'g' : 'gi'
        searchRegex = new RegExp(escapedPattern, flags)
      }
    } catch (error) {
      // If regex is invalid, fall back to simple string search
      const escapedPattern = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
      const flags = caseSensitive ? 'g' : 'gi'
      searchRegex = new RegExp(escapedPattern, flags)
    }

    for (const file of files) {
      if (!file.content) continue

      const lines = file.content.split('\n')
      const matches: any[] = []

      lines.forEach((line: string, index: number) => {
        // Reset regex lastIndex for global searches
        searchRegex.lastIndex = 0
        
        if (searchRegex.test(line)) {
          matches.push({
            line_number: index + 1,
            content: line.trim(),
            context_before: index > 0 ? lines[index - 1]?.trim() : null,
            context_after: index < lines.length - 1 ? lines[index + 1]?.trim() : null
          })
        }
      })

      if (matches.length > 0) {
        results.push({
          id: file.id,
          name: file.name,
          file_extension: file.file_extension,
          matches: matches.slice(0, 10), // Limit to 10 matches per file
          total_matches: matches.length
        })
      }
    }

    console.log(`Grep search for "${pattern}" found ${results.length} files with matches`)
    return results
  }

  private extractSearchTerms(query: string): string[] {
    const commonWords = new Set(['the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'about', 'how', 'what', 'when', 'where', 'why', 'who', 'can', 'could', 'would', 'should', 'is', 'are', 'was', 'were', 'be', 'been', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might', 'must', 'shall', 'i', 'you', 'he', 'she', 'it', 'we', 'they', 'me', 'him', 'her', 'us', 'them', 'my', 'your', 'his', 'her', 'its', 'our', 'their'])
    
    return query
      .toLowerCase()
      .replace(/[^\w\s]/g, ' ')
      .split(/\s+/)
      .filter(word => word.length > 2 && !commonWords.has(word))
      .slice(0, 5) // Limit to 5 key terms
  }
}