143 lines
3.9 KiB
TypeScript
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;
|
|
}
|
|
}
|