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

177 lines
5.4 KiB
TypeScript

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';
import { logError } from '../logger.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<ImportResult> {
const parsed = this.htmlParser.parseRecipes(html);
if (parsed.length === 0) {
return {
success: false,
imported: 0,
skipped: 0,
failed: 1, // one attempt at import failed to produce any valid recipe
recipes: [],
errors: ['No valid recipes found in the provided HTML.'],
};
}
return this.processRecipes(parsed, options);
}
/**
* Import recipes from multiple CopyMeThat TXT export files.
*/
async importFromTxtFiles(txtContents: { filename: string; content: string }[], options: ImportOptions = {}): Promise<ImportResult> {
const parsed: ParsedCopyMeThatTxtRecipe[] = [];
for (const { content } of txtContents) {
const recipe = this.txtParser.parseRecipe(content);
if (recipe) parsed.push(recipe);
}
if (parsed.length === 0) {
return {
success: false,
imported: 0,
skipped: 0,
failed: txtContents.length,
recipes: [],
errors: txtContents.map(f => `No valid recipe found in file: ${f.filename}`),
};
}
return this.processRecipes(parsed, options);
}
/**
* Core import logic: process parsed recipes and insert into database.
*/
private async processRecipes(
recipes: (ParsedCopyMeThatRecipe | ParsedCopyMeThatTxtRecipe)[],
options: ImportOptions
): Promise<ImportResult> {
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<string>();
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}`);
logError(`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;
}
}