import type { Database } from 'sql.js'; import { RecipeRepository } from '../repositories/RecipeRepository.js'; import { TagRepository } from '../repositories/TagRepository.js'; import { CopyMeThatHtmlParser, type ParsedCopyMeThatRecipe } from './CopyMeThatHtmlParser.js'; import { CopyMeThatTxtParser, type ParsedCopyMeThatTxtRecipe } from './CopyMeThatTxtParser.js'; import type { Recipe, CreateRecipeInput } from '../types/recipe.js'; export interface ImportOptions { skipDuplicates?: boolean; importImages?: boolean; } export interface ImportResult { success: boolean; imported: number; skipped: number; failed: number; recipes: Recipe[]; errors: string[]; } export class CopyMeThatImportService { private recipeRepo: RecipeRepository; private tagRepo: TagRepository; private htmlParser: CopyMeThatHtmlParser; private txtParser: CopyMeThatTxtParser; constructor(private db: Database) { this.recipeRepo = new RecipeRepository(db); this.tagRepo = new TagRepository(db); this.htmlParser = new CopyMeThatHtmlParser(); this.txtParser = new CopyMeThatTxtParser(); } /** * Import recipes from CopyMeThat HTML export file. */ async importFromHtml(html: string, options: ImportOptions = {}): Promise { const parsed = this.htmlParser.parseRecipes(html); return this.processRecipes(parsed, options); } /** * Import recipes from multiple CopyMeThat TXT export files. */ async importFromTxtFiles(txtContents: { filename: string; content: string }[], options: ImportOptions = {}): Promise { const parsed: ParsedCopyMeThatTxtRecipe[] = []; for (const { content } of txtContents) { const recipe = this.txtParser.parseRecipe(content); if (recipe) parsed.push(recipe); } return this.processRecipes(parsed, options); } /** * Core import logic: process parsed recipes and insert into database. */ private async processRecipes( recipes: (ParsedCopyMeThatRecipe | ParsedCopyMeThatTxtRecipe)[], options: ImportOptions ): Promise { const result: ImportResult = { success: true, imported: 0, skipped: 0, failed: 0, recipes: [], errors: [], }; // Precompute a Set of normalized keys for O(1) duplicate detection const existingRecipes = this.recipeRepo.findAll({ limit: 10000 }); const existingKeys = new Set(); for (const r of existingRecipes) { const key = `${r.title.toLowerCase()}|${r.source_url ?? ''}`; existingKeys.add(key); } for (const parsedRecipe of recipes) { try { // Check for duplicates if (options.skipDuplicates) { const key = `${parsedRecipe.title.toLowerCase()}|${parsedRecipe.sourceUrl ?? ''}`; if (existingKeys.has(key)) { result.skipped++; continue; } } // Get or create tags const tagIds: number[] = []; if ('tags' in parsedRecipe && parsedRecipe.tags.length > 0) { for (const tagName of parsedRecipe.tags) { let tag = this.tagRepo.findByName(tagName); if (!tag) { tag = this.tagRepo.create({ name: tagName }); } tagIds.push(tag.id); } } // Convert to CreateRecipeInput let input: CreateRecipeInput; if ('description' in parsedRecipe) { // HTML format has more fields input = this.htmlParser.toCreateRecipeInput(parsedRecipe as ParsedCopyMeThatRecipe); } else { // TXT format input = this.txtParser.toCreateRecipeInput(parsedRecipe as ParsedCopyMeThatTxtRecipe); } input.tagIds = tagIds; // Create recipe const created = this.recipeRepo.create(input); result.recipes.push(created); result.imported++; } catch (error) { result.failed++; const errorMsg = error instanceof Error ? error.message : 'Unknown error'; result.errors.push(`Failed to import "${parsedRecipe.title}": ${errorMsg}`); console.error(`Import error for "${parsedRecipe.title}":`, error); } } if (result.failed > 0) { result.success = false; } return result; } /** * Validate and clean an image URL path from CopyMeThat export. */ private cleanImageUrl(imageUrl: string | undefined): string | undefined { if (!imageUrl) return undefined; // If it's a relative path (images/...), we'll need to handle image uploads separately // For now, return undefined for relative paths if (imageUrl.startsWith('images/')) { return undefined; } // If it's an absolute URL, return as-is if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) { return imageUrl; } return undefined; } }