diff --git a/js/measurements.js b/js/measurements.js index 13a4079..6dad318 100644 --- a/js/measurements.js +++ b/js/measurements.js @@ -159,15 +159,31 @@ spans.forEach(function (span) { var type = span.getAttribute('data-measurement-type'); var isHybrid = span.getAttribute('data-hybrid') === 'true'; - var useVolumeMode = isHybrid && measureByVolume; - var effectiveType = useVolumeMode ? 'volume' : type; + var scalableAttr, defaultAttr, altAttr, effectiveType; + + if (isHybrid && measureByVolume && type === 'weight') { + // weight measurement in volume mode -> use volume data + scalableAttr = 'data-volume-scalable'; + defaultAttr = 'data-volume-default'; + altAttr = 'data-volume-alt'; + effectiveType = 'volume'; + } else if (isHybrid && !measureByVolume && type === 'volume') { + // volume measurement in weight mode -> use weight data + scalableAttr = 'data-weight-scalable'; + defaultAttr = 'data-weight-default'; + altAttr = 'data-weight-alt'; + effectiveType = 'weight'; + } else { + // default: use primary data + scalableAttr = 'data-scalable'; + defaultAttr = 'data-default'; + altAttr = 'data-alt'; + effectiveType = type; + } + var imperial = !!showingAlt[effectiveType]; - var scalableAttr = useVolumeMode ? 'data-volume-scalable' : 'data-scalable'; - var defaultAttr = useVolumeMode ? 'data-volume-default' : 'data-default'; - var altAttr = useVolumeMode ? 'data-volume-alt' : 'data-alt'; - var scalableRaw = span.getAttribute(scalableAttr); if (scalableRaw && scale !== 1) { @@ -258,9 +274,10 @@ if (span.getAttribute('data-hybrid') === 'true') hasHybrid = true; }); - // Hybrid measurements in volume mode need the volume metric/imperial toggle + // Hybrid measurements need both weight and volume metric/imperial toggles if (hasHybrid) { typeHasToggle['volume'] = true; + typeHasToggle['weight'] = true; } var hasAny = false; diff --git a/lib/measurements/__tests__/plugin.test.js b/lib/measurements/__tests__/plugin.test.js index c2a408c..269dcb6 100644 --- a/lib/measurements/__tests__/plugin.test.js +++ b/lib/measurements/__tests__/plugin.test.js @@ -272,9 +272,12 @@ describe('hybrid weight/volume measurements', () => { expect(html).toContain('data-volume-scalable='); }); - it('does NOT add hybrid attributes for weight-to-weight alt', () => { - const md = '- butter 227g (8 oz)'; + it('does NOT add manual hybrid attributes for weight-to-weight alt', () => { + // weight-to-weight alt doesn't produce manual hybrid volume data, + // but density-based auto hybrid will still kick in for known ingredients + const md = '- cheddar 227g (8 oz)'; const html = render(md); + // "cheddar" is not in the density table, so no hybrid at all expect(html).not.toContain('data-hybrid'); }); @@ -323,4 +326,196 @@ describe('hybrid weight/volume measurements', () => { expect(html).toContain('data-default="80g"'); expect(html).toContain('data-measurement-type="weight"'); }); +}); + +// ─── Density Table ────────────────────────────────────────── + +const { matchDensity } = require('../densities'); + +describe('matchDensity', () => { + it('returns density for known ingredient', () => { + expect(matchDensity('butter')).toBe(0.96); + }); + + it('is case-insensitive', () => { + expect(matchDensity('Butter')).toBe(0.96); + expect(matchDensity('FLOUR')).toBe(0.53); + }); + + it('returns null for unknown ingredient', () => { + expect(matchDensity('dragon fruit')).toBeNull(); + }); + + it('matches longest keyword first (brown sugar vs sugar)', () => { + expect(matchDensity('brown sugar')).toBe(0.93); + expect(matchDensity('sugar')).toBe(0.85); + }); + + it('matches longest keyword first (olive oil vs oil)', () => { + expect(matchDensity('olive oil')).toBe(0.92); + expect(matchDensity('vegetable oil')).toBe(0.92); + }); + + it('matches ingredient within longer text', () => { + expect(matchDensity('unsalted butter')).toBe(0.96); + expect(matchDensity('all-purpose flour')).toBe(0.53); + }); + + it('returns null for empty/null input', () => { + expect(matchDensity('')).toBeNull(); + expect(matchDensity(null)).toBeNull(); + }); +}); + +// ─── Density-Based Auto Hybrid Generation ─────────────────── + +function getWeightAttrs(html) { + const matches = []; + const re = /data-hybrid="true"[^>]*data-weight-default="([^"]*)"[^>]*data-weight-alt="([^"]*)"(?:[^>]*data-weight-scalable="([^"]*)")?/g; + let m; + while ((m = re.exec(html)) !== null) { + matches.push({ + weightDefault: decodeHtmlEntities(m[1]), + weightAlt: decodeHtmlEntities(m[2]), + weightScalable: m[3] ? JSON.parse(decodeHtmlEntities(m[3])) : null, + }); + } + return matches; +} + +describe('density-based auto hybrid: weight -> volume', () => { + it('auto-generates volume data for weight measurement with known ingredient', () => { + const md = '- butter 227g'; + const html = render(md); + expect(html).toContain('data-hybrid="true"'); + expect(html).toContain('data-volume-default='); + expect(html).toContain('data-volume-alt='); + expect(html).toContain('data-volume-scalable='); + }); + + it('generates correct volume from density (butter 227g)', () => { + const md = '- butter 227g'; + const html = render(md); + const hybrids = getHybridAttrs(html); + expect(hybrids.length).toBe(1); + // 227g / 0.96 density = 236.46ml -> "236.5ml" metric + expect(hybrids[0].volumeDefault).toBe('236.5ml'); + expect(hybrids[0].volumeScalable).toBeDefined(); + expect(hybrids[0].volumeScalable.type).toBe('volume'); + expect(hybrids[0].volumeScalable.base).toBeCloseTo(236.46, 0); + }); + + it('generates correct volume from density (flour 500g)', () => { + const md = '- all-purpose flour 500g'; + const html = render(md); + const hybrids = getHybridAttrs(html); + expect(hybrids.length).toBe(1); + // 500g / 0.53 density = 943.4ml -> "943.4ml" + expect(hybrids[0].volumeDefault).toBe('943.4ml'); + }); + + it('does not generate hybrid for unknown ingredient weight', () => { + const md = '- mystery powder 100g'; + const html = render(md); + expect(html).not.toContain('data-hybrid'); + }); + + it('handles range weight measurements with density', () => { + const md = '- butter 200-250g'; + const html = render(md); + const hybrids = getHybridAttrs(html); + expect(hybrids.length).toBe(1); + // 200/0.96 = 208.3ml, 250/0.96 = 260.4ml + expect(hybrids[0].volumeDefault).toMatch(/208.*-.*260/); + expect(hybrids[0].volumeScalable.base).toHaveLength(2); + }); +}); + +describe('density-based auto hybrid: volume -> weight', () => { + it('auto-generates weight data for volume measurement with known ingredient', () => { + const md = '- flour 2 cups'; + const html = render(md); + expect(html).toContain('data-hybrid="true"'); + expect(html).toContain('data-weight-default='); + expect(html).toContain('data-weight-alt='); + expect(html).toContain('data-weight-scalable='); + }); + + it('generates correct weight from density (flour 2 cups)', () => { + const md = '- flour 2 cups'; + const html = render(md); + const weightAttrs = getWeightAttrs(html); + expect(weightAttrs.length).toBe(1); + // 2 cups = 2 * 236.588ml = 473.176ml, * 0.53 density = 250.8g + expect(weightAttrs[0].weightDefault).toBe('250.8g'); + expect(weightAttrs[0].weightScalable).toBeDefined(); + expect(weightAttrs[0].weightScalable.type).toBe('weight'); + expect(weightAttrs[0].weightScalable.base).toBeCloseTo(250.78, 0); + }); + + it('generates correct weight from density (milk 1 cup)', () => { + const md = '- milk 1 cup'; + const html = render(md); + const weightAttrs = getWeightAttrs(html); + expect(weightAttrs.length).toBe(1); + // 1 cup = 236.588ml, * 1.03 density = 243.69g + expect(weightAttrs[0].weightDefault).toBe('243.7g'); + }); + + it('does not generate hybrid for unknown ingredient volume', () => { + const md = '- unicorn tears 1 cup'; + const html = render(md); + expect(html).not.toContain('data-hybrid'); + }); + + it('handles range volume measurements with density', () => { + const md = '- sugar 1-2 cups'; + const html = render(md); + const weightAttrs = getWeightAttrs(html); + expect(weightAttrs.length).toBe(1); + // 1 cup = 236.588ml * 0.85 = 201.1g, 2 cups = 473.176ml * 0.85 = 402.2g + expect(weightAttrs[0].weightDefault).toMatch(/201.*-.*402/); + expect(weightAttrs[0].weightScalable.base).toHaveLength(2); + }); +}); + +describe('density vs manual hybrid precedence', () => { + it('manual hybrid annotation takes precedence over density table', () => { + const md = '- unsalted butter 80g (6 tablespoons)'; + const html = render(md); + const hybrids = getHybridAttrs(html); + expect(hybrids.length).toBe(1); + // Should use the manual 6 tablespoons, not density-computed value + expect(hybrids[0].volumeDefault).toBe('88.7ml'); + expect(hybrids[0].volumeAlt).toBe('6 tbsp'); + }); + + it('weight-to-weight alt does not trigger density hybrid (butter 227g (8 oz))', () => { + // "227g (8 oz)" is weight-to-weight alt, no manual hybrid volume data, + // but density should still auto-generate volume data + const md = '- butter 227g (8 oz)'; + const html = render(md); + // getHybridVolumeData returns null for weight-to-weight, so density kicks in + expect(html).toContain('data-hybrid="true"'); + const hybrids = getHybridAttrs(html); + expect(hybrids.length).toBe(1); + }); +}); + +describe('density with approximate values', () => { + it('preserves approximate prefix in density-generated volume', () => { + const md = '- butter ~200g'; + const html = render(md); + const hybrids = getHybridAttrs(html); + expect(hybrids.length).toBe(1); + expect(hybrids[0].volumeDefault).toMatch(/^~/); + }); + + it('preserves approximate prefix in density-generated weight', () => { + const md = '- flour ~2 cups'; + const html = render(md); + const weightAttrs = getWeightAttrs(html); + expect(weightAttrs.length).toBe(1); + expect(weightAttrs[0].weightDefault).toMatch(/^~/); + }); }); \ No newline at end of file diff --git a/lib/measurements/densities.js b/lib/measurements/densities.js new file mode 100644 index 0000000..6f319f0 --- /dev/null +++ b/lib/measurements/densities.js @@ -0,0 +1,58 @@ +/** + * Ingredient density table for automatic weight/volume conversion. + * + * Each entry has keywords (ingredient names) and a density in g/ml. + * Conversion: + * Weight -> Volume: ml = grams / density + * Volume -> Weight: grams = ml * density + * + * Entries are sorted longest-keyword-first so that "brown sugar" matches + * before "sugar", "olive oil" before "oil", etc. + */ + +const DENSITIES = [ + { keywords: ['all-purpose flour', 'all purpose flour', 'ap flour'], density: 0.53 }, + { keywords: ['powdered sugar', 'confectioners sugar', 'icing sugar'], density: 0.50 }, + { keywords: ['vanilla extract'], density: 1.03 }, + { keywords: ['baking powder'], density: 0.90 }, + { keywords: ['cocoa powder'], density: 0.43 }, + { keywords: ['brown sugar'], density: 0.93 }, + { keywords: ['maple syrup'], density: 1.32 }, + { keywords: ['baking soda', 'bicarbonate'], density: 1.10 }, + { keywords: ['bread flour'], density: 0.55 }, + { keywords: ['sour cream'], density: 1.05 }, + { keywords: ['olive oil'], density: 0.92 }, + { keywords: ['cornstarch', 'corn starch'], density: 0.54 }, + { keywords: ['butter'], density: 0.96 }, + { keywords: ['cream'], density: 1.01 }, + { keywords: ['flour'], density: 0.53 }, + { keywords: ['sugar'], density: 0.85 }, + { keywords: ['honey'], density: 1.42 }, + { keywords: ['milk'], density: 1.03 }, + { keywords: ['water'], density: 1.0 }, + { keywords: ['salt'], density: 1.22 }, + { keywords: ['rice'], density: 0.80 }, + { keywords: ['oil'], density: 0.92 }, +]; + +/** + * Check if text contains a known ingredient and return its density. + * Lowercases the text and checks keywords longest-first. + * + * @param {string} text - text preceding the measurement (ingredient context) + * @returns {number|null} density in g/ml, or null if no match + */ +function matchDensity(text) { + if (!text) return null; + const lower = text.toLowerCase().trim(); + for (const entry of DENSITIES) { + for (const keyword of entry.keywords) { + if (lower.includes(keyword)) { + return entry.density; + } + } + } + return null; +} + +module.exports = { DENSITIES, matchDensity }; diff --git a/lib/measurements/plugin.js b/lib/measurements/plugin.js index 1d23dfb..5d78736 100644 --- a/lib/measurements/plugin.js +++ b/lib/measurements/plugin.js @@ -8,6 +8,7 @@ */ const { findAllMeasurements, unitType } = require('./matcher'); +const { matchDensity } = require('./densities'); // ─── Number Formatting ────────────────────────────────── @@ -463,6 +464,116 @@ function formatMeasurementText(amount, unit, approximate) { return prefix + formatValueUnit(amount.value, unit); } +// ─── Density-Based Auto Hybrid Generation ─────────────── + +function generateDensityVolumeData(measurement, density) { + const { amount, unit, approximate } = measurement; + const prefix = approximate ? '~' : ''; + + if (amount.min !== undefined) { + const mlMin = toBaseGrams(amount.min.value, unit) / density; + const mlMax = toBaseGrams(amount.max.value, unit) / density; + const mMin = smartMetricVolume(mlMin); + const mMax = smartMetricVolume(mlMax); + const iMin = smartImperialVolume(mlMin); + const iMax = smartImperialVolume(mlMax); + const rPrefix = (approximate || amount.min.approximate) ? '~' : ''; + + let volumeDefault, volumeAlt; + if (mMin.unit === mMax.unit) { + const space = NO_SPACE_UNITS.has(mMin.unit) ? '' : ' '; + volumeDefault = rPrefix + formatNumber(mMin.value) + '-' + formatNumber(mMax.value) + space + unitLabel(mMin.unit, mMax.value !== 1); + } else { + volumeDefault = rPrefix + formatValueUnit(mMin.value, mMin.unit) + '-' + formatValueUnit(mMax.value, mMax.unit); + } + if (iMin.unit === iMax.unit) { + const space = NO_SPACE_UNITS.has(iMin.unit) ? '' : ' '; + volumeAlt = rPrefix + formatNumber(iMin.value) + '-' + formatNumber(iMax.value) + space + unitLabel(iMin.unit, iMax.value !== 1); + } else { + volumeAlt = rPrefix + formatValueUnit(iMin.value, iMin.unit) + '-' + formatValueUnit(iMax.value, iMax.unit); + } + + return { + default: volumeDefault, + alt: volumeAlt, + scalable: { + base: [mlMin, mlMax], + type: 'volume', + approx: approximate || amount.min.approximate || false, + }, + }; + } + + const grams = toBaseGrams(amount.value, unit); + const ml = grams / density; + const metric = smartMetricVolume(ml); + const imperial = smartImperialVolume(ml); + + return { + default: prefix + formatValueUnit(metric.value, metric.unit), + alt: prefix + formatValueUnit(imperial.value, imperial.unit), + scalable: { + base: ml, + type: 'volume', + approx: approximate || false, + }, + }; +} + +function generateDensityWeightData(measurement, density) { + const { amount, unit, approximate } = measurement; + const prefix = approximate ? '~' : ''; + + if (amount.min !== undefined) { + const gMin = toBaseMl(amount.min.value, unit) * density; + const gMax = toBaseMl(amount.max.value, unit) * density; + const mMin = smartMetricWeight(gMin); + const mMax = smartMetricWeight(gMax); + const iMin = smartImperialWeight(gMin / 28.3495); + const iMax = smartImperialWeight(gMax / 28.3495); + const rPrefix = (approximate || amount.min.approximate) ? '~' : ''; + + let weightDefault, weightAlt; + if (mMin.unit === mMax.unit) { + const space = NO_SPACE_UNITS.has(mMin.unit) ? '' : ' '; + weightDefault = rPrefix + formatNumber(mMin.value) + '-' + formatNumber(mMax.value) + space + unitLabel(mMin.unit, mMax.value !== 1); + } else { + weightDefault = rPrefix + formatValueUnit(mMin.value, mMin.unit) + '-' + formatValueUnit(mMax.value, mMax.unit); + } + if (iMin.unit === iMax.unit) { + const space = NO_SPACE_UNITS.has(iMin.unit) ? '' : ' '; + weightAlt = rPrefix + formatNumber(iMin.value) + '-' + formatNumber(iMax.value) + space + unitLabel(iMin.unit, iMax.value !== 1); + } else { + weightAlt = rPrefix + formatValueUnit(iMin.value, iMin.unit) + '-' + formatValueUnit(iMax.value, iMax.unit); + } + + return { + default: weightDefault, + alt: weightAlt, + scalable: { + base: [gMin, gMax], + type: 'weight', + approx: approximate || amount.min.approximate || false, + }, + }; + } + + const ml = toBaseMl(amount.value, unit); + const grams = ml * density; + const metric = smartMetricWeight(grams); + const imperial = smartImperialWeight(grams / 28.3495); + + return { + default: prefix + formatValueUnit(metric.value, metric.unit), + alt: prefix + formatValueUnit(imperial.value, imperial.unit), + scalable: { + base: grams, + type: 'weight', + approx: approximate || false, + }, + }; +} + // ─── Serving Vessel Detection ─────────────────────────── // Serving vessel keywords — items in the Tools section containing these words @@ -534,6 +645,26 @@ function replaceMeasurementsInToken(token, Token, inToolsSection) { } } + // Density-based auto hybrid: generate the OTHER type's data from ingredient density + if (!hybridData && (m.type === 'weight' || m.type === 'volume')) { + const textBefore = text.slice(0, m.index); + const density = matchDensity(textBefore); + if (density) { + open.attrSet('data-hybrid', 'true'); + if (m.type === 'weight') { + const volData = generateDensityVolumeData(m, density); + open.attrSet('data-volume-default', volData.default); + open.attrSet('data-volume-alt', volData.alt); + open.attrSet('data-volume-scalable', JSON.stringify(volData.scalable)); + } else { + const wtData = generateDensityWeightData(m, density); + open.attrSet('data-weight-default', wtData.default); + open.attrSet('data-weight-alt', wtData.alt); + open.attrSet('data-weight-scalable', JSON.stringify(wtData.scalable)); + } + } + } + newTokens.push(open); // Display metric-normalized text by default @@ -620,4 +751,6 @@ module.exports.generateTexts = generateTexts; module.exports.collapsedTime = collapsedTime; module.exports.expandedTime = expandedTime; module.exports.getHybridVolumeData = getHybridVolumeData; -module.exports.generateHybridVolumeTexts = generateHybridVolumeTexts; \ No newline at end of file +module.exports.generateHybridVolumeTexts = generateHybridVolumeTexts; +module.exports.generateDensityVolumeData = generateDensityVolumeData; +module.exports.generateDensityWeightData = generateDensityWeightData; \ No newline at end of file diff --git a/recipes/Japanese Curry Soup.md b/recipes/Japanese Curry Soup.md index f04bc60..1058e58 100644 --- a/recipes/Japanese Curry Soup.md +++ b/recipes/Japanese Curry Soup.md @@ -10,9 +10,9 @@ title: Japanese Curry Soup - [ ] carrots 4x - [ ] lion's mane - [ ] vegetable stock 1 jar -- [ ] 5 cups water +- [ ] water 5 cups - [ ] heavy cream [^1] -- [ ] 4 japanese curry bricks +- [ ] japanese curry bricks 4 - [ ] bay leaf - [ ] red pepper ## Tools