recipe-manager/src/backend/services/CopyMeThatTxtParser.ts

143 lines
3.9 KiB
TypeScript

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;
}
}