export const DROPS_PER_ML = 18.6 export const OIL_HOMOPHONES = { '相貌':'香茅','香矛':'香茅','向茅':'香茅','像茅':'香茅', '如香':'乳香','儒香':'乳香', '古巴想':'古巴香脂','古巴香':'古巴香脂','古巴相脂':'古巴香脂', '博荷':'薄荷','薄河':'薄荷', '尤佳利':'尤加利','优加利':'尤加利', '依兰':'依兰依兰', '雪松木':'雪松', '桧木':'扁柏','桧柏':'扁柏', '永久化':'永久花','永久华':'永久花', '罗马洋柑菊':'罗马洋甘菊','洋甘菊':'罗马洋甘菊', '天竹葵':'天竺葵','天竺癸':'天竺葵', '没要':'没药','莫药':'没药', '快乐鼠尾':'快乐鼠尾草', '椒样博荷':'椒样薄荷','椒样薄和':'椒样薄荷', '丝柏木':'丝柏', '柠檬草油':'柠檬草', '茶树油':'茶树', '薰衣草油':'薰衣草', '玫瑰花':'玫瑰', } /** * Levenshtein edit distance between two strings */ export function editDistance(a, b) { const m = a.length, n = b.length const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0)) for (let i = 0; i <= m; i++) dp[i][0] = i for (let j = 0; j <= n; j++) dp[0][j] = j for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { if (a[i - 1] === b[j - 1]) { dp[i][j] = dp[i - 1][j - 1] } else { dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]) } } } return dp[m][n] } /** * Fuzzy match oil name against known list. * Priority: homophone -> exact -> substring -> missing-char -> edit distance * Returns matched oil name or null. */ export function findOil(input, oilNames) { if (!input || input.length === 0) return null const trimmed = input.trim() if (!trimmed) return null // 1. Homophone alias check if (OIL_HOMOPHONES[trimmed]) { const alias = OIL_HOMOPHONES[trimmed] if (oilNames.includes(alias)) return alias } // 2. Exact match if (oilNames.includes(trimmed)) return trimmed // 3. Substring match (input ⊂ name or name ⊂ input), prefer longest let substringMatches = [] for (const name of oilNames) { if (name.includes(trimmed) || trimmed.includes(name)) { substringMatches.push(name) } } if (substringMatches.length > 0) { substringMatches.sort((a, b) => b.length - a.length) return substringMatches[0] } // 4. "Missing one char" match - input is one char shorter than an oil name for (const name of oilNames) { if (Math.abs(name.length - trimmed.length) === 1) { const longer = name.length > trimmed.length ? name : trimmed const shorter = name.length > trimmed.length ? trimmed : name // Check if shorter can be formed by removing one char from longer for (let i = 0; i < longer.length; i++) { const candidate = longer.slice(0, i) + longer.slice(i + 1) if (candidate === shorter) return name } } } // 5. Edit distance fuzzy match let bestMatch = null let bestDist = Infinity for (const name of oilNames) { const dist = editDistance(trimmed, name) const maxLen = Math.max(trimmed.length, name.length) // Only accept if edit distance is reasonable (less than half the length) if (dist < bestDist && dist <= Math.floor(maxLen / 2)) { bestDist = dist bestMatch = name } } return bestMatch } /** * Greedy longest-match from concatenated string against oil names. * Returns array of matched oil names in order. */ export function greedyMatchOils(text, oilNames) { const results = [] let i = 0 while (i < text.length) { let bestMatch = null let bestLen = 0 // Try all oil names sorted by length (longest first) const sorted = [...oilNames].sort((a, b) => b.length - a.length) for (const name of sorted) { if (text.substring(i, i + name.length) === name) { bestMatch = name bestLen = name.length break } } // Also check homophones if (!bestMatch) { for (const [alias, canonical] of Object.entries(OIL_HOMOPHONES)) { if (text.substring(i, i + alias.length) === alias) { if (!bestMatch || alias.length > bestLen) { bestMatch = canonical bestLen = alias.length } } } } if (bestMatch) { results.push(bestMatch) i += bestLen } else { i++ } } return results } /** * Parse text chunk into [{oil, drops}] pairs. * Handles formats like "芳香调理8永久花10" or "薰衣草 3滴 茶树 2ml" */ export function parseOilChunk(text, oilNames) { const results = [] const regex = /([^\d]+?)(\d+\.?\d*)\s*(ml|毫升|ML|mL|滴)?/g let match while ((match = regex.exec(text)) !== null) { const namePart = match[1].trim() let amount = parseFloat(match[2]) const unit = match[3] || '' // Convert ml to drops if (unit && (unit.toLowerCase() === 'ml' || unit === '毫升')) { amount = Math.round(amount * 20) } // Try greedy match on the name part const matched = greedyMatchOils(namePart, oilNames) if (matched.length > 0) { // Last matched oil gets the drops for (let i = 0; i < matched.length - 1; i++) { results.push({ oil: matched[i], drops: 0 }) } results.push({ oil: matched[matched.length - 1], drops: amount }) } else { // Try findOil as fallback const found = findOil(namePart, oilNames) if (found) { results.push({ oil: found, drops: amount }) } else if (namePart) { results.push({ oil: namePart, drops: amount, notFound: true }) } } } return results } /** * Split multi-recipe input by blank lines or semicolons. * Detects recipe boundaries (non-oil text after seeing oils = new recipe). */ export function splitRawIntoBlocks(raw, oilNames) { // First split by semicolons let parts = raw.split(/[;;]/) // Then split each part by blank lines let blocks = [] for (const part of parts) { const subBlocks = part.split(/\n\s*\n/) blocks.push(...subBlocks) } // Filter empty blocks blocks = blocks.map(b => b.trim()).filter(b => b.length > 0) return blocks } /** * Parse one recipe block into {name, ingredients, notFound}. * 1. Split by commas/newlines/etc * 2. First non-oil, non-number part = recipe name * 3. Rest parsed through parseOilChunk * 4. Deduplicate ingredients */ export function parseSingleBlock(raw, oilNames) { // Split by commas, Chinese commas, newlines, spaces const parts = raw.split(/[,,\n\r]+/).map(s => s.trim()).filter(s => s) let name = '' let ingredientParts = [] let foundFirstOil = false for (const part of parts) { // Check if this part contains oil references const hasNumber = /\d/.test(part) const hasOil = oilNames.some(oil => part.includes(oil)) || Object.keys(OIL_HOMOPHONES).some(alias => part.includes(alias)) if (!foundFirstOil && !hasOil && !hasNumber && !name) { // This is the recipe name name = part } else { foundFirstOil = true ingredientParts.push(part) } } // Parse all ingredient parts const allIngredients = [] const notFound = [] for (const part of ingredientParts) { const parsed = parseOilChunk(part, oilNames) for (const item of parsed) { if (item.notFound) { notFound.push(item.oil) } else { allIngredients.push(item) } } } // Deduplicate: merge same oil, sum drops const deduped = [] const seen = {} for (const item of allIngredients) { if (seen[item.oil] !== undefined) { deduped[seen[item.oil]].drops += item.drops } else { seen[item.oil] = deduped.length deduped.push({ ...item }) } } return { name: name || '未命名配方', ingredients: deduped, notFound } }