import type { CreateRecipeInput } from '../types/recipe.js'; export interface ParsedCopyMeThatTxtRecipe { title: string; sourceUrl?: string; tags: string[]; made: boolean; servings?: string; ingredients: string[]; instructions: string[]; notes?: string; } /** * Parses CopyMeThat TXT export format. * Each .txt file contains one recipe. */ export class CopyMeThatTxtParser { /** * Parse a single .txt file content. */ parseRecipe(content: string): ParsedCopyMeThatTxtRecipe | null { try { const lines = content.split('\n').map(l => l.trim()); if (lines.length < 5) return null; // Too short to be valid const title = lines[0]; if (!title) return null; let sourceUrl: string | undefined; let tags: string[] = []; let made = false; let servings: string | undefined; // Parse header section let currentLine = 1; for (let i = 1; i < lines.length && i < 20; i++) { const line = lines[i]; if (line.startsWith('Adapted from ')) { sourceUrl = line.replace('Adapted from ', '').trim(); } else if (line.startsWith('tags:')) { tags = line.replace('tags:', '').split(',').map(t => t.trim()).filter(t => t); } else if (line.includes('I made this')) { made = true; } else if (line.startsWith('Servings:')) { servings = line.replace('Servings:', '').trim(); } else if (line === 'INGREDIENTS') { currentLine = i + 1; break; } } // Extract ingredients const ingredients: string[] = []; for (let i = currentLine; i < lines.length; i++) { const line = lines[i]; if (line === 'STEPS') { currentLine = i + 1; break; } if (line && line !== '') { ingredients.push(line); } } // Extract instructions const instructions: string[] = []; let hasNotes = false; for (let i = currentLine; i < lines.length; i++) { const line = lines[i]; if (line === 'NOTES' || line === 'NOTE') { currentLine = i + 1; hasNotes = true; break; } if (line && line !== '') { // Remove leading numbers like "1)", "2)", etc. const cleaned = line.replace(/^\d+\)\s*/, '').trim(); if (cleaned) instructions.push(cleaned); } } // Extract notes only if NOTES marker was present let notes: string | undefined; if (hasNotes && currentLine < lines.length) { const notesLines = lines.slice(currentLine).filter(l => l !== ''); if (notesLines.length > 0) { notes = notesLines.join('\n\n'); } } if (!title || ingredients.length === 0 || instructions.length === 0) { return null; // Invalid recipe } return { title, sourceUrl, tags, made, servings, ingredients, instructions, notes, }; } catch (error) { console.error('Error parsing TXT recipe:', error); return null; } } /** * Convert parsed recipe to CreateRecipeInput format. */ toCreateRecipeInput(parsed: ParsedCopyMeThatTxtRecipe): CreateRecipeInput { return { title: parsed.title, source_url: parsed.sourceUrl, made: parsed.made, notes: parsed.notes, servings: parsed.servings ? this.extractServingCount(parsed.servings) : undefined, ingredients: parsed.ingredients.map((item, index) => ({ item, position: index, })), steps: parsed.instructions.map((instruction, index) => ({ instruction, position: index, })), }; } /** * Try to extract numeric serving count from serving string. */ private extractServingCount(servingStr: string): number | undefined { const match = /(\d+)\s*servings?/i.exec(servingStr); return match ? parseInt(match[1], 10) : undefined; } }