694 lines
21 KiB
TypeScript
694 lines
21 KiB
TypeScript
import { Injectable, NotFoundException } from '@nestjs/common';
|
|
import { Prisma } from '@prisma/client';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { CreateRecipeDto } from './dto/create-recipe.dto';
|
|
import { ParseMarkdownDto } from './dto/parse-markdown.dto';
|
|
import { downloadAndOptimizeImage } from '../common/utils/download-image';
|
|
|
|
const IMAGE_DEST_DIR = process.env.IMAGE_DEST_DIR || '/app/recipe-images';
|
|
|
|
// Lokala typdefiniitioner (tidigare från recipe-document-converter)
|
|
interface ParsedIngredient {
|
|
rawName: string;
|
|
quantity: number;
|
|
unit: string;
|
|
note: string | null;
|
|
}
|
|
|
|
interface ParsedRecipe {
|
|
name: string;
|
|
description: string;
|
|
instructions: string;
|
|
ingredients: ParsedIngredient[];
|
|
}
|
|
|
|
@Injectable()
|
|
export class RecipesService {
|
|
// Enhetsklassificering
|
|
private static readonly WEIGHT_UNITS = ['g', 'kg'];
|
|
private static readonly VOLUME_UNITS = ['ml', 'dl'];
|
|
private static readonly PORTION_UNITS = ['tsk', 'msk']; // tesked, matsked
|
|
private static readonly PIECE_UNITS = ['st']; // stycken
|
|
|
|
// Konverteringsregler för varje enhetstyp
|
|
private static readonly WEIGHT_CONVERSIONS: Record<string, number> = {
|
|
'g': 1,
|
|
'kg': 1000,
|
|
};
|
|
private static readonly VOLUME_CONVERSIONS: Record<string, number> = {
|
|
'ml': 1,
|
|
'dl': 100,
|
|
};
|
|
private static readonly PORTION_CONVERSIONS: Record<string, number> = {
|
|
'tsk': 1,
|
|
'msk': 3, // 1 matsked ≈ 3 teskedar
|
|
};
|
|
|
|
constructor(private readonly prisma: PrismaService) {}
|
|
|
|
/** Normalisera enheter (t.ex. "tesked" → "tsk", "milliliter" → "ml") */
|
|
private normalizeUnit(unit: string): string {
|
|
const normalized = unit.trim().toLowerCase();
|
|
const unitAliases: Record<string, string> = {
|
|
'tesked': 'tsk',
|
|
'test': 'tsk',
|
|
'matsked': 'msk',
|
|
'matsled': 'msk',
|
|
'milliliter': 'ml',
|
|
'deciliter': 'dl',
|
|
'gram': 'g',
|
|
'kilo': 'kg',
|
|
'kilogram': 'kg',
|
|
'stycke': 'st',
|
|
};
|
|
return unitAliases[normalized] || normalized;
|
|
}
|
|
|
|
/** Bestäm vilken enhetstyp en enhet tillhör */
|
|
private getUnitCategory(unit: string): string | null {
|
|
const normalized = this.normalizeUnit(unit);
|
|
if (RecipesService.WEIGHT_UNITS.includes(normalized)) return 'weight';
|
|
if (RecipesService.VOLUME_UNITS.includes(normalized)) return 'volume';
|
|
if (RecipesService.PORTION_UNITS.includes(normalized)) return 'portion';
|
|
if (RecipesService.PIECE_UNITS.includes(normalized)) return 'piece';
|
|
return null;
|
|
}
|
|
|
|
/** Kontrollera om en enhet är viktbaserad */
|
|
private isWeightUnit(unit: string): boolean {
|
|
return this.getUnitCategory(unit) === 'weight';
|
|
}
|
|
|
|
/** Kontrollera om en enhet är volymbaserad */
|
|
private isVolumeUnit(unit: string): boolean {
|
|
return this.getUnitCategory(unit) === 'volume';
|
|
}
|
|
|
|
/**
|
|
* Konverterar kvantitet mellan enheter för en given produkt.
|
|
* Stödjer vikt (g/kg), volym (ml/dl) och svenska måttenheter (tsk/msk).
|
|
* Konverterar endast inom samma enhetstyp.
|
|
*
|
|
* @throws Error om quantity är negativ/noll, enheter är tomma, eller enheter är inkompatibla
|
|
*/
|
|
private convertUnit(quantity: number, fromUnit: string, toUnit: string, productName: string): number {
|
|
// Input validation
|
|
if (quantity <= 0) {
|
|
throw new Error(`Invalid quantity: ${quantity}. Quantity must be positive.`);
|
|
}
|
|
if (!fromUnit?.trim()) {
|
|
throw new Error('From unit cannot be empty.');
|
|
}
|
|
if (!toUnit?.trim()) {
|
|
throw new Error('To unit cannot be empty.');
|
|
}
|
|
if (!productName?.trim()) {
|
|
throw new Error('Product name cannot be empty.');
|
|
}
|
|
|
|
// Normalisera och kontrollera enheter
|
|
const normalizedFromUnit = this.normalizeUnit(fromUnit);
|
|
const normalizedToUnit = this.normalizeUnit(toUnit);
|
|
|
|
// Om enheterna är identiska efter normalisering, returnera direkt
|
|
if (normalizedFromUnit === normalizedToUnit) {
|
|
return quantity;
|
|
}
|
|
|
|
// Bestäm enhetstyp
|
|
const fromCategory = this.getUnitCategory(normalizedFromUnit);
|
|
const toCategory = this.getUnitCategory(normalizedToUnit);
|
|
|
|
if (!fromCategory) {
|
|
throw new Error(`Unknown unit: "${fromUnit}"`);
|
|
}
|
|
if (!toCategory) {
|
|
throw new Error(`Unknown unit: "${toUnit}"`);
|
|
}
|
|
|
|
// Konvertera endast inom samma enhetstyp
|
|
if (fromCategory !== toCategory) {
|
|
throw new Error(
|
|
`Cannot convert between incompatible unit types: "${fromUnit}" (${fromCategory}) and "${toUnit}" (${toCategory}) for product "${productName}"`,
|
|
);
|
|
}
|
|
|
|
// Hämta rätt konverteringstabll baserat på enhetstyp
|
|
let conversions: Record<string, number>;
|
|
switch (fromCategory) {
|
|
case 'weight':
|
|
conversions = RecipesService.WEIGHT_CONVERSIONS;
|
|
break;
|
|
case 'volume':
|
|
conversions = RecipesService.VOLUME_CONVERSIONS;
|
|
break;
|
|
case 'portion':
|
|
conversions = RecipesService.PORTION_CONVERSIONS;
|
|
break;
|
|
case 'piece':
|
|
// Kan inte konvertera stycken
|
|
return quantity;
|
|
default:
|
|
throw new Error(`Unknown unit category: ${fromCategory}`);
|
|
}
|
|
|
|
// Konvertera via basenhet
|
|
return (quantity * conversions[normalizedFromUnit]) / conversions[normalizedToUnit];
|
|
}
|
|
|
|
// --- ÖVRIGA METODER (findAll, findOne, create) OFÖRÄNDRADE ---
|
|
|
|
async getInventoryPreview(id: number) {
|
|
const recipe = await this.prisma.recipe.findUnique({
|
|
where: { id },
|
|
include: {
|
|
ingredients: {
|
|
include: {
|
|
product: true,
|
|
},
|
|
orderBy: {
|
|
id: 'asc',
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!recipe) {
|
|
throw new NotFoundException(`Recipe with id ${id} not found`);
|
|
}
|
|
|
|
const ingredientPreviews = await Promise.all(
|
|
recipe.ingredients.map(async (ingredient: any) => {
|
|
const inventoryItems = await this.prisma.inventoryItem.findMany({
|
|
where: { productId: ingredient.productId },
|
|
orderBy: { createdAt: 'desc' },
|
|
});
|
|
|
|
// Hitta inventory-poster med samma enhet
|
|
const sameUnitItems = inventoryItems.filter(
|
|
(item: any) => item.unit.trim().toLowerCase() === ingredient.unit.trim().toLowerCase(),
|
|
);
|
|
const availableSameUnit = sameUnitItems.reduce(
|
|
(sum: number, item: any) => sum + Number(item.quantity),
|
|
0,
|
|
);
|
|
|
|
// Hitta inventory-poster med annan enhet och konvertera (endast viktbaserade enheter)
|
|
const otherUnitItems = inventoryItems.filter(
|
|
(item: any) => item.unit.trim().toLowerCase() !== ingredient.unit.trim().toLowerCase(),
|
|
);
|
|
let availableOtherUnit = 0;
|
|
|
|
for (const item of otherUnitItems) {
|
|
// Konvertera endast om enheter är kompatibla (samma kategori)
|
|
try {
|
|
const convertedQuantity = this.convertUnit(
|
|
Number(item.quantity),
|
|
item.unit,
|
|
ingredient.unit,
|
|
ingredient.product.name,
|
|
);
|
|
availableOtherUnit += convertedQuantity;
|
|
} catch {
|
|
// Om konvertering misslyckas, hoppa över denna post
|
|
// (t.ex. st kan inte konverteras till g)
|
|
}
|
|
}
|
|
|
|
const totalAvailable = availableSameUnit + availableOtherUnit;
|
|
let status: 'enough' | 'missing' | 'unit_mismatch';
|
|
|
|
if (totalAvailable >= Number(ingredient.quantity)) {
|
|
status = 'enough';
|
|
} else if (availableSameUnit === 0 && availableOtherUnit > 0) {
|
|
status = 'unit_mismatch';
|
|
} else {
|
|
status = 'missing';
|
|
}
|
|
|
|
return {
|
|
ingredientId: ingredient.id,
|
|
productId: ingredient.productId,
|
|
productName: ingredient.product.canonicalName || ingredient.product.name,
|
|
requiredQuantity: Number(ingredient.quantity),
|
|
requiredUnit: ingredient.unit,
|
|
note: ingredient.note,
|
|
availableQuantity: totalAvailable,
|
|
availableUnit: ingredient.unit,
|
|
matchingInventoryItems: sameUnitItems.map((item: any) => ({
|
|
id: item.id,
|
|
quantity: item.quantity,
|
|
unit: item.unit,
|
|
location: item.location,
|
|
brand: item.brand || null,
|
|
bestBeforeDate: item.bestBeforeDate || null,
|
|
})),
|
|
otherInventoryItems: otherUnitItems.map((item: any) => {
|
|
// Kolla om konvertering är möjlig (samma enhetskategori)
|
|
const canConvert = this.getUnitCategory(item.unit) === this.getUnitCategory(ingredient.unit)
|
|
&& this.getUnitCategory(ingredient.unit) !== 'piece';
|
|
let convertedQuantity = 0;
|
|
if (canConvert) {
|
|
try {
|
|
convertedQuantity = this.convertUnit(Number(item.quantity), item.unit, ingredient.unit, ingredient.product.name);
|
|
} catch {
|
|
convertedQuantity = 0;
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: item.id,
|
|
quantity: item.quantity,
|
|
unit: item.unit,
|
|
location: item.location,
|
|
convertedQuantity: canConvert ? convertedQuantity : 0,
|
|
canConvert,
|
|
};
|
|
}),
|
|
status,
|
|
missingQuantity: status === 'missing' ? Math.max(0, Number(ingredient.quantity) - totalAvailable) : 0,
|
|
};
|
|
}),
|
|
);
|
|
|
|
const summary = {
|
|
totalIngredients: ingredientPreviews.length,
|
|
enoughCount: ingredientPreviews.filter((i: any) => i.status === 'enough').length,
|
|
missingCount: ingredientPreviews.filter((i: any) => i.status === 'missing').length,
|
|
unitMismatchCount: ingredientPreviews.filter((i: any) => i.status === 'unit_mismatch').length,
|
|
canCookExactly: ingredientPreviews.every((i: any) => i.status === 'enough'),
|
|
};
|
|
|
|
return {
|
|
recipe: {
|
|
id: recipe.id,
|
|
name: recipe.name,
|
|
description: recipe.description,
|
|
},
|
|
ingredients: ingredientPreviews,
|
|
summary,
|
|
};
|
|
}
|
|
|
|
async findAll() {
|
|
return this.prisma.recipe.findMany({
|
|
include: {
|
|
ingredients: {
|
|
include: {
|
|
product: { include: { nutrition: true } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
async findOne(id: number) {
|
|
const recipe = await this.prisma.recipe.findUnique({
|
|
where: { id },
|
|
include: {
|
|
ingredients: {
|
|
include: {
|
|
product: { include: { nutrition: true } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!recipe) {
|
|
throw new NotFoundException(`Recipe with id ${id} not found`);
|
|
}
|
|
|
|
return recipe;
|
|
}
|
|
|
|
async update(id: number, updateRecipeDto: CreateRecipeDto) {
|
|
// Verifiera att receptet finns
|
|
const existingRecipe = await this.prisma.recipe.findUnique({
|
|
where: { id },
|
|
});
|
|
|
|
if (!existingRecipe) {
|
|
throw new NotFoundException(`Recipe with id ${id} not found`);
|
|
}
|
|
|
|
// Ta bort gamla ingredienser
|
|
await this.prisma.recipeIngredient.deleteMany({
|
|
where: { recipeId: id },
|
|
});
|
|
|
|
// Uppdatera receptet och lägg till nya ingredienser
|
|
const recipe = await this.prisma.recipe.update({
|
|
where: { id },
|
|
data: {
|
|
name: updateRecipeDto.name,
|
|
description: updateRecipeDto.description || null,
|
|
instructions: updateRecipeDto.instructions || null,
|
|
servings: updateRecipeDto.servings ?? null,
|
|
...(updateRecipeDto.imageUrl !== undefined && { imageUrl: updateRecipeDto.imageUrl || null }),
|
|
ingredients: {
|
|
create: updateRecipeDto.ingredients.map((ingredient) => ({
|
|
productId: ingredient.productId,
|
|
quantity: ingredient.quantity,
|
|
unit: ingredient.unit,
|
|
note: ingredient.note || null,
|
|
})),
|
|
},
|
|
},
|
|
include: {
|
|
ingredients: {
|
|
include: {
|
|
product: { include: { nutrition: true } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
return recipe;
|
|
}
|
|
|
|
async remove(id: number) {
|
|
const existingRecipe = await this.prisma.recipe.findUnique({
|
|
where: { id },
|
|
});
|
|
|
|
if (!existingRecipe) {
|
|
throw new NotFoundException(`Recipe with id ${id} not found`);
|
|
}
|
|
|
|
await this.prisma.recipeIngredient.deleteMany({ where: { recipeId: id } });
|
|
await this.prisma.recipe.delete({ where: { id } });
|
|
}
|
|
|
|
async updateImage(id: number, sourceUrl: string) {
|
|
const existingRecipe = await this.prisma.recipe.findUnique({ where: { id } });
|
|
if (!existingRecipe) {
|
|
throw new NotFoundException(`Recipe with id ${id} not found`);
|
|
}
|
|
|
|
const imageUrl = await downloadAndOptimizeImage(sourceUrl, IMAGE_DEST_DIR);
|
|
|
|
return this.prisma.recipe.update({
|
|
where: { id },
|
|
data: { imageUrl },
|
|
include: { ingredients: { include: { product: { include: { nutrition: true } } } } },
|
|
});
|
|
}
|
|
|
|
async create(createRecipeDto: CreateRecipeDto) {
|
|
// Om imageUrl är en extern URL — ladda ner och optimera
|
|
let imageUrl: string | null = createRecipeDto.imageUrl || null;
|
|
if (imageUrl && imageUrl.startsWith('http')) {
|
|
try {
|
|
imageUrl = await downloadAndOptimizeImage(imageUrl, IMAGE_DEST_DIR);
|
|
} catch (err) {
|
|
console.warn('[RecipesService] Kunde inte ladda ner receptbild:', err);
|
|
imageUrl = null;
|
|
}
|
|
}
|
|
|
|
const recipe = await this.prisma.recipe.create({
|
|
data: {
|
|
name: createRecipeDto.name,
|
|
description: createRecipeDto.description || null,
|
|
instructions: createRecipeDto.instructions || null,
|
|
imageUrl,
|
|
servings: createRecipeDto.servings ?? null,
|
|
ingredients: {
|
|
create: createRecipeDto.ingredients.map((ingredient) => ({
|
|
productId: ingredient.productId,
|
|
quantity: ingredient.quantity,
|
|
unit: ingredient.unit,
|
|
note: ingredient.note || null,
|
|
})),
|
|
},
|
|
},
|
|
include: {
|
|
ingredients: {
|
|
include: {
|
|
product: { include: { nutrition: true } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
return recipe;
|
|
}
|
|
|
|
async parseMarkdown(dto: ParseMarkdownDto) {
|
|
const parsed = parseRecipeMarkdown(dto.markdown);
|
|
|
|
const allProducts = await this.prisma.product.findMany({
|
|
where: { isActive: true },
|
|
select: { id: true, name: true, canonicalName: true, normalizedName: true },
|
|
});
|
|
|
|
// Normalisera en sträng för jämförelse (lowercase, trim, ta bort skiljetecken)
|
|
const normalize = (s: string) =>
|
|
s.toLowerCase().trim().replace(/[^a-zåäö0-9\s]/gi, '').replace(/\s+/g, ' ');
|
|
|
|
// Enkel Levenshtein-distans
|
|
const levenshtein = (a: string, b: string): number => {
|
|
const m = a.length;
|
|
const n = b.length;
|
|
const dp: number[][] = Array.from({ length: m + 1 }, (_, i) =>
|
|
Array.from({ length: n + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
|
|
);
|
|
for (let i = 1; i <= m; i++) {
|
|
for (let j = 1; j <= n; j++) {
|
|
dp[i][j] =
|
|
a[i - 1] === b[j - 1]
|
|
? dp[i - 1][j - 1]
|
|
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
|
|
}
|
|
}
|
|
return dp[m][n];
|
|
};
|
|
|
|
const ingredientsWithSuggestions = parsed.ingredients.map((ingredient: ParsedIngredient) => {
|
|
const query = normalize(ingredient.rawName);
|
|
|
|
const scored = allProducts
|
|
.map((product) => {
|
|
const targetName = normalize(product.canonicalName || product.name);
|
|
const targetNormalized = normalize(product.normalizedName);
|
|
|
|
// Exakt träff på normalizedName prioriteras
|
|
if (targetNormalized === query || targetName === query) {
|
|
return { product, score: 100 };
|
|
}
|
|
|
|
// Delsträng-match
|
|
if (targetName.includes(query) || query.includes(targetName)) {
|
|
return { product, score: 70 };
|
|
}
|
|
|
|
// Levenshtein-baserad likhet
|
|
const dist = levenshtein(query, targetName);
|
|
const maxLen = Math.max(query.length, targetName.length);
|
|
const similarity = maxLen === 0 ? 100 : Math.round((1 - dist / maxLen) * 100);
|
|
|
|
return { product, score: similarity };
|
|
})
|
|
.filter((s) => s.score >= 40)
|
|
.sort((a, b) => b.score - a.score)
|
|
.slice(0, 5)
|
|
.map((s) => ({
|
|
productId: s.product.id,
|
|
productName: s.product.canonicalName || s.product.name,
|
|
score: s.score,
|
|
}));
|
|
|
|
return {
|
|
rawName: ingredient.rawName,
|
|
quantity: ingredient.quantity,
|
|
unit: ingredient.unit,
|
|
note: ingredient.note,
|
|
suggestions: scored,
|
|
};
|
|
});
|
|
|
|
return {
|
|
name: parsed.name,
|
|
description: parsed.description,
|
|
instructions: parsed.instructions,
|
|
ingredients: ingredientsWithSuggestions,
|
|
};
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Parser Functions (previously from recipe-document-converter library)
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Parsar ett recept i Markdown-format och extraherar namn, beskrivning,
|
|
* instruktioner och ingredienser.
|
|
*
|
|
* Förväntat format:
|
|
* # Receptnamn
|
|
* Beskrivning (valfritt stycke efter titeln)
|
|
*
|
|
* ## Ingredienser
|
|
* - 400 g kycklingfilé
|
|
* - 2 dl grädde (eller crème fraiche)
|
|
*
|
|
* ## Instruktioner
|
|
* 1. Stek kycklingen …
|
|
*/
|
|
function parseRecipeMarkdown(markdown: string): ParsedRecipe {
|
|
const lines = markdown.split('\n');
|
|
|
|
let name = '';
|
|
let description = '';
|
|
let instructions = '';
|
|
const ingredients: ParsedIngredient[] = [];
|
|
|
|
let currentSection: 'none' | 'description' | 'ingredients' | 'instructions' = 'none';
|
|
const descriptionLines: string[] = [];
|
|
const instructionLines: string[] = [];
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
|
|
// H1 — receptnamn
|
|
if (/^#\s+/.test(trimmed) && !trimmed.startsWith('##')) {
|
|
name = trimmed.replace(/^#\s+/, '').trim();
|
|
currentSection = 'description';
|
|
continue;
|
|
}
|
|
|
|
// H2 — sektionsrubriker
|
|
if (/^##\s+/.test(trimmed)) {
|
|
const heading = trimmed.replace(/^##\s+/, '').trim().toLowerCase();
|
|
if (/ingrediens/.test(heading)) {
|
|
currentSection = 'ingredients';
|
|
} else if (/instruktion|tillagning|gör så här|steg|tillväg|metod/.test(heading)) {
|
|
currentSection = 'instructions';
|
|
} else {
|
|
currentSection = 'none';
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Samla rader beroende på sektion
|
|
switch (currentSection) {
|
|
case 'description':
|
|
if (trimmed.length > 0) {
|
|
descriptionLines.push(trimmed);
|
|
}
|
|
break;
|
|
|
|
case 'ingredients':
|
|
if (/^[-*]\s+/.test(trimmed)) {
|
|
const ingredientText = trimmed.replace(/^[-*]\s+/, '');
|
|
ingredients.push(parseIngredientLine(ingredientText));
|
|
}
|
|
break;
|
|
|
|
case 'instructions':
|
|
if (trimmed.length > 0) {
|
|
instructionLines.push(trimmed);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
description = descriptionLines.join('\n');
|
|
instructions = instructionLines.join('\n');
|
|
|
|
return { name, description, instructions, ingredients };
|
|
}
|
|
|
|
/**
|
|
* Parsar en ingrediensrad, t.ex.:
|
|
* "400 g kycklingfilé"
|
|
* "2 dl grädde (eller crème fraiche)"
|
|
* "1 1/2 dl crème fraiche"
|
|
* "1 polka- eller gulbeta"
|
|
* "1 kruka basilika"
|
|
* "salt"
|
|
*/
|
|
function parseIngredientLine(text: string): ParsedIngredient {
|
|
const trimmed = text.trim();
|
|
|
|
// Kända enheter
|
|
const knownUnits = [
|
|
'g', 'kg', 'hg', 'mg', 'ml', 'dl', 'l', 'tl',
|
|
'st', 'tsk', 'msk', 'krm', 'matsled', 'tesled',
|
|
'pris', 'portion', 'port', 'burk', 'förp', 'paket', 'efter smak', 'klyfta',
|
|
];
|
|
|
|
// Extrahera eventuell parentes-not i slutet
|
|
let note: string | null = null;
|
|
let main = trimmed;
|
|
const parenMatch = trimmed.match(/\(([^)]+)\)\s*$/);
|
|
if (parenMatch) {
|
|
note = parenMatch[1].trim();
|
|
main = trimmed.slice(0, parenMatch.index).trim();
|
|
}
|
|
|
|
// Försök matcha bråk först: "1 1/2 dl crème fraiche" eller "1/2 dl"
|
|
const fractionMatch = main.match(/^(\d+)?\s*(\d+)\s*\/\s*([\d.]+)\s+(\S+)\s+(.*)$/);
|
|
if (fractionMatch) {
|
|
let quantity = 0;
|
|
if (fractionMatch[1]) {
|
|
quantity = parseFloat(fractionMatch[1]) + parseFloat(fractionMatch[2]) / parseFloat(fractionMatch[3]);
|
|
} else {
|
|
quantity = parseFloat(fractionMatch[2]) / parseFloat(fractionMatch[3]);
|
|
}
|
|
const candidateUnit = fractionMatch[4].toLowerCase();
|
|
if (knownUnits.includes(candidateUnit)) {
|
|
return {
|
|
quantity,
|
|
unit: candidateUnit,
|
|
rawName: fractionMatch[5].trim(),
|
|
note,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Försök matcha "kvantitet enhet namn" — t.ex. "400 g kycklingfilé" eller "2.5 dl grädde"
|
|
const fullMatch = main.match(/^(\d+(?:[.,]\d+)?)\s+(\S+)\s+(.+)$/);
|
|
if (fullMatch) {
|
|
const candidateUnit = fullMatch[2].toLowerCase();
|
|
// Validera att det andra ordet är en känd enhet
|
|
if (knownUnits.includes(candidateUnit)) {
|
|
return {
|
|
quantity: parseNumber(fullMatch[1]),
|
|
unit: candidateUnit,
|
|
rawName: fullMatch[3].trim(),
|
|
note,
|
|
};
|
|
}
|
|
// Om inte känd enhet, behandla som "kvantitet namn" utan enhet
|
|
return {
|
|
quantity: parseNumber(fullMatch[1]),
|
|
unit: 'st',
|
|
rawName: fullMatch[2] + ' ' + fullMatch[3],
|
|
note,
|
|
};
|
|
}
|
|
|
|
// Försök matcha "kvantitet namn" utan enhet — t.ex. "3 ägg"
|
|
const noUnitMatch = main.match(/^(\d+(?:[.,]\d+)?)\s+(.+)$/);
|
|
if (noUnitMatch) {
|
|
return {
|
|
quantity: parseNumber(noUnitMatch[1]),
|
|
unit: 'st',
|
|
rawName: noUnitMatch[2].trim(),
|
|
note,
|
|
};
|
|
}
|
|
|
|
// Bara ett namn, ingen kvantitet — t.ex. "salt"
|
|
return {
|
|
quantity: 0,
|
|
unit: '',
|
|
rawName: main,
|
|
note,
|
|
};
|
|
}
|
|
|
|
function parseNumber(s: string): number {
|
|
return parseFloat(s.replace(',', '.'));
|
|
} |