155 lines
4.8 KiB
TypeScript
155 lines
4.8 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';
|
|
|
|
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);
|
|
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);
|
|
}
|
|
|
|
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}`);
|
|
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;
|
|
}
|
|
}
|