From 2ef1b939cb3d009685e76986496cd0c0fdb4d1d5 Mon Sep 17 00:00:00 2001 From: Barney Boisvert Date: Sat, 17 Jan 2026 11:01:33 -0800 Subject: [PATCH 1/8] remove null lines before diffing --- .../cookbook/services/DiffService.java | 12 +++- .../cookbook/services/PlanService.java | 2 +- .../cookbook/services/DiffServiceTest.java | 70 +++++++++++++++++++ 3 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 src/test/java/com/brennaswitzer/cookbook/services/DiffServiceTest.java diff --git a/src/main/java/com/brennaswitzer/cookbook/services/DiffService.java b/src/main/java/com/brennaswitzer/cookbook/services/DiffService.java index 24bdae61..a3dfc94d 100644 --- a/src/main/java/com/brennaswitzer/cookbook/services/DiffService.java +++ b/src/main/java/com/brennaswitzer/cookbook/services/DiffService.java @@ -4,11 +4,13 @@ import com.github.difflib.unifieddiff.UnifiedDiff; import com.github.difflib.unifieddiff.UnifiedDiffFile; import com.github.difflib.unifieddiff.UnifiedDiffWriter; +import jakarta.annotation.Nonnull; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.io.IOException; import java.util.List; +import java.util.Objects; @Service @Slf4j @@ -17,7 +19,8 @@ public class DiffService { public String diffLinesToPatch(List left, List right) { try { - return diffLinesToPatchInternal(left, right); + return diffLinesToPatchInternal(withoutNulls(left), + withoutNulls(right)); } catch (Exception e) { log.warn(String.format("Failed to diff %s and %s", left, @@ -27,6 +30,11 @@ public String diffLinesToPatch(List left, } } + @Nonnull + private static List withoutNulls(List left) { + return left.stream().filter(Objects::nonNull).toList(); + } + private String diffLinesToPatchInternal(List left, List right) { var patch = DiffUtils.diff(left, right); @@ -39,7 +47,7 @@ private String diffLinesToPatchInternal(List left, throw new RuntimeException(e); } var idx = sb.indexOf("\n"); - return sb.substring(idx + 1).trim(); + return sb.substring(idx + 1); } } diff --git a/src/main/java/com/brennaswitzer/cookbook/services/PlanService.java b/src/main/java/com/brennaswitzer/cookbook/services/PlanService.java index aea791ee..222a5ea4 100644 --- a/src/main/java/com/brennaswitzer/cookbook/services/PlanService.java +++ b/src/main/java/com/brennaswitzer/cookbook/services/PlanService.java @@ -390,7 +390,7 @@ private void recordRecipeHistories(PlanItem item, }); var diff = diffService.diffLinesToPatch(recipeLines, planLines); if (!diff.isBlank()) { - h.setNotes("```diff\n" + diff + "\n```\n"); + h.setNotes("```diff\n" + diff + "```\n"); } recipeHistoryRepo.save(h); } else if (item.hasChildren()) { diff --git a/src/test/java/com/brennaswitzer/cookbook/services/DiffServiceTest.java b/src/test/java/com/brennaswitzer/cookbook/services/DiffServiceTest.java new file mode 100644 index 00000000..73ac5cec --- /dev/null +++ b/src/test/java/com/brennaswitzer/cookbook/services/DiffServiceTest.java @@ -0,0 +1,70 @@ +package com.brennaswitzer.cookbook.services; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class DiffServiceTest { + + private DiffService service = new DiffService(); + + @Test + void christmasWontonBroth() { + var left = Stream.of( + "Chicken Broth From Scratch", + "", + "2 chicken, 3 to 3 1/2 pounds, with skin, cut up", + "6 celery, stalks , with leaves, cut into chunks", + "4 large carrot, cut into chunks", + "4 onion, yellow , peeled and halved", + "2 parsley, parsnip or root (optional)", + "About 1 dozen large sprigs parsley", + "About 1 dozen black peppercorns", + null, + "4 teaspoon salt, kosher , more to taste", + "2 head garlic") + .toList(); + var right = List.of( + "Chicken Broth From Scratch", + "", + "3 to 3 1/2 pounds chicken legs", + "3 stalks celery, with leaves, cut into chunks", + "2 large carrots, cut into chunks", + "2 yellow onions, peeled and halved", + "1 parsnip or parsley root (optional)", + "About 1 dozen large sprigs parsley", + "About 1 dozen black peppercorns", + "2 bay leaves", + "2 teaspoons kosher salt, more to taste", + "1 _head_ garlic"); + assertEquals( + "[Chicken Broth From Scratch, , 2 chicken, 3 to 3 1/2 pounds, with skin, cut up, 6 celery, stalks , with leaves, cut into chunks, 4 large carrot, cut into chunks, 4 onion, yellow , peeled and halved, 2 parsley, parsnip or root (optional), About 1 dozen large sprigs parsley, About 1 dozen black peppercorns, null, 4 teaspoon salt, kosher , more to taste, 2 head garlic] and [Chicken Broth From Scratch, , 3 to 3 1/2 pounds chicken legs, 3 stalks celery, with leaves, cut into chunks, 2 large carrots, cut into chunks, 2 yellow onions, peeled and halved, 1 parsnip or parsley root (optional), About 1 dozen large sprigs parsley, About 1 dozen black peppercorns, 2 bay leaves, 2 teaspoons kosher salt, more to taste, 1 _head_ garlic]", + String.format("%s and %s", left, right)); + assertEquals(""" + @@ -2,10 +2,11 @@ + \s + -2 chicken, 3 to 3 1/2 pounds, with skin, cut up + -6 celery, stalks , with leaves, cut into chunks + -4 large carrot, cut into chunks + -4 onion, yellow , peeled and halved + -2 parsley, parsnip or root (optional) + +3 to 3 1/2 pounds chicken legs + +3 stalks celery, with leaves, cut into chunks + +2 large carrots, cut into chunks + +2 yellow onions, peeled and halved + +1 parsnip or parsley root (optional) + About 1 dozen large sprigs parsley + About 1 dozen black peppercorns + -4 teaspoon salt, kosher , more to taste + -2 head garlic + +2 bay leaves + +2 teaspoons kosher salt, more to taste + +1 _head_ garlic + """, + service.diffLinesToPatch(left, right)); + } + +} From 9854b8372679c0b9115b48c07e5e208c454aeb04 Mon Sep 17 00:00:00 2001 From: Barney Boisvert Date: Sat, 17 Jan 2026 11:28:01 -0800 Subject: [PATCH 2/8] don't assume ranges to merge are in order and non-overlapping --- .../cookbook/payload/RecognizedRange.java | 5 +-- .../cookbook/payload/RecognizedRangeTest.java | 41 +++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/brennaswitzer/cookbook/payload/RecognizedRange.java b/src/main/java/com/brennaswitzer/cookbook/payload/RecognizedRange.java index 41495d48..26bce01f 100644 --- a/src/main/java/com/brennaswitzer/cookbook/payload/RecognizedRange.java +++ b/src/main/java/com/brennaswitzer/cookbook/payload/RecognizedRange.java @@ -49,9 +49,8 @@ public int length() { public RecognizedRange merge(RecognizedRange other) { return new RecognizedRange( - getStart(), - other.getEnd() - ); + Math.min(getStart(), other.getStart()), + Math.max(getEnd(), other.getEnd())); } public boolean overlaps(RecognizedRange r) { diff --git a/src/test/java/com/brennaswitzer/cookbook/payload/RecognizedRangeTest.java b/src/test/java/com/brennaswitzer/cookbook/payload/RecognizedRangeTest.java index 45126eaa..c1bb8cce 100644 --- a/src/test/java/com/brennaswitzer/cookbook/payload/RecognizedRangeTest.java +++ b/src/test/java/com/brennaswitzer/cookbook/payload/RecognizedRangeTest.java @@ -1,7 +1,13 @@ package com.brennaswitzer.cookbook.payload; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -34,4 +40,39 @@ void overlaps() { assertFalse(new RecognizedRange(5, 6).overlaps(a)); } + @ParameterizedTest + @MethodSource + void merge(RecognizedRange left, RecognizedRange right, RecognizedRange expected) { + assertEquals(expected, + left.merge(right), + () -> String.format("expected merge of %d-%d and %d-%d to be %d-%d", + left.getStart(), left.getEnd(), + right.getStart(), right.getEnd(), + expected.getStart(), expected.getEnd())); + } + + private static Stream merge() { + RecognizedRange a = new RecognizedRange(2, 6); + return Stream.of( + Arguments.of(a, + new RecognizedRange(3, 5), + a), + Arguments.of(a, + new RecognizedRange(1, 5), + new RecognizedRange(1, 6)), + Arguments.of(a, + new RecognizedRange(3, 7), + new RecognizedRange(2, 7)), + Arguments.of(new RecognizedRange(3, 5), + a, + a), + Arguments.of(new RecognizedRange(4, 6), + new RecognizedRange(2, 4), + a), + Arguments.of(new RecognizedRange(2, 4), + new RecognizedRange(4, 6), + a) + ); + } + } From 473aa93251427d5e8cd27a1c056c66145b871d59 Mon Sep 17 00:00:00 2001 From: Barney Boisvert Date: Sat, 17 Jan 2026 12:11:46 -0800 Subject: [PATCH 3/8] canonicalize commas when dissecting a recognized item --- .../payload/RawIngredientDissection.java | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/brennaswitzer/cookbook/payload/RawIngredientDissection.java b/src/main/java/com/brennaswitzer/cookbook/payload/RawIngredientDissection.java index 22440b10..47114410 100644 --- a/src/main/java/com/brennaswitzer/cookbook/payload/RawIngredientDissection.java +++ b/src/main/java/com/brennaswitzer/cookbook/payload/RawIngredientDissection.java @@ -13,6 +13,7 @@ import java.util.List; import java.util.Optional; import java.util.function.Function; +import java.util.regex.Pattern; @Setter @Getter @@ -20,6 +21,10 @@ @ToString public class RawIngredientDissection { + private static final Pattern RE_CANON_SPACES = Pattern.compile("\\s+"); + private static final Pattern RE_CANON_COMMAS = Pattern.compile("\\s*(,\\s*)+"); + private static final Pattern RE_LEADING_COMMAS = Pattern.compile("^,\\s*"); + // this is "duplicated" as processRecognizedItem public static RawIngredientDissection fromRecognizedItem(RecognizedItem it) { Optional qr = it.getRanges().stream() @@ -27,11 +32,11 @@ public static RawIngredientDissection fromRecognizedItem(RecognizedItem it) { .findFirst(); Optional ur = it.getRanges().stream() .filter(r -> RecognizedRangeType.UNIT == r.getType() - || RecognizedRangeType.NEW_UNIT == r.getType()) + || RecognizedRangeType.NEW_UNIT == r.getType()) .findFirst(); Optional nr = it.getRanges().stream() .filter(r -> RecognizedRangeType.ITEM == r.getType() - || RecognizedRangeType.NEW_ITEM == r.getType()) + || RecognizedRangeType.NEW_ITEM == r.getType()) .findFirst(); Function, Section> sectionFromRange = or -> @@ -47,23 +52,24 @@ public static RawIngredientDissection fromRecognizedItem(RecognizedItem it) { ranges.add(qr); ranges.add(ur); ranges.add(nr); - String p = ranges.stream() + String prep = ranges.stream() .filter(Optional::isPresent) .map(Optional::get) .sorted(Comparator.comparingInt(RecognizedRange::getStart).reversed()) .reduce( it.getRaw(), (s, r) -> s.substring(0, r.getStart()) + s.substring(r.getEnd()), - (a, b) -> { throw new UnsupportedOperationException(); }) - .trim() - .replaceAll("\\s+", " ") - .replaceAll("^\\s*,", ""); + (a, b) -> {throw new UnsupportedOperationException();}) + .trim(); + prep = RE_CANON_SPACES.matcher(prep).replaceAll(" "); + prep = RE_CANON_COMMAS.matcher(prep).replaceAll(", "); + prep = RE_LEADING_COMMAS.matcher(prep).replaceFirst(""); RawIngredientDissection d = new RawIngredientDissection(it.getRaw()); d.quantity = sectionFromRange.apply(qr); d.units = sectionFromRange.apply(ur); d.name = sectionFromRange.apply(nr); - d.prep = p; + d.prep = prep; return d; } @@ -115,7 +121,6 @@ public void setPrep(String prep) { @Value public static class Section { - String text; int start; int end; From 96cde3e3a2c351e018ee532b9d5b0382c07c6474 Mon Sep 17 00:00:00 2001 From: Barney Boisvert Date: Sat, 17 Jan 2026 14:10:36 -0800 Subject: [PATCH 4/8] spiffs to auto-recognition of items In particular, quantities still get parsed, even if there's no ingredient name. A numberless unit is assumed to have a 1. A quantity after an ingredient is now recognized. --- .../cookbook/services/IngredientService.java | 24 +- .../cookbook/services/ItemService.java | 35 +-- .../brennaswitzer/cookbook/util/RawUtils.java | 3 +- .../services/ItemServiceAutoRecogTest.java | 234 ++++++++++++++++++ .../cookbook/util/RawUtilsTest.java | 28 ++- 5 files changed, 286 insertions(+), 38 deletions(-) create mode 100644 src/test/java/com/brennaswitzer/cookbook/services/ItemServiceAutoRecogTest.java diff --git a/src/main/java/com/brennaswitzer/cookbook/services/IngredientService.java b/src/main/java/com/brennaswitzer/cookbook/services/IngredientService.java index 7fea52a9..a09d40ec 100644 --- a/src/main/java/com/brennaswitzer/cookbook/services/IngredientService.java +++ b/src/main/java/com/brennaswitzer/cookbook/services/IngredientService.java @@ -31,12 +31,11 @@ public class IngredientService { private UserPrincipalAccess principalAccess; public Ingredient ensureIngredientByName(String name) { - Optional oing = findIngredientByName(name); - if (oing.isPresent()) { - return oing.get(); - } - // make a new pantry item - return pantryItemRepository.save(new PantryItem(EnglishUtils.unpluralize(name))); + return findIngredientByName(name) + // otherwise make a new pantry item + .orElseGet(() -> pantryItemRepository.save( + new PantryItem( + EnglishUtils.unpluralize(name)))); } public List findAllIngredientsByNameContaining(String name) { @@ -51,22 +50,27 @@ public List findAllIngredientsByNameContaining(String name) { return result; } - public Optional findIngredientByName(String name) { + public Optional findIngredientByName(String name) { String unpluralized = EnglishUtils.unpluralize(name); // see if there's a pantry item... List pantryItem = pantryItemRepository.findByNameIgnoreCaseOrderById(unpluralized); if (!pantryItem.isEmpty()) { - return pantryItem.stream().findFirst(); + return pantryItem.stream() + .findFirst() + .map(Ingredient.class::cast); } // see if there's a recipe... User user = principalAccess.getUser(); List recipe = recipeRepository.findByOwnerAndNameIgnoreCaseOrderById(user, name); if (!recipe.isEmpty() || name.equals(unpluralized)) { - return recipe.stream().findFirst(); + return recipe.stream() + .findFirst() + .map(Ingredient.class::cast); } return recipeRepository.findByOwnerAndNameIgnoreCaseOrderById(user, unpluralized) .stream() - .findFirst(); + .findFirst() + .map(Ingredient.class::cast); } /** diff --git a/src/main/java/com/brennaswitzer/cookbook/services/ItemService.java b/src/main/java/com/brennaswitzer/cookbook/services/ItemService.java index 97f663ed..5374271d 100644 --- a/src/main/java/com/brennaswitzer/cookbook/services/ItemService.java +++ b/src/main/java/com/brennaswitzer/cookbook/services/ItemService.java @@ -76,7 +76,7 @@ public RecognizedItem recognizeItem(String raw, int cursor, boolean withSuggesti int idxImplicitItemStart = -1; if (secName != null) { // there's an explicit name - Optional oing = ingredientService.findIngredientByName( + Optional oing = ingredientService.findIngredientByName( secName.getText()); idxExplicitItemStart = secName.getStart(); item.withRange(new RecognizedRange( @@ -214,24 +214,31 @@ public void updateAutoRecognition(MutableItem it) { public void autoRecognize(MutableItem it) { if (it == null) return; String raw = it.getRaw(); - if (raw == null || raw.trim().isEmpty()) return; + if (raw == null || raw.isBlank()) return; RecognizedItem recog = recognizeItem(raw, raw.length(), false); if (recog == null) return; RawIngredientDissection dissection = RawIngredientDissection .fromRecognizedItem(recog); - if (!dissection.hasName()) return; - it.setIngredient(ingredientService.ensureIngredientByName(dissection.getNameText())); - it.setPreparation(dissection.getPrep()); - if (!dissection.hasQuantity()) return; - Quantity q = new Quantity(); - Double quantity = NumberUtils.parseNumber(dissection.getQuantityText()); - if (quantity == null) return; // couldn't parse? - q.setQuantity(quantity); - if (dissection.hasUnits()) { - q.setUnits(UnitOfMeasure.ensure(entityManager, - EnglishUtils.canonicalize(dissection.getUnitsText()))); + if (!dissection.hasName() + && !dissection.hasQuantity() + && !dissection.hasUnits()) { + return; + } + if (dissection.hasQuantity() || dissection.hasUnits()) { + Quantity q = new Quantity(); + Double quantity = NumberUtils.parseNumber(dissection.getQuantityText()); + if (quantity == null) quantity = 1.0; + q.setQuantity(quantity); + if (dissection.hasUnits()) { + q.setUnits(UnitOfMeasure.ensure(entityManager, + EnglishUtils.canonicalize(dissection.getUnitsText()))); + } + it.setQuantity(q); } - it.setQuantity(q); + if (dissection.hasName()) { + it.setIngredient(ingredientService.ensureIngredientByName(dissection.getNameText())); + } + it.setPreparation(dissection.getPrep()); } @Getter diff --git a/src/main/java/com/brennaswitzer/cookbook/util/RawUtils.java b/src/main/java/com/brennaswitzer/cookbook/util/RawUtils.java index 41a81a9d..88a58217 100644 --- a/src/main/java/com/brennaswitzer/cookbook/util/RawUtils.java +++ b/src/main/java/com/brennaswitzer/cookbook/util/RawUtils.java @@ -61,7 +61,7 @@ public static String stripMarkers(String region) { public static RawIngredientDissection dissect(String raw) { if (raw == null) return null; - if (raw.trim().isEmpty()) return null; + if (raw.isBlank()) return null; RawIngredientDissection d = new RawIngredientDissection(raw); NumberUtils.NumberWithRange n = NumberUtils.parseNumberWithRange(raw); int pos = 0; @@ -76,7 +76,6 @@ public static RawIngredientDissection dissect(String raw) { RawIngredientDissection.Section s = findSection(raw, pos, '_', '_'); if (s != null) { d.setUnits(s); - pos = s.getEnd(); } Stream.builder() .add(findSection(raw, pos, '"', '"')) diff --git a/src/test/java/com/brennaswitzer/cookbook/services/ItemServiceAutoRecogTest.java b/src/test/java/com/brennaswitzer/cookbook/services/ItemServiceAutoRecogTest.java new file mode 100644 index 00000000..0f5bce7a --- /dev/null +++ b/src/test/java/com/brennaswitzer/cookbook/services/ItemServiceAutoRecogTest.java @@ -0,0 +1,234 @@ +package com.brennaswitzer.cookbook.services; + +import com.brennaswitzer.cookbook.domain.Ingredient; +import com.brennaswitzer.cookbook.domain.MutableItem; +import com.brennaswitzer.cookbook.domain.Quantity; +import com.brennaswitzer.cookbook.domain.UnitOfMeasure; +import com.brennaswitzer.cookbook.util.RecipeBox; +import jakarta.persistence.EntityManager; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +/** + * I am really more of an integration test for recognition as a whole, in + * contrast to {@link com.brennaswitzer.cookbook.util.RawUtilsTest} which is + * only for context-free parsing of strings. + */ +@ExtendWith(MockitoExtension.class) +public class ItemServiceAutoRecogTest { + + @InjectMocks + private ItemService service; + + @Mock + private EntityManager entityManager; + + // Not every parameterization will end up using it this + @Mock(strictness = Mock.Strictness.LENIENT) + private IngredientService ingredientService; + + private static final RecipeBox box = new RecipeBox(); + + @BeforeEach + void setUp() { + // sugar is known + when(ingredientService.findAllIngredientsByNamesContaining( + argThat(ns -> ns.contains(box.sugar.getName())))) + .thenReturn(List.of(box.sugar)); + when(ingredientService.findIngredientByName(box.sugar.getName())) + .thenReturn(Optional.of(box.sugar)); + when(ingredientService.ensureIngredientByName(box.sugar.getName())) + .thenReturn(box.sugar); + // chicken is not (though it _is_ in the RecipeBox) + when(ingredientService.ensureIngredientByName(box.chicken.getName())) + .thenReturn(box.chicken); + } + + @ParameterizedTest + @MethodSource("onePart") + @MethodSource("twoParts") + @MethodSource("threeParts") + @MethodSource("createStuff") + void combosAndPerms(Item expected) { + Item actual = new Item(expected.getRaw()); + try (MockedStatic uom = mockStatic(UnitOfMeasure.class)) { + // cup is known + uom.when(() -> UnitOfMeasure.find(entityManager, "cup")) + .thenReturn(Optional.of(box.cup)); + uom.when(() -> UnitOfMeasure.ensure(entityManager, "cup")) + .thenReturn(box.cup); + // lbs is not (though it _is_ in the box) + uom.when(() -> UnitOfMeasure.ensure(entityManager, "lbs")) + .thenReturn(box.lbs); + + service.autoRecognize(actual); + } + assertEquals(expected, actual); + } + + private static Stream onePart() { + return Stream.of( + Item.builder() + .raw("5, divided") + .quantity(Quantity.count(5)) + .preparation("divided") + .build(), + // no unit w/out a quantity + Item.builder() + .raw("cup, divided") + .build(), + Item.builder() + .raw("sugar, divided") + .ingredient(box.sugar) + .preparation("divided") + .build() + ); + } + + private static Stream twoParts() { + return Stream.of( + Item.builder() + .raw("5 cup, divided") + .quantity(new Quantity(5, box.cup)) + .preparation("divided") + .build(), + Item.builder() + .raw("5 sugar, divided") + .quantity(Quantity.count(5)) + .ingredient(box.sugar) + .preparation("divided") + .build(), + Item.builder() + .raw("cup 5, divided") + .build(), + Item.builder() + .raw("cup sugar, divided") + .ingredient(box.sugar) + .preparation("cup, divided") + .build(), + Item.builder() + .raw("sugar 5, divided") + .ingredient(box.sugar) + .preparation("5, divided") + .build(), + Item.builder() + .raw("sugar cup, divided") + .ingredient(box.sugar) + .preparation("cup, divided") + .build() + ); + } + + private static Stream threeParts() { + return Stream.of( + Item.builder() + .raw("5 cup sugar, divided") + .quantity(new Quantity(5, box.cup)) + .ingredient(box.sugar) + .preparation("divided") + .build(), + Item.builder() + .raw("5 sugar cup, divided") + .quantity(Quantity.count(5)) + .ingredient(box.sugar) + .preparation("cup, divided") + .build(), + Item.builder() + .raw("cup 5 sugar, divided") + .ingredient(box.sugar) + .preparation("cup 5, divided") + .build(), + Item.builder() + .raw("cup sugar 5, divided") + .ingredient(box.sugar) + .preparation("cup 5, divided") + .build(), + Item.builder() + .raw("sugar 5 cup, divided") + .ingredient(box.sugar) + .preparation("5 cup, divided") + .build(), + Item.builder() + .raw("sugar cup 5, divided") + .ingredient(box.sugar) + .preparation("cup 5, divided") + .build() + ); + } + + private static Stream createStuff() { + return Stream.of( + Item.builder() + .raw("1&1/2 lbs young chicken, deboned") + .quantity(Quantity.count(1.5)) + .preparation("lbs young chicken, deboned") + .build(), + Item.builder() + .raw("1&1/2 _lbs_ young chicken, deboned") + .quantity(new Quantity(1.5, box.lbs)) + .preparation("young chicken, deboned") + .build(), + Item.builder() + .raw("1&1/2 lbs young \"chicken\", deboned") + .quantity(Quantity.count(1.5)) + .ingredient(box.chicken) + .preparation("lbs young, deboned") + .build(), + Item.builder() + .raw("1&1/2 _lbs_ young \"chicken\", deboned") + .quantity(new Quantity(1.5, box.lbs)) + .ingredient(box.chicken) + .preparation("young, deboned") + .build(), + Item.builder() + .raw("_lbs_ chicken") + .quantity(new Quantity(1, box.lbs)) + .preparation("chicken") + .build(), + Item.builder() + .raw("_lbs_ 5 chicken") + .quantity(new Quantity(1, box.lbs)) + .preparation("5 chicken") + .build(), + Item.builder() + .raw("\"chicken\" 2 _lbs_") + .quantity(new Quantity(1, box.lbs)) + .ingredient(box.chicken) + .preparation("2") + .build() + ); + } + + @Data + @RequiredArgsConstructor + @AllArgsConstructor + @Builder + private static class Item implements MutableItem { + + private final String raw; + private Quantity quantity; + private String preparation; + private Ingredient ingredient; + + } + +} diff --git a/src/test/java/com/brennaswitzer/cookbook/util/RawUtilsTest.java b/src/test/java/com/brennaswitzer/cookbook/util/RawUtilsTest.java index 89f31fb6..876f7e7b 100644 --- a/src/test/java/com/brennaswitzer/cookbook/util/RawUtilsTest.java +++ b/src/test/java/com/brennaswitzer/cookbook/util/RawUtilsTest.java @@ -2,10 +2,13 @@ import com.brennaswitzer.cookbook.payload.RawIngredientDissection; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; +import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -77,24 +80,25 @@ void stripMarkers() { assertEquals("_grams", RawUtils.stripMarkers("_grams")); } - @Test - public void fromTestFile() throws IOException { - BufferedReader r = new BufferedReader(new InputStreamReader(getClass().getResourceAsStream("/raw/dissections.txt"))); + @ParameterizedTest + @MethodSource + public void fromTestFile(RawIngredientDissection expected) { + assertEquals(expected, RawUtils.dissect(expected.getRaw())); + } + + private static Stream fromTestFile() throws IOException { + BufferedReader r = new BufferedReader(new InputStreamReader(RawUtilsTest.class.getResourceAsStream( + "/raw/dissections.txt"))); r.readLine(); // the header - r.lines() - .filter(l -> !l.trim().isEmpty()) + return r.lines() + .filter(l -> !l.isBlank()) .filter(l -> !l.startsWith("#")) .filter(l -> !l.startsWith("//")) .map(l -> l.split("\\|")) - .map(this::inflateDissection) - .forEach(this::testDissection); - } - - private void testDissection(RawIngredientDissection expected) { - assertEquals(expected, RawUtils.dissect(expected.getRaw())); + .map(RawUtilsTest::inflateDissection); } - private RawIngredientDissection inflateDissection(String[] parts) { + private static RawIngredientDissection inflateDissection(String[] parts) { RawIngredientDissection dissection = new RawIngredientDissection(parts[0]); if (parts.length > 1 && !parts[1].isEmpty()) { dissection.setQuantity(new RawIngredientDissection.Section( From 1261b96ea804fcd587c50ea29badf066c6414dd5 Mon Sep 17 00:00:00 2001 From: Barney Boisvert Date: Sat, 17 Jan 2026 14:51:04 -0800 Subject: [PATCH 5/8] don't assume "no quantity" means "one" --- .../cookbook/domain/AggregateIngredient.java | 2 +- .../brennaswitzer/cookbook/domain/IngredientRef.java | 5 ----- .../com/brennaswitzer/cookbook/domain/PlanItem.java | 11 +++++------ .../brennaswitzer/cookbook/services/PlanService.java | 4 +++- 4 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/brennaswitzer/cookbook/domain/AggregateIngredient.java b/src/main/java/com/brennaswitzer/cookbook/domain/AggregateIngredient.java index 2b3aa444..5a1dd2ab 100644 --- a/src/main/java/com/brennaswitzer/cookbook/domain/AggregateIngredient.java +++ b/src/main/java/com/brennaswitzer/cookbook/domain/AggregateIngredient.java @@ -11,7 +11,7 @@ public interface AggregateIngredient { Collection getIngredients(); default IngredientRef addIngredient(Ingredient ingredient) { - return addIngredient(Quantity.ONE, ingredient, null); + return addIngredient(null, ingredient, null); } default IngredientRef addIngredient(Quantity quantity, Ingredient ingredient) { diff --git a/src/main/java/com/brennaswitzer/cookbook/domain/IngredientRef.java b/src/main/java/com/brennaswitzer/cookbook/domain/IngredientRef.java index 46071d9e..29e9c702 100644 --- a/src/main/java/com/brennaswitzer/cookbook/domain/IngredientRef.java +++ b/src/main/java/com/brennaswitzer/cookbook/domain/IngredientRef.java @@ -68,11 +68,6 @@ public String getRaw() { return raw == null ? toString() : raw; } - public Quantity getQuantity() { - if (quantity == null) return Quantity.ONE; - return quantity; - } - public boolean hasQuantity() { return quantity != null; } diff --git a/src/main/java/com/brennaswitzer/cookbook/domain/PlanItem.java b/src/main/java/com/brennaswitzer/cookbook/domain/PlanItem.java index 80951f1a..5ee321c3 100644 --- a/src/main/java/com/brennaswitzer/cookbook/domain/PlanItem.java +++ b/src/main/java/com/brennaswitzer/cookbook/domain/PlanItem.java @@ -85,6 +85,7 @@ public class PlanItem extends BaseEntity implements Named, MutableItem, CorePlan private PlanItemStatus status = PlanItemStatus.NEEDED; @Embedded + @Getter @Setter private Quantity quantity; @@ -210,6 +211,10 @@ public boolean hasComponents() { return getComponentCount() != 0; } + public boolean hasQuantity() { + return quantity != null; + } + public boolean isDescendant(PlanItem t) { for (; t != null; t = t.getParent()) { if (t == this) return true; @@ -449,12 +454,6 @@ public String getRaw() { return getName(); } - @Override - public Quantity getQuantity() { - if (quantity == null) return Quantity.ONE; - return quantity; - } - public boolean hasIngredient() { return ingredient != null; } diff --git a/src/main/java/com/brennaswitzer/cookbook/services/PlanService.java b/src/main/java/com/brennaswitzer/cookbook/services/PlanService.java index 222a5ea4..7c22e235 100644 --- a/src/main/java/com/brennaswitzer/cookbook/services/PlanService.java +++ b/src/main/java/com/brennaswitzer/cookbook/services/PlanService.java @@ -362,7 +362,9 @@ private void recordRecipeHistories(PlanItem item, // It was probably tentatively added and then decided against. return; } - double scale = item.getQuantity().getQuantity(); + double scale = item.hasQuantity() + ? item.getQuantity().getQuantity() + : 1; var h = new PlannedRecipeHistory(); h.setRecipe(r); h.setOwner(principalAccess.getUser()); From edac4665fdc148b268672227106540cabc0a1e27 Mon Sep 17 00:00:00 2001 From: Barney Boisvert Date: Sun, 18 Jan 2026 07:52:26 -0800 Subject: [PATCH 6/8] script to tag a deployment --- tag_deploy.sh | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100755 tag_deploy.sh diff --git a/tag_deploy.sh b/tag_deploy.sh new file mode 100755 index 00000000..5fa38ac2 --- /dev/null +++ b/tag_deploy.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env zsh +set -e + +tag="deploy-$(date "+%Y%m%d_%H%M%S")" + +reset=no +if ! git diff --quiet || ! git diff --quiet --cached; then + reset=yes + git commit -am "WIP before $tag" +fi + +git tag $tag +git branch --force auto-deploy +git tag --list 'deploy-*' \ + | sort -r \ + | tail -n +10 \ + | xargs git tag -d + +if [ $reset = "yes" ]; then + git reset --soft HEAD^ +fi From ce27d2cf5df92db9054113af16fddaba3263ac71 Mon Sep 17 00:00:00 2001 From: Barney Boisvert Date: Sun, 18 Jan 2026 08:37:27 -0800 Subject: [PATCH 7/8] don't scale subrecipes onto the plan or back into history --- .../cookbook/domain/IngredientRef.java | 30 +-------- .../brennaswitzer/cookbook/domain/Item.java | 32 +++++++++ .../cookbook/domain/PlanItem.java | 8 --- .../cookbook/services/PlanService.java | 65 ++++++++++--------- .../cookbook/util/ValueUtils.java | 13 ++++ 5 files changed, 81 insertions(+), 67 deletions(-) create mode 100644 src/main/java/com/brennaswitzer/cookbook/util/ValueUtils.java diff --git a/src/main/java/com/brennaswitzer/cookbook/domain/IngredientRef.java b/src/main/java/com/brennaswitzer/cookbook/domain/IngredientRef.java index 29e9c702..5b51e620 100644 --- a/src/main/java/com/brennaswitzer/cookbook/domain/IngredientRef.java +++ b/src/main/java/com/brennaswitzer/cookbook/domain/IngredientRef.java @@ -60,46 +60,22 @@ public IngredientRef(String raw) { setRaw(raw); } - public boolean hasIngredient() { - return ingredient != null; - } - public String getRaw() { return raw == null ? toString() : raw; } - public boolean hasQuantity() { - return quantity != null; - } - public IngredientRef scale(Double scale) { - if (!hasQuantity() || scale == 1) return this; + if (scale <= 0) throw new IllegalArgumentException("Scaling by " + scale + " makes no sense?!"); + if (scale == 1 || !hasQuantity()) return this; return new IngredientRef( getQuantity().times(scale), getIngredient(), getPreparation()); } - public boolean hasPreparation() { - return preparation != null && !preparation.isEmpty(); - } - @Override public String toString() { - return toString(true); - } - - public String toString(boolean includePrep) { - if (!hasIngredient()) return raw; - StringBuilder sb = new StringBuilder(); - if (hasQuantity()) { - sb.append(quantity).append(' '); - } - sb.append(ingredient.getName()); - if (includePrep && hasPreparation()) { - sb.append(", ").append(preparation); - } - return sb.toString(); + return toRaw(true); } } diff --git a/src/main/java/com/brennaswitzer/cookbook/domain/Item.java b/src/main/java/com/brennaswitzer/cookbook/domain/Item.java index e860068b..9fff7869 100644 --- a/src/main/java/com/brennaswitzer/cookbook/domain/Item.java +++ b/src/main/java/com/brennaswitzer/cookbook/domain/Item.java @@ -1,5 +1,7 @@ package com.brennaswitzer.cookbook.domain; +import com.brennaswitzer.cookbook.util.ValueUtils; + /** * Describes a single, atomic unit for a recipe, which could be * something like "3 oz Parmesan, shredded" or "2 each Pizza Dough, thawed". @@ -21,16 +23,46 @@ public interface Item { */ Quantity getQuantity(); + default boolean hasQuantity() { + return getQuantity() != null; + } + /** * The rest of the info needed to prep a plan item * @return instructions on prep */ String getPreparation(); + default boolean hasPreparation() { + return ValueUtils.hasValue(getPreparation()); + } + /** * Reference to an ingredient, in this case most likely * a pantry item. * @return an ingredient */ Ingredient getIngredient(); + + default boolean hasIngredient() { + return getIngredient() != null; + } + + default String toRaw(boolean includePrep) { + StringBuilder sb = new StringBuilder(); + if (hasQuantity()) { + sb.append(getQuantity()).append(' '); + } + if (hasIngredient()) { + sb.append(getIngredient().getName()); + } + if (includePrep && hasPreparation()) { + if (hasIngredient()) { + sb.append(", "); + } + sb.append(getPreparation()); + } + if (sb.isEmpty()) return getRaw(); + return sb.toString(); + } } diff --git a/src/main/java/com/brennaswitzer/cookbook/domain/PlanItem.java b/src/main/java/com/brennaswitzer/cookbook/domain/PlanItem.java index 5ee321c3..1bdf3b5b 100644 --- a/src/main/java/com/brennaswitzer/cookbook/domain/PlanItem.java +++ b/src/main/java/com/brennaswitzer/cookbook/domain/PlanItem.java @@ -211,10 +211,6 @@ public boolean hasComponents() { return getComponentCount() != 0; } - public boolean hasQuantity() { - return quantity != null; - } - public boolean isDescendant(PlanItem t) { for (; t != null; t = t.getParent()) { if (t == this) return true; @@ -454,10 +450,6 @@ public String getRaw() { return getName(); } - public boolean hasIngredient() { - return ingredient != null; - } - public boolean hasBucket() { return bucket != null; } diff --git a/src/main/java/com/brennaswitzer/cookbook/services/PlanService.java b/src/main/java/com/brennaswitzer/cookbook/services/PlanService.java index 7c22e235..e0c796ca 100644 --- a/src/main/java/com/brennaswitzer/cookbook/services/PlanService.java +++ b/src/main/java/com/brennaswitzer/cookbook/services/PlanService.java @@ -20,6 +20,7 @@ import com.brennaswitzer.cookbook.repositories.PlannedRecipeHistoryRepository; import com.brennaswitzer.cookbook.repositories.UserRepository; import com.brennaswitzer.cookbook.util.UserPrincipalAccess; +import com.brennaswitzer.cookbook.util.ValueUtils; import lombok.val; import org.hibernate.Hibernate; import org.springframework.beans.factory.annotation.Autowired; @@ -35,6 +36,7 @@ import java.util.LinkedList; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.function.Consumer; import java.util.function.Predicate; import java.util.regex.Pattern; @@ -168,26 +170,28 @@ private void sendToPlan(AggregateIngredient r, PlanItem aggItem, Double scale) { } private void sendToPlan(IngredientRef ref, PlanItem aggItem, Double scale) { - if (scale == null || scale <= 0) { // nonsense! - scale = 1d; - } - Ingredient ingredient = Hibernate.unproxy(ref.getIngredient(), Ingredient.class); - boolean isAggregate = ingredient instanceof AggregateIngredient; - if (ref.hasQuantity()) { + // ignore nonsense scaling + if (scale != null && scale > 0) { ref = ref.scale(scale); } - PlanItem t = new PlanItem( - isAggregate - ? ingredient.getName() - : ref.getRaw(), - ref.getQuantity(), - ingredient, - ref.getPreparation()); - aggItem.addAggregateComponent(t); - if (isAggregate) { + Ingredient ingredient = Hibernate.unproxy(ref.getIngredient(), Ingredient.class); + if (ingredient instanceof AggregateIngredient agg) { // Subrecipes DO NOT get scaled; there's not a quantifiable - // relationship to multiply across. - sendToPlan((AggregateIngredient) ingredient, t, 1d); + // relationship to multiply across. The ref's quantity itself is + // scaled, so the recipe remains intact. + PlanItem it = new PlanItem( + ingredient.getName(), + ref.getQuantity(), + ingredient, + ref.getPreparation()); + aggItem.addAggregateComponent(it); + sendToPlan(agg, it, 1d); + } else { + aggItem.addAggregateComponent(new PlanItem( + ref.toString(), + ref.getQuantity(), + ingredient, + ref.getPreparation())); } } @@ -345,7 +349,10 @@ public PlanItem setItemStatus(Long id, PlanItemStatus status, Instant doneAt) { PlanItem item = getPlanItemById(id, AccessLevel.CHANGE); item.setStatus(status); if (item.getStatus().isForDelete()) { - recordRecipeHistories(item, item.getStatus(), doneAt); + double scale = item.hasQuantity() + ? item.getQuantity().getQuantity() + : 1; + recordRecipeHistories(item, item.getStatus(), doneAt, scale); item.moveToTrash(); } return item; @@ -353,8 +360,10 @@ public PlanItem setItemStatus(Long id, PlanItemStatus status, Instant doneAt) { private void recordRecipeHistories(PlanItem item, PlanItemStatus status, - Instant doneAtOrNull) { - Instant doneAt = doneAtOrNull == null ? Instant.now() : doneAtOrNull; + Instant doneAtOrNull, + double scale) { + Instant doneAt = Optional.ofNullable(doneAtOrNull) + .orElseGet(Instant::now); if (Hibernate.unproxy(item.getIngredient()) instanceof Recipe r) { if (status == PlanItemStatus.DELETED && Duration.between(item.getCreatedAt(), doneAt).toMinutes() < 120) { @@ -362,9 +371,6 @@ private void recordRecipeHistories(PlanItem item, // It was probably tentatively added and then decided against. return; } - double scale = item.hasQuantity() - ? item.getQuantity().getQuantity() - : 1; var h = new PlannedRecipeHistory(); h.setRecipe(r); h.setOwner(principalAccess.getUser()); @@ -376,19 +382,14 @@ private void recordRecipeHistories(PlanItem item, recipeLines.add(r.getName()); recipeLines.add(""); r.getIngredients() - .forEach(ir -> { - if (scale > 0 && scale != 1) { - ir = ir.scale(scale); - } - recipeLines.add(ir.getRaw()); - }); + .forEach(ir -> recipeLines.add(ir.scale(scale).toRaw(true))); var planLines = new ArrayList(); planLines.add(item.getName()); planLines.add(""); item.getOrderedChildView() .forEach(it -> { - planLines.add(it.getName()); - recordRecipeHistories(it, status, doneAt); + planLines.add(it.toRaw(true)); + recordRecipeHistories(it, status, doneAt, 1); }); var diff = diffService.diffLinesToPatch(recipeLines, planLines); if (!diff.isBlank()) { @@ -397,7 +398,7 @@ private void recordRecipeHistories(PlanItem item, recipeHistoryRepo.save(h); } else if (item.hasChildren()) { item.getChildView() - .forEach(it -> recordRecipeHistories(it, status, doneAt)); + .forEach(it -> recordRecipeHistories(it, status, doneAt, 1)); } } diff --git a/src/main/java/com/brennaswitzer/cookbook/util/ValueUtils.java b/src/main/java/com/brennaswitzer/cookbook/util/ValueUtils.java new file mode 100644 index 00000000..4562d9c4 --- /dev/null +++ b/src/main/java/com/brennaswitzer/cookbook/util/ValueUtils.java @@ -0,0 +1,13 @@ +package com.brennaswitzer.cookbook.util; + +public class ValueUtils { + + public static boolean hasValue(String s) { + return s != null && !s.isBlank(); + } + + public static boolean noValue(String s) { + return s == null || s.isBlank(); + } + +} From 17ee11988b233085da708b8b4dbd63857a576d73 Mon Sep 17 00:00:00 2001 From: Barney Boisvert Date: Sun, 18 Jan 2026 08:50:35 -0800 Subject: [PATCH 8/8] use ValueUtils throughout --- .../cookbook/config/JwtConfig.java | 3 ++- .../cookbook/domain/PantryItem.java | 7 ++++--- .../brennaswitzer/cookbook/domain/Plan.java | 5 +++-- .../cookbook/domain/PlanBucket.java | 3 ++- .../cookbook/domain/PlanItem.java | 3 ++- .../brennaswitzer/cookbook/domain/S3File.java | 3 ++- .../graphql/PantryQueryController.java | 3 ++- .../graphql/resolvers/RecipeResolver.java | 3 ++- .../cookbook/payload/IngredientInfo.java | 3 ++- .../cookbook/payload/IngredientRefInfo.java | 3 ++- .../payload/RawIngredientDissection.java | 4 ++-- .../impl/LibrarySearchRequest.java | 5 +++-- .../impl/PantryItemSearchRequest.java | 3 ++- .../oauth2/CustomOAuth2UserService.java | 4 ++-- .../cookbook/services/ItemService.java | 8 ++++---- .../cookbook/services/PlanService.java | 2 +- .../cookbook/util/NumberUtils.java | 3 +-- .../brennaswitzer/cookbook/util/RawUtils.java | 3 +-- .../cookbook/util/SlugUtils.java | 2 +- .../cookbook/util/ValueUtils.java | 19 +++++++++++++++++++ .../cookbook/util/RawUtilsTest.java | 2 +- 21 files changed, 60 insertions(+), 31 deletions(-) diff --git a/src/main/java/com/brennaswitzer/cookbook/config/JwtConfig.java b/src/main/java/com/brennaswitzer/cookbook/config/JwtConfig.java index d65454eb..429f2a5d 100644 --- a/src/main/java/com/brennaswitzer/cookbook/config/JwtConfig.java +++ b/src/main/java/com/brennaswitzer/cookbook/config/JwtConfig.java @@ -1,5 +1,6 @@ package com.brennaswitzer.cookbook.config; +import com.brennaswitzer.cookbook.util.ValueUtils; import com.nimbusds.jose.JOSEObjectType; import com.nimbusds.jose.JWSAlgorithm; import com.nimbusds.jose.jwk.JWK; @@ -34,7 +35,7 @@ public class JwtConfig { public JWKSet jwkSet(AppProperties appProperties) { List tokenSecrets = appProperties.getAuth() .getTokenSecrets(); - if (tokenSecrets == null || tokenSecrets.isEmpty()) { + if (ValueUtils.noValue(tokenSecrets)) { throw new IllegalStateException("At least one token secret must be provided in app.auth.token-secrets[]"); } Base64.Decoder decoder = Base64.getDecoder(); diff --git a/src/main/java/com/brennaswitzer/cookbook/domain/PantryItem.java b/src/main/java/com/brennaswitzer/cookbook/domain/PantryItem.java index cf7f9155..457ad42e 100644 --- a/src/main/java/com/brennaswitzer/cookbook/domain/PantryItem.java +++ b/src/main/java/com/brennaswitzer/cookbook/domain/PantryItem.java @@ -1,6 +1,7 @@ package com.brennaswitzer.cookbook.domain; import com.brennaswitzer.cookbook.repositories.PantryItemSearchRepository; +import com.brennaswitzer.cookbook.util.ValueUtils; import com.fasterxml.jackson.annotation.JsonTypeName; import jakarta.persistence.Column; import jakarta.persistence.DiscriminatorValue; @@ -86,7 +87,7 @@ private void ensureNameIsNotSynonym() { return; } var name = getName(); - if (name == null || name.isBlank()) return; + if (ValueUtils.noValue(name)) return; if (!synonyms.remove(name)) { synonyms.removeIf(name::equalsIgnoreCase); } @@ -102,7 +103,7 @@ private void ensureNameIsNotSynonym() { public void setName(String name) { name = name.trim(); var oldName = getName(); - if (oldName != null && !oldName.equalsIgnoreCase(name) && !oldName.isBlank()) { + if (ValueUtils.hasValue(oldName) && !oldName.equalsIgnoreCase(name)) { addSynonym(oldName); } super.setName(name); @@ -116,7 +117,7 @@ public boolean answersTo(String name) { public boolean hasSynonym(String synonym) { if (synonyms == null) return false; - if (synonym == null || synonym.isBlank()) return false; + if (ValueUtils.noValue(synonym)) return false; synonym = synonym.trim(); if (synonyms.contains(synonym)) return true; for (var syn : synonyms) diff --git a/src/main/java/com/brennaswitzer/cookbook/domain/Plan.java b/src/main/java/com/brennaswitzer/cookbook/domain/Plan.java index 6d8c0203..a069a00d 100644 --- a/src/main/java/com/brennaswitzer/cookbook/domain/Plan.java +++ b/src/main/java/com/brennaswitzer/cookbook/domain/Plan.java @@ -1,5 +1,6 @@ package com.brennaswitzer.cookbook.domain; +import com.brennaswitzer.cookbook.util.ValueUtils; import jakarta.persistence.CascadeType; import jakarta.persistence.DiscriminatorValue; import jakarta.persistence.Embedded; @@ -87,7 +88,7 @@ public int getBucketCount() { } public boolean hasBuckets() { - return buckets != null && !buckets.isEmpty(); + return ValueUtils.hasValue(buckets); } public Set getTrashBinItems() { @@ -98,7 +99,7 @@ public Set getTrashBinItems() { } public boolean hasTrash() { - return trashBinItems != null && !trashBinItems.isEmpty(); + return ValueUtils.hasValue(trashBinItems); } public User getOwner() { diff --git a/src/main/java/com/brennaswitzer/cookbook/domain/PlanBucket.java b/src/main/java/com/brennaswitzer/cookbook/domain/PlanBucket.java index 7fa66f12..e17d4215 100644 --- a/src/main/java/com/brennaswitzer/cookbook/domain/PlanBucket.java +++ b/src/main/java/com/brennaswitzer/cookbook/domain/PlanBucket.java @@ -1,5 +1,6 @@ package com.brennaswitzer.cookbook.domain; +import com.brennaswitzer.cookbook.util.ValueUtils; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -70,7 +71,7 @@ public boolean isDated() { } public boolean isNamed() { - return name != null && !name.isBlank(); + return ValueUtils.hasValue(name); } public void setPlan(Plan plan) { diff --git a/src/main/java/com/brennaswitzer/cookbook/domain/PlanItem.java b/src/main/java/com/brennaswitzer/cookbook/domain/PlanItem.java index 1bdf3b5b..a008555c 100644 --- a/src/main/java/com/brennaswitzer/cookbook/domain/PlanItem.java +++ b/src/main/java/com/brennaswitzer/cookbook/domain/PlanItem.java @@ -1,5 +1,6 @@ package com.brennaswitzer.cookbook.domain; +import com.brennaswitzer.cookbook.util.ValueUtils; import jakarta.persistence.Column; import jakarta.persistence.DiscriminatorColumn; import jakarta.persistence.DiscriminatorValue; @@ -455,7 +456,7 @@ public boolean hasBucket() { } public boolean hasNotes() { - return notes != null && !notes.isEmpty(); + return ValueUtils.hasValue(notes); } } diff --git a/src/main/java/com/brennaswitzer/cookbook/domain/S3File.java b/src/main/java/com/brennaswitzer/cookbook/domain/S3File.java index a1f3f16d..100c3637 100644 --- a/src/main/java/com/brennaswitzer/cookbook/domain/S3File.java +++ b/src/main/java/com/brennaswitzer/cookbook/domain/S3File.java @@ -1,5 +1,6 @@ package com.brennaswitzer.cookbook.domain; +import com.brennaswitzer.cookbook.util.ValueUtils; import jakarta.persistence.Embeddable; import lombok.AllArgsConstructor; import lombok.Getter; @@ -17,7 +18,7 @@ public class S3File { private static final Pattern FILENAME_SANITIZER = Pattern.compile("[^a-zA-Z0-9.\\-]+"); public static String sanitizeFilename(String filename) { - if (filename == null || filename.isBlank()) { + if (ValueUtils.noValue(filename)) { return "unnamed"; } // Opera supplies a full path, not just a filename diff --git a/src/main/java/com/brennaswitzer/cookbook/graphql/PantryQueryController.java b/src/main/java/com/brennaswitzer/cookbook/graphql/PantryQueryController.java index 6b93a13d..c386f03c 100644 --- a/src/main/java/com/brennaswitzer/cookbook/graphql/PantryQueryController.java +++ b/src/main/java/com/brennaswitzer/cookbook/graphql/PantryQueryController.java @@ -10,6 +10,7 @@ import com.brennaswitzer.cookbook.repositories.impl.SortDir; import com.brennaswitzer.cookbook.services.IngredientService; import com.brennaswitzer.cookbook.services.PantryItemService; +import com.brennaswitzer.cookbook.util.ValueUtils; import graphql.relay.Connection; import lombok.AccessLevel; import lombok.Builder; @@ -66,7 +67,7 @@ Long getDuplicateOf() { } Sort getSort() { - if (sortBy == null || sortBy.isBlank()) return null; + if (ValueUtils.noValue(sortBy)) return null; Sort.Direction dir = SortDir.DESC == sortDir ? Sort.Direction.DESC : Sort.Direction.ASC; diff --git a/src/main/java/com/brennaswitzer/cookbook/graphql/resolvers/RecipeResolver.java b/src/main/java/com/brennaswitzer/cookbook/graphql/resolvers/RecipeResolver.java index f0798962..c905214d 100644 --- a/src/main/java/com/brennaswitzer/cookbook/graphql/resolvers/RecipeResolver.java +++ b/src/main/java/com/brennaswitzer/cookbook/graphql/resolvers/RecipeResolver.java @@ -14,6 +14,7 @@ import com.brennaswitzer.cookbook.security.CurrentUser; import com.brennaswitzer.cookbook.security.UserPrincipal; import com.brennaswitzer.cookbook.util.ShareHelper; +import com.brennaswitzer.cookbook.util.ValueUtils; import org.dataloader.DataLoader; import org.hibernate.Hibernate; import org.springframework.beans.factory.annotation.Autowired; @@ -72,7 +73,7 @@ public List ingredients(Recipe recipe, Stream allIngredients = recipe.getIngredients() .stream() .filter(not(Section::isSection)); - if (ingredientIds != null && !ingredientIds.isEmpty()) { + if (ValueUtils.hasValue(ingredientIds)) { allIngredients = allIngredients .filter(r -> r.hasIngredient() && ingredientIds.contains(r.getIngredient().getId())); diff --git a/src/main/java/com/brennaswitzer/cookbook/payload/IngredientInfo.java b/src/main/java/com/brennaswitzer/cookbook/payload/IngredientInfo.java index 5bf0fdd4..209585ee 100644 --- a/src/main/java/com/brennaswitzer/cookbook/payload/IngredientInfo.java +++ b/src/main/java/com/brennaswitzer/cookbook/payload/IngredientInfo.java @@ -1,5 +1,6 @@ package com.brennaswitzer.cookbook.payload; +import com.brennaswitzer.cookbook.util.ValueUtils; import com.fasterxml.jackson.annotation.JsonInclude; import lombok.Getter; import lombok.Setter; @@ -28,7 +29,7 @@ public boolean isCookThis() { } public boolean hasSections() { - return getSections() != null && !getSections().isEmpty(); + return ValueUtils.hasValue(getSections()); } } diff --git a/src/main/java/com/brennaswitzer/cookbook/payload/IngredientRefInfo.java b/src/main/java/com/brennaswitzer/cookbook/payload/IngredientRefInfo.java index 6c85295c..2a3129ea 100644 --- a/src/main/java/com/brennaswitzer/cookbook/payload/IngredientRefInfo.java +++ b/src/main/java/com/brennaswitzer/cookbook/payload/IngredientRefInfo.java @@ -3,6 +3,7 @@ import com.brennaswitzer.cookbook.domain.IngredientRef; import com.brennaswitzer.cookbook.domain.Quantity; import com.brennaswitzer.cookbook.domain.UnitOfMeasure; +import com.brennaswitzer.cookbook.util.ValueUtils; import com.fasterxml.jackson.annotation.JsonInclude; import jakarta.persistence.EntityManager; import lombok.Getter; @@ -47,7 +48,7 @@ public void setUnits(String units) { @Deprecated public boolean hasUnits() { - return units != null && !units.isBlank(); + return ValueUtils.hasValue(units); } public boolean hasUomId() { diff --git a/src/main/java/com/brennaswitzer/cookbook/payload/RawIngredientDissection.java b/src/main/java/com/brennaswitzer/cookbook/payload/RawIngredientDissection.java index 47114410..fc394fe0 100644 --- a/src/main/java/com/brennaswitzer/cookbook/payload/RawIngredientDissection.java +++ b/src/main/java/com/brennaswitzer/cookbook/payload/RawIngredientDissection.java @@ -1,6 +1,7 @@ package com.brennaswitzer.cookbook.payload; import com.brennaswitzer.cookbook.util.RawUtils; +import com.brennaswitzer.cookbook.util.ValueUtils; import jakarta.validation.constraints.NotNull; import lombok.EqualsAndHashCode; import lombok.Getter; @@ -115,8 +116,7 @@ public String getNameText() { } public void setPrep(String prep) { - if (prep != null) prep = prep.trim(); - this.prep = prep == null || prep.isEmpty() ? null : prep; + this.prep = ValueUtils.hasValue(prep) ? prep.trim() : null; } @Value diff --git a/src/main/java/com/brennaswitzer/cookbook/repositories/impl/LibrarySearchRequest.java b/src/main/java/com/brennaswitzer/cookbook/repositories/impl/LibrarySearchRequest.java index fc545df4..fd1ac962 100644 --- a/src/main/java/com/brennaswitzer/cookbook/repositories/impl/LibrarySearchRequest.java +++ b/src/main/java/com/brennaswitzer/cookbook/repositories/impl/LibrarySearchRequest.java @@ -2,6 +2,7 @@ import com.brennaswitzer.cookbook.domain.User; import com.brennaswitzer.cookbook.repositories.SearchRequest; +import com.brennaswitzer.cookbook.util.ValueUtils; import lombok.Builder; import lombok.Value; import org.springframework.data.domain.Sort; @@ -25,7 +26,7 @@ public class LibrarySearchRequest implements SearchRequest { Sort sort; public boolean isFiltered() { - return filter != null && !filter.isBlank(); + return ValueUtils.hasValue(filter); } public boolean isOwnerConstrained() { @@ -33,7 +34,7 @@ public boolean isOwnerConstrained() { } public boolean isIngredientConstrained() { - return ingredientIds != null && !ingredientIds.isEmpty(); + return ValueUtils.hasValue(ingredientIds); } } diff --git a/src/main/java/com/brennaswitzer/cookbook/repositories/impl/PantryItemSearchRequest.java b/src/main/java/com/brennaswitzer/cookbook/repositories/impl/PantryItemSearchRequest.java index ad20a788..abaaa2c0 100644 --- a/src/main/java/com/brennaswitzer/cookbook/repositories/impl/PantryItemSearchRequest.java +++ b/src/main/java/com/brennaswitzer/cookbook/repositories/impl/PantryItemSearchRequest.java @@ -2,6 +2,7 @@ import com.brennaswitzer.cookbook.domain.PantryItem_; import com.brennaswitzer.cookbook.repositories.SearchRequest; +import com.brennaswitzer.cookbook.util.ValueUtils; import lombok.Builder; import lombok.Value; import org.springframework.data.domain.Sort; @@ -17,7 +18,7 @@ public class PantryItemSearchRequest implements SearchRequest { Sort sort; public boolean isFiltered() { - return filter != null && !filter.isBlank(); + return ValueUtils.hasValue(filter); } public boolean isDuplicateOf() { diff --git a/src/main/java/com/brennaswitzer/cookbook/security/oauth2/CustomOAuth2UserService.java b/src/main/java/com/brennaswitzer/cookbook/security/oauth2/CustomOAuth2UserService.java index 9e16edd3..395a3615 100644 --- a/src/main/java/com/brennaswitzer/cookbook/security/oauth2/CustomOAuth2UserService.java +++ b/src/main/java/com/brennaswitzer/cookbook/security/oauth2/CustomOAuth2UserService.java @@ -7,6 +7,7 @@ import com.brennaswitzer.cookbook.security.UserPrincipal; import com.brennaswitzer.cookbook.security.oauth2.user.OAuth2UserInfo; import com.brennaswitzer.cookbook.security.oauth2.user.OAuth2UserInfoFactory; +import com.brennaswitzer.cookbook.util.ValueUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.InternalAuthenticationServiceException; import org.springframework.security.core.AuthenticationException; @@ -15,7 +16,6 @@ import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; -import org.springframework.util.StringUtils; import java.util.Optional; @@ -41,7 +41,7 @@ public OAuth2User loadUser(OAuth2UserRequest oAuth2UserRequest) throws OAuth2Aut private OAuth2User processOAuth2User(OAuth2UserRequest oAuth2UserRequest, OAuth2User oAuth2User) { OAuth2UserInfo oAuth2UserInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(oAuth2UserRequest.getClientRegistration().getRegistrationId(), oAuth2User.getAttributes()); - if(StringUtils.isEmpty(oAuth2UserInfo.getEmail())) { + if (ValueUtils.noValue(oAuth2UserInfo.getEmail())) { throw new OAuth2AuthenticationProcessingException("Email not found from OAuth2 provider"); } diff --git a/src/main/java/com/brennaswitzer/cookbook/services/ItemService.java b/src/main/java/com/brennaswitzer/cookbook/services/ItemService.java index 5374271d..a61c89d7 100644 --- a/src/main/java/com/brennaswitzer/cookbook/services/ItemService.java +++ b/src/main/java/com/brennaswitzer/cookbook/services/ItemService.java @@ -12,6 +12,7 @@ import com.brennaswitzer.cookbook.util.EnglishUtils; import com.brennaswitzer.cookbook.util.NumberUtils; import com.brennaswitzer.cookbook.util.RawUtils; +import com.brennaswitzer.cookbook.util.ValueUtils; import jakarta.persistence.EntityManager; import lombok.EqualsAndHashCode; import lombok.Getter; @@ -44,8 +45,7 @@ public class ItemService { private IngredientService ingredientService; public RecognizedItem recognizeItem(String raw, int cursor, boolean withSuggestions) { - if (raw == null) return null; - if (raw.trim().isEmpty()) return null; + if (ValueUtils.noValue(raw)) return null; RecognizedItem item = new RecognizedItem(raw, cursor); RawIngredientDissection d = RawUtils.dissect(raw); RawIngredientDissection.Section secQuantity = d.getQuantity(); @@ -143,7 +143,7 @@ public List getSuggestions(RecognizedItem item, String search = raw.substring(hasQuote ? replaceStart + 1 : replaceStart, item.getCursor()) .trim() .toLowerCase(); - if (search.isEmpty()) { + if (ValueUtils.noValue(search)) { return Collections.emptyList(); } String singularSearch = EnglishUtils.unpluralize(search); @@ -214,7 +214,7 @@ public void updateAutoRecognition(MutableItem it) { public void autoRecognize(MutableItem it) { if (it == null) return; String raw = it.getRaw(); - if (raw == null || raw.isBlank()) return; + if (ValueUtils.noValue(raw)) return; RecognizedItem recog = recognizeItem(raw, raw.length(), false); if (recog == null) return; RawIngredientDissection dissection = RawIngredientDissection diff --git a/src/main/java/com/brennaswitzer/cookbook/services/PlanService.java b/src/main/java/com/brennaswitzer/cookbook/services/PlanService.java index e0c796ca..7e1aca45 100644 --- a/src/main/java/com/brennaswitzer/cookbook/services/PlanService.java +++ b/src/main/java/com/brennaswitzer/cookbook/services/PlanService.java @@ -392,7 +392,7 @@ private void recordRecipeHistories(PlanItem item, recordRecipeHistories(it, status, doneAt, 1); }); var diff = diffService.diffLinesToPatch(recipeLines, planLines); - if (!diff.isBlank()) { + if (ValueUtils.hasValue(diff)) { h.setNotes("```diff\n" + diff + "```\n"); } recipeHistoryRepo.save(h); diff --git a/src/main/java/com/brennaswitzer/cookbook/util/NumberUtils.java b/src/main/java/com/brennaswitzer/cookbook/util/NumberUtils.java index 6a163a47..f10df8c5 100644 --- a/src/main/java/com/brennaswitzer/cookbook/util/NumberUtils.java +++ b/src/main/java/com/brennaswitzer/cookbook/util/NumberUtils.java @@ -103,8 +103,7 @@ private NumberWithRange val(double val, ParserRuleContext ctx) { } public static NumberWithRange parseNumberWithRange(String str) { - if (str == null) return null; - if (str.trim().isEmpty()) return null; + if (ValueUtils.noValue(str)) return null; try { NumberLexer lexer = new NumberLexer(CharStreams.fromString(str.toLowerCase())); CommonTokenStream tokens = new CommonTokenStream(lexer); diff --git a/src/main/java/com/brennaswitzer/cookbook/util/RawUtils.java b/src/main/java/com/brennaswitzer/cookbook/util/RawUtils.java index 88a58217..7f93199b 100644 --- a/src/main/java/com/brennaswitzer/cookbook/util/RawUtils.java +++ b/src/main/java/com/brennaswitzer/cookbook/util/RawUtils.java @@ -60,8 +60,7 @@ public static String stripMarkers(String region) { } public static RawIngredientDissection dissect(String raw) { - if (raw == null) return null; - if (raw.isBlank()) return null; + if (ValueUtils.noValue(raw)) return null; RawIngredientDissection d = new RawIngredientDissection(raw); NumberUtils.NumberWithRange n = NumberUtils.parseNumberWithRange(raw); int pos = 0; diff --git a/src/main/java/com/brennaswitzer/cookbook/util/SlugUtils.java b/src/main/java/com/brennaswitzer/cookbook/util/SlugUtils.java index a36dbe2f..2ba19d02 100644 --- a/src/main/java/com/brennaswitzer/cookbook/util/SlugUtils.java +++ b/src/main/java/com/brennaswitzer/cookbook/util/SlugUtils.java @@ -30,7 +30,7 @@ public static String toSlug(String name) { } public static String toSlug(String name, int maxLength) { - if (name == null || name.isBlank()) return "empty"; + if (ValueUtils.noValue(name)) return "empty"; String slug = name; for (Munge m : MUNGES) { slug = m.munge(slug); diff --git a/src/main/java/com/brennaswitzer/cookbook/util/ValueUtils.java b/src/main/java/com/brennaswitzer/cookbook/util/ValueUtils.java index 4562d9c4..bf877302 100644 --- a/src/main/java/com/brennaswitzer/cookbook/util/ValueUtils.java +++ b/src/main/java/com/brennaswitzer/cookbook/util/ValueUtils.java @@ -1,13 +1,32 @@ package com.brennaswitzer.cookbook.util; +import java.util.Collection; +import java.util.Map; + public class ValueUtils { public static boolean hasValue(String s) { return s != null && !s.isBlank(); } + public static boolean hasValue(Collection coll) { + return coll != null && !coll.isEmpty(); + } + + public static boolean hasValue(Map map) { + return map != null && !map.isEmpty(); + } + public static boolean noValue(String s) { return s == null || s.isBlank(); } + public static boolean noValue(Collection coll) { + return coll == null || coll.isEmpty(); + } + + public static boolean noValue(Map map) { + return map == null || map.isEmpty(); + } + } diff --git a/src/test/java/com/brennaswitzer/cookbook/util/RawUtilsTest.java b/src/test/java/com/brennaswitzer/cookbook/util/RawUtilsTest.java index 876f7e7b..3e44e60d 100644 --- a/src/test/java/com/brennaswitzer/cookbook/util/RawUtilsTest.java +++ b/src/test/java/com/brennaswitzer/cookbook/util/RawUtilsTest.java @@ -91,7 +91,7 @@ private static Stream fromTestFile() throws IOException "/raw/dissections.txt"))); r.readLine(); // the header return r.lines() - .filter(l -> !l.isBlank()) + .filter(ValueUtils::hasValue) .filter(l -> !l.startsWith("#")) .filter(l -> !l.startsWith("//")) .map(l -> l.split("\\|"))