diff --git a/app/assets/images/crops/COPYRIGHT b/app/assets/images/crops/COPYRIGHT new file mode 100644 index 000000000..7a651d0ca --- /dev/null +++ b/app/assets/images/crops/COPYRIGHT @@ -0,0 +1,7 @@ +The SVG icons in this folder are the "Flat" style of Microsoft's Fluent Emoji, +https://github.com/microsoft/fluentui-emoji, taken from commit 1ffb34c75 +(24 August 2026) and renamed to the emoji's name in lower case, so +"Ear of corn" is ear_of_corn.svg. + +They are Copyright (c) Microsoft Corporation, and used under the MIT License, +which is in LICENSE alongside them. diff --git a/app/assets/images/crops/LICENSE b/app/assets/images/crops/LICENSE new file mode 100644 index 000000000..9e841e7a2 --- /dev/null +++ b/app/assets/images/crops/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/app/assets/images/crops/avocado.svg b/app/assets/images/crops/avocado.svg new file mode 100644 index 000000000..281808f43 --- /dev/null +++ b/app/assets/images/crops/avocado.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/assets/images/crops/banana.svg b/app/assets/images/crops/banana.svg new file mode 100644 index 000000000..d5ad21117 --- /dev/null +++ b/app/assets/images/crops/banana.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/assets/images/crops/beans.svg b/app/assets/images/crops/beans.svg new file mode 100644 index 000000000..afeb18643 --- /dev/null +++ b/app/assets/images/crops/beans.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/images/crops/bell_pepper.svg b/app/assets/images/crops/bell_pepper.svg new file mode 100644 index 000000000..66fa53131 --- /dev/null +++ b/app/assets/images/crops/bell_pepper.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/assets/images/crops/blossom.svg b/app/assets/images/crops/blossom.svg new file mode 100644 index 000000000..09c6df5be --- /dev/null +++ b/app/assets/images/crops/blossom.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/assets/images/crops/blueberries.svg b/app/assets/images/crops/blueberries.svg new file mode 100644 index 000000000..e7c93d80b --- /dev/null +++ b/app/assets/images/crops/blueberries.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/app/assets/images/crops/broccoli.svg b/app/assets/images/crops/broccoli.svg new file mode 100644 index 000000000..c7476cb64 --- /dev/null +++ b/app/assets/images/crops/broccoli.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/images/crops/brown_mushroom.svg b/app/assets/images/crops/brown_mushroom.svg new file mode 100644 index 000000000..90298fe9e --- /dev/null +++ b/app/assets/images/crops/brown_mushroom.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/assets/images/crops/cactus.svg b/app/assets/images/crops/cactus.svg new file mode 100644 index 000000000..66a881843 --- /dev/null +++ b/app/assets/images/crops/cactus.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/images/crops/carrot.svg b/app/assets/images/crops/carrot.svg new file mode 100644 index 000000000..0ece92fcf --- /dev/null +++ b/app/assets/images/crops/carrot.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/assets/images/crops/cherries.svg b/app/assets/images/crops/cherries.svg new file mode 100644 index 000000000..6c4842567 --- /dev/null +++ b/app/assets/images/crops/cherries.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/images/crops/cherry_blossom.svg b/app/assets/images/crops/cherry_blossom.svg new file mode 100644 index 000000000..fc3c0b595 --- /dev/null +++ b/app/assets/images/crops/cherry_blossom.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/images/crops/chestnut.svg b/app/assets/images/crops/chestnut.svg new file mode 100644 index 000000000..9079171ce --- /dev/null +++ b/app/assets/images/crops/chestnut.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/images/crops/coconut.svg b/app/assets/images/crops/coconut.svg new file mode 100644 index 000000000..57cb44eef --- /dev/null +++ b/app/assets/images/crops/coconut.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/assets/images/crops/cucumber.svg b/app/assets/images/crops/cucumber.svg new file mode 100644 index 000000000..2178db4cd --- /dev/null +++ b/app/assets/images/crops/cucumber.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/assets/images/crops/deciduous_tree.svg b/app/assets/images/crops/deciduous_tree.svg new file mode 100644 index 000000000..381f8382d --- /dev/null +++ b/app/assets/images/crops/deciduous_tree.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/images/crops/ear_of_corn.svg b/app/assets/images/crops/ear_of_corn.svg new file mode 100644 index 000000000..951905179 --- /dev/null +++ b/app/assets/images/crops/ear_of_corn.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/assets/images/crops/eggplant.svg b/app/assets/images/crops/eggplant.svg new file mode 100644 index 000000000..773c18eb5 --- /dev/null +++ b/app/assets/images/crops/eggplant.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/assets/images/crops/evergreen_tree.svg b/app/assets/images/crops/evergreen_tree.svg new file mode 100644 index 000000000..d0a3dd3a0 --- /dev/null +++ b/app/assets/images/crops/evergreen_tree.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/images/crops/four_leaf_clover.svg b/app/assets/images/crops/four_leaf_clover.svg new file mode 100644 index 000000000..6c1a36653 --- /dev/null +++ b/app/assets/images/crops/four_leaf_clover.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/images/crops/garlic.svg b/app/assets/images/crops/garlic.svg new file mode 100644 index 000000000..0647883b7 --- /dev/null +++ b/app/assets/images/crops/garlic.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/images/crops/grapes.svg b/app/assets/images/crops/grapes.svg new file mode 100644 index 000000000..8608c8748 --- /dev/null +++ b/app/assets/images/crops/grapes.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/app/assets/images/crops/green_apple.svg b/app/assets/images/crops/green_apple.svg new file mode 100644 index 000000000..8789dbd69 --- /dev/null +++ b/app/assets/images/crops/green_apple.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/images/crops/herb.svg b/app/assets/images/crops/herb.svg new file mode 100644 index 000000000..ce0f7653c --- /dev/null +++ b/app/assets/images/crops/herb.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/app/assets/images/crops/hibiscus.svg b/app/assets/images/crops/hibiscus.svg new file mode 100644 index 000000000..3f9f93272 --- /dev/null +++ b/app/assets/images/crops/hibiscus.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/app/assets/images/crops/hot_pepper.svg b/app/assets/images/crops/hot_pepper.svg new file mode 100644 index 000000000..a272d6894 --- /dev/null +++ b/app/assets/images/crops/hot_pepper.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/images/crops/hyacinth.svg b/app/assets/images/crops/hyacinth.svg new file mode 100644 index 000000000..a5fc6e93f --- /dev/null +++ b/app/assets/images/crops/hyacinth.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/app/assets/images/crops/kiwi_fruit.svg b/app/assets/images/crops/kiwi_fruit.svg new file mode 100644 index 000000000..dd77c6f62 --- /dev/null +++ b/app/assets/images/crops/kiwi_fruit.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/app/assets/images/crops/leafy_green.svg b/app/assets/images/crops/leafy_green.svg new file mode 100644 index 000000000..4aeb0af74 --- /dev/null +++ b/app/assets/images/crops/leafy_green.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/assets/images/crops/lemon.svg b/app/assets/images/crops/lemon.svg new file mode 100644 index 000000000..9525b3553 --- /dev/null +++ b/app/assets/images/crops/lemon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/images/crops/lime.svg b/app/assets/images/crops/lime.svg new file mode 100644 index 000000000..f5c96bda6 --- /dev/null +++ b/app/assets/images/crops/lime.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/app/assets/images/crops/lotus.svg b/app/assets/images/crops/lotus.svg new file mode 100644 index 000000000..f4131056d --- /dev/null +++ b/app/assets/images/crops/lotus.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/app/assets/images/crops/mango.svg b/app/assets/images/crops/mango.svg new file mode 100644 index 000000000..62dca8841 --- /dev/null +++ b/app/assets/images/crops/mango.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/images/crops/melon.svg b/app/assets/images/crops/melon.svg new file mode 100644 index 000000000..e0638efb9 --- /dev/null +++ b/app/assets/images/crops/melon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/assets/images/crops/mushroom.svg b/app/assets/images/crops/mushroom.svg new file mode 100644 index 000000000..465c23128 --- /dev/null +++ b/app/assets/images/crops/mushroom.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/assets/images/crops/olive.svg b/app/assets/images/crops/olive.svg new file mode 100644 index 000000000..7867307dd --- /dev/null +++ b/app/assets/images/crops/olive.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/assets/images/crops/onion.svg b/app/assets/images/crops/onion.svg new file mode 100644 index 000000000..3757f6854 --- /dev/null +++ b/app/assets/images/crops/onion.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/assets/images/crops/palm_tree.svg b/app/assets/images/crops/palm_tree.svg new file mode 100644 index 000000000..14a339aca --- /dev/null +++ b/app/assets/images/crops/palm_tree.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/app/assets/images/crops/pea_pod.svg b/app/assets/images/crops/pea_pod.svg new file mode 100644 index 000000000..98bb54b3c --- /dev/null +++ b/app/assets/images/crops/pea_pod.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/assets/images/crops/peach.svg b/app/assets/images/crops/peach.svg new file mode 100644 index 000000000..868c8b86a --- /dev/null +++ b/app/assets/images/crops/peach.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/assets/images/crops/peanuts.svg b/app/assets/images/crops/peanuts.svg new file mode 100644 index 000000000..9c5038926 --- /dev/null +++ b/app/assets/images/crops/peanuts.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/app/assets/images/crops/pear.svg b/app/assets/images/crops/pear.svg new file mode 100644 index 000000000..06804beee --- /dev/null +++ b/app/assets/images/crops/pear.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/images/crops/pineapple.svg b/app/assets/images/crops/pineapple.svg new file mode 100644 index 000000000..cdcc20ff1 --- /dev/null +++ b/app/assets/images/crops/pineapple.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/app/assets/images/crops/potato.svg b/app/assets/images/crops/potato.svg new file mode 100644 index 000000000..fe62f8f83 --- /dev/null +++ b/app/assets/images/crops/potato.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/app/assets/images/crops/potted_plant.svg b/app/assets/images/crops/potted_plant.svg new file mode 100644 index 000000000..fc15c40fb --- /dev/null +++ b/app/assets/images/crops/potted_plant.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/assets/images/crops/red_apple.svg b/app/assets/images/crops/red_apple.svg new file mode 100644 index 000000000..aac151e99 --- /dev/null +++ b/app/assets/images/crops/red_apple.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/images/crops/rose.svg b/app/assets/images/crops/rose.svg new file mode 100644 index 000000000..62aab83cd --- /dev/null +++ b/app/assets/images/crops/rose.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/assets/images/crops/seedling.svg b/app/assets/images/crops/seedling.svg new file mode 100644 index 000000000..541a51175 --- /dev/null +++ b/app/assets/images/crops/seedling.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/images/crops/shamrock.svg b/app/assets/images/crops/shamrock.svg new file mode 100644 index 000000000..cc11d2b4e --- /dev/null +++ b/app/assets/images/crops/shamrock.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/assets/images/crops/sheaf_of_rice.svg b/app/assets/images/crops/sheaf_of_rice.svg new file mode 100644 index 000000000..0074627cf --- /dev/null +++ b/app/assets/images/crops/sheaf_of_rice.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/assets/images/crops/strawberry.svg b/app/assets/images/crops/strawberry.svg new file mode 100644 index 000000000..3255bf565 --- /dev/null +++ b/app/assets/images/crops/strawberry.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/assets/images/crops/sunflower.svg b/app/assets/images/crops/sunflower.svg new file mode 100644 index 000000000..1083cf637 --- /dev/null +++ b/app/assets/images/crops/sunflower.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/assets/images/crops/tangerine.svg b/app/assets/images/crops/tangerine.svg new file mode 100644 index 000000000..5a92c382e --- /dev/null +++ b/app/assets/images/crops/tangerine.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/images/crops/tomato.svg b/app/assets/images/crops/tomato.svg new file mode 100644 index 000000000..35e3e9224 --- /dev/null +++ b/app/assets/images/crops/tomato.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/assets/images/crops/tulip.svg b/app/assets/images/crops/tulip.svg new file mode 100644 index 000000000..c7509e9d8 --- /dev/null +++ b/app/assets/images/crops/tulip.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/assets/images/crops/watermelon.svg b/app/assets/images/crops/watermelon.svg new file mode 100644 index 000000000..89db2b56b --- /dev/null +++ b/app/assets/images/crops/watermelon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/app/controllers/crops_controller.rb b/app/controllers/crops_controller.rb index 2a8f09687..a174c69fa 100644 --- a/app/controllers/crops_controller.rb +++ b/app/controllers/crops_controller.rb @@ -236,7 +236,7 @@ class CropsController < ApplicationController def crop_params params.require(:crop).permit( :name, :en_wikipedia_url, :en_youtube_url, - :parent_id, :perennial, + :parent_id, :perennial, :icon, :request_notes, :reason_for_rejection, :rejection_notes, :description, diff --git a/app/models/concerns/open_farm_data.rb b/app/models/concerns/open_farm_data.rb index 34573d742..11e3d88c9 100644 --- a/app/models/concerns/open_farm_data.rb +++ b/app/models/concerns/open_farm_data.rb @@ -8,11 +8,11 @@ module OpenFarmData fetch_attr('main_image_path') end - def svg_icon - icon = fetch_attr('svg_icon') - return icon if icon.present? - - parent.svg_icon if parent.present? + # The icon OpenFarm had for this crop, if any. OpenFarm is gone, so these + # are whatever was fetched before it closed; Crop#svg_icon decides which + # icon a crop actually gets. + def openfarm_svg_icon + fetch_attr('svg_icon') end def tags_array diff --git a/app/models/crop.rb b/app/models/crop.rb index bf94a9fed..7dde31277 100644 --- a/app/models/crop.rb +++ b/app/models/crop.rb @@ -67,11 +67,35 @@ class Crop < ApplicationRecord }, allow_blank: true validates :name, uniqueness: { scope: :approval_status }, if: :pending? + validates :icon, allow_blank: true, inclusion: { in: ->(_) { icon_names } } + + # Crop icons that ship with the app: Microsoft's Fluent Emoji (MIT licensed; + # see COPYRIGHT there), one per food plant or flower they draw. + ICON_DIR = Rails.root.join('app/assets/images/crops') + + def self.icon_names + @icon_names ||= Dir[ICON_DIR.join('*.svg')].map { |path| File.basename(path, '.svg') }.sort + end + + # Read once and kept, as crop chips ask for these a lot. + def self.icon_svg(name) + @icon_svgs ||= {} + @icon_svgs[name] ||= ICON_DIR.join("#{name}.svg").read + end def to_s name end + # This crop's icon as SVG: the one chosen for it, else the one OpenFarm had, + # else its parent crop's, so a variety of tomato gets the tomato. Nil when + # none of those has one; crops#show then serves a sprout. + def svg_icon + return self.class.icon_svg(icon) if icon.present? + + openfarm_svg_icon.presence || parent&.svg_icon + end + def to_param slug end diff --git a/app/services/crop_icon_matcher.rb b/app/services/crop_icon_matcher.rb new file mode 100644 index 000000000..90d47f991 --- /dev/null +++ b/app/services/crop_icon_matcher.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +# Gives crops one of the app's crop icons by name, for crops that haven't had +# one chosen. Crops named like the plant an icon draws get it, and varieties +# then follow their parent crop, so tagging "tomato" covers every tomato. +# +# A crop with no parent to follow is also matched on the end of its name, so +# "cherry tomato" gets the tomato and "flat leaf parsley" the herb. +# +# Never replaces an icon a wrangler has already chosen. +class CropIconMatcher + # Icon => the crop names it stands for, singular, lower case. + MATCHES = { + 'avocado' => %w(avocado), + 'banana' => %w(banana plantain), + 'beans' => ['bean', 'broad bean', 'fava bean', 'runner bean', 'green bean', 'french bean', + 'soybean', 'soy bean', 'kidney bean', 'lima bean', 'borlotti bean'], + 'bell_pepper' => ['bell pepper', 'capsicum', 'sweet pepper', 'pepper'], + 'blueberries' => %w(blueberry), + 'broccoli' => ['broccoli', 'cauliflower', 'romanesco', 'brussels sprout'], + 'brown_mushroom' => ['shiitake', 'oyster mushroom'], + 'cactus' => ['cactus', 'prickly pear', 'dragon fruit'], + 'carrot' => %w(carrot parsnip), + 'cherries' => %w(cherry), + 'chestnut' => %w(chestnut), + 'coconut' => %w(coconut), + 'cucumber' => %w(cucumber gherkin zucchini courgette), + 'ear_of_corn' => ['corn', 'sweet corn', 'sweetcorn', 'maize', 'popcorn'], + 'eggplant' => %w(eggplant aubergine), + 'four_leaf_clover' => %w(clover alfalfa lucerne), + 'garlic' => %w(garlic), + 'grapes' => %w(grape), + 'green_apple' => ['granny smith'], + 'herb' => ['basil', 'parsley', 'coriander', 'cilantro', 'mint', 'spearmint', 'peppermint', + 'thyme', 'oregano', 'marjoram', 'rosemary', 'sage', 'dill', 'tarragon', + 'lemon balm', 'chervil', 'lovage', 'sorrel', 'bay', 'bay laurel', 'stevia', + 'catnip', 'chamomile', 'anise', 'alexanders', 'lemongrass', 'borage'], + 'hibiscus' => %w(hibiscus rosella), + # Named ones too, or matching on the last word would make them bell peppers. + 'hot_pepper' => ['chilli', 'chili', 'chile', 'chilli pepper', 'chili pepper', 'hot pepper', + 'cayenne pepper', 'jalapeno', 'jalapeño', 'jalapeno pepper', 'jalapeño pepper', + 'habanero', 'habanero pepper', 'tabasco pepper', 'serrano pepper', + 'poblano pepper', 'ghost pepper', 'scotch bonnet', 'scotch bonnet pepper'], + 'hyacinth' => %w(hyacinth lavender), + 'kiwi_fruit' => %w(kiwifruit kiwi), + 'leafy_green' => ['lettuce', 'spinach', 'kale', 'cabbage', 'silverbeet', 'chard', 'swiss chard', + 'rocket', 'arugula', 'bok choy', 'pak choi', 'collard', 'collards', + 'mustard greens', 'endive', 'radicchio', 'mizuna', 'watercress', 'celery'], + 'lemon' => %w(lemon), + 'lime' => %w(lime), + 'lotus' => %w(lotus), + 'mango' => %w(mango), + 'melon' => %w(melon rockmelon cantaloupe honeydew muskmelon), + 'mushroom' => %w(mushroom), + 'olive' => %w(olive), + 'onion' => ['onion', 'shallot', 'leek', 'spring onion', 'scallion', 'chives', 'chive'], + 'pea_pod' => ['pea', 'snow pea', 'sugar snap pea', 'snap pea', 'mangetout'], + 'peach' => %w(peach nectarine apricot plum), + 'peanuts' => %w(peanut), + 'pear' => %w(pear nashi), + 'pineapple' => %w(pineapple), + 'potato' => ['potato', 'sweet potato', 'kumara', 'yam', 'jerusalem artichoke'], + 'red_apple' => %w(apple crabapple), + 'rose' => %w(rose), + 'sheaf_of_rice' => %w(rice wheat barley oat oats rye), + 'strawberry' => %w(strawberry), + 'sunflower' => %w(sunflower), + 'tangerine' => %w(orange mandarin tangerine clementine grapefruit), + 'tomato' => %w(tomato), + 'tulip' => %w(tulip), + 'watermelon' => %w(watermelon) + }.freeze + + # Crop name => icon, for looking up. + BY_NAME = MATCHES.flat_map { |icon, names| names.map { |name| [name, icon] } }.to_h.freeze + + def self.icon_for(name) + key = name.to_s.downcase.strip + BY_NAME[key] || BY_NAME[key.singularize] + end + + # The same, but also trying the end of the name, longest first: "persian + # lime", then "lime". + def self.icon_for_last_words(name) + words = name.to_s.downcase.split + words.each_index.lazy.filter_map { |start| icon_for(words[start..].join(' ')) }.first + end + + # Returns the crops it gave an icon, as {crop => icon}. With dry_run, says + # what it would do and changes nothing. + def call(dry_run: false) + Crop.where(icon: [nil, '']).find_each.with_object({}) do |crop, assigned| + icon = crop.parent_id ? self.class.icon_for(crop.name) : self.class.icon_for_last_words(crop.name) + next if icon.nil? + + crop.update_column(:icon, icon) unless dry_run # rubocop:disable Rails/SkipsModelValidations + assigned[crop] = icon + end + end +end diff --git a/app/views/crops/_form.html.haml b/app/views/crops/_form.html.haml index c4274601b..81850c022 100644 --- a/app/views/crops/_form.html.haml +++ b/app/views/crops/_form.html.haml @@ -89,6 +89,14 @@ { include_blank: true, label: 'Parent crop'}) %span.form-text Optional. For setting up crop hierarchies for varieties etc. + - icon_choices = Crop.icon_names.map { |icon| [icon.humanize, icon] } + = f.select(:icon, icon_choices, { include_blank: "None: use the parent crop's", label: 'Icon' }) + %span.form-text + Optional. Shown for this crop and any varieties of it that don't have their own. + - if @crop.persisted? && @crop.svg_icon.present? + Currently: + = image_tag crop_path(@crop, format: 'svg'), class: 'crop-icon', alt: '' + -# Everyone (wranglers and requesters) gets to add scientific names %h2 Scientific names diff --git a/db/migrate/20260922140000_add_icon_to_crops.rb b/db/migrate/20260922140000_add_icon_to_crops.rb new file mode 100644 index 000000000..5ba331fa0 --- /dev/null +++ b/db/migrate/20260922140000_add_icon_to_crops.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# Which of the app's crop icons (app/assets/images/crops) a crop is drawn +# with, by file name. Blank means it follows its parent crop. +class AddIconToCrops < ActiveRecord::Migration[8.1] + def change + add_column :crops, :icon, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 3011ba8ad..7bf61002a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_09_21_000000) do +ActiveRecord::Schema[8.1].define(version: 2026_09_22_140000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -551,6 +551,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_09_21_000000) do t.integer "growing_degree_days" t.integer "harvests_count", default: 0 t.integer "height" + t.string "icon" t.integer "median_days_to_first_harvest" t.integer "median_days_to_last_harvest" t.integer "median_lifespan" diff --git a/lib/tasks/crops.rake b/lib/tasks/crops.rake index 7344fa9ce..e314e99be 100644 --- a/lib/tasks/crops.rake +++ b/lib/tasks/crops.rake @@ -23,4 +23,13 @@ namespace :crops do abort "Stopped: #{e.message}" end end + + desc "Give crops an icon from app/assets/images/crops by their name, where they haven't one. " \ + "Varieties then follow their parent. DRY_RUN=true to only list what it would do." + task assign_icons: :environment do + dry_run = ENV['DRY_RUN'] == 'true' + assigned = CropIconMatcher.new.call(dry_run:) + assigned.each { |crop, icon| puts "#{crop.name} -> #{icon}" } + puts "#{dry_run ? 'Would give' : 'Gave'} #{assigned.size} crops an icon." + end end diff --git a/spec/models/crop_spec.rb b/spec/models/crop_spec.rb index 1b59c1b93..e25b10096 100644 --- a/spec/models/crop_spec.rb +++ b/spec/models/crop_spec.rb @@ -606,4 +606,47 @@ describe Crop do end end end + + describe 'its icon' do + let(:tomato) { create(:crop, name: 'tomato') } + let(:variety) { create(:crop, name: 'island bay tomato', parent: tomato) } + + it 'has none until one is chosen, or OpenFarm had one' do + expect(tomato.svg_icon).to be_nil + end + + it "draws the app's icon chosen for it" do + tomato.update!(icon: 'tomato') + expect(tomato.svg_icon).to eq Rails.root.join('app/assets/images/crops/tomato.svg').read + end + + it 'prefers its chosen icon to the one OpenFarm had' do + tomato.update!(icon: 'tomato', openfarm_data: { 'attributes' => { 'svg_icon' => 'openfarm' } }) + expect(tomato.svg_icon).to include('viewBox="0 0 32 32"') + end + + it 'falls back to the one OpenFarm had' do + tomato.update!(openfarm_data: { 'attributes' => { 'svg_icon' => 'openfarm' } }) + expect(tomato.svg_icon).to eq 'openfarm' + end + + it 'follows its parent crop until it has its own' do + tomato.update!(icon: 'tomato') + expect(variety.svg_icon).to eq tomato.svg_icon + + variety.update!(icon: 'cherries') + expect(variety.svg_icon).to eq described_class.icon_svg('cherries') + end + + it "must be one of the app's icons" do + expect(build(:crop, icon: 'tomato')).to be_valid + expect(build(:crop, icon: 'dragon')).not_to be_valid + expect(build(:crop, icon: '')).to be_valid + end + + it 'lists the icons there are to choose from' do + expect(described_class.icon_names).to include('tomato', 'herb', 'leafy_green') + expect(described_class.icon_names).to eq described_class.icon_names.sort + end + end end diff --git a/spec/requests/crops_controller_icons_spec.rb b/spec/requests/crops_controller_icons_spec.rb new file mode 100644 index 000000000..70809cbc2 --- /dev/null +++ b/spec/requests/crops_controller_icons_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe CropsController do + let(:tomato) { create(:crop, name: 'tomato') } + + describe 'crop icons: GET /crops/:slug.svg' do + it "serves the icon chosen for the crop" do + tomato.update!(icon: 'tomato') + get crop_path(tomato, format: 'svg') + + expect(response).to have_http_status(:ok) + expect(response.media_type).to eq 'image/svg+xml' + expect(response.body).to eq Crop.icon_svg('tomato') + end + + it 'serves a sprout for a crop with no icon' do + get crop_path(tomato, format: 'svg') + + expect(response.body).to eq Rails.root.join('app/assets/images/icons/sprout.svg').read + end + end + + describe 'crop icons: choosing one' do + it 'lets a crop wrangler set it from the crop form' do + sign_in create(:crop_wrangling_member) + patch crop_path(tomato), params: { crop: { icon: 'tomato' } } + + expect(tomato.reload.icon).to eq 'tomato' + end + + it 'offers the icons on the crop form' do + sign_in create(:crop_wrangling_member) + get edit_crop_path(tomato) + + expect(response.body).to include('') + end + end +end diff --git a/spec/services/crop_icon_matcher_spec.rb b/spec/services/crop_icon_matcher_spec.rb new file mode 100644 index 000000000..de6b593aa --- /dev/null +++ b/spec/services/crop_icon_matcher_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe CropIconMatcher do + describe '.icon_for' do + it 'knows crops by name, whatever the case, singular or plural' do + expect(described_class.icon_for('Tomato')).to eq 'tomato' + expect(described_class.icon_for('strawberries')).to eq 'strawberry' + end + + it 'knows the other names a crop goes by' do + expect(described_class.icon_for('aubergine')).to eq 'eggplant' + expect(described_class.icon_for('capsicum')).to eq 'bell_pepper' + expect(described_class.icon_for('basil')).to eq 'herb' + end + + it 'has nothing for a crop without an icon' do + expect(described_class.icon_for('fig')).to be_nil + end + end + + describe '.icon_for_last_words' do + it 'goes by the end of the name, longest first' do + expect(described_class.icon_for_last_words('cherry tomato')).to eq 'tomato' + expect(described_class.icon_for_last_words('flat leaf parsley')).to eq 'herb' + expect(described_class.icon_for_last_words('rocket potato')).to eq 'potato' + end + + it "doesn't make a named chilli a bell pepper" do + expect(described_class.icon_for_last_words('habanero pepper')).to eq 'hot_pepper' + expect(described_class.icon_for_last_words('sweet pepper')).to eq 'bell_pepper' + end + end + + it 'names only icons the app has' do + expect(described_class::MATCHES.keys - Crop.icon_names).to be_empty + end + + describe '#call' do + it 'gives crops their icon, and says which' do + tomato = create(:crop, name: 'tomato') + fig = create(:crop, name: 'fig') + + expect(described_class.new.call).to eq(tomato => 'tomato') + expect(tomato.reload.icon).to eq 'tomato' + expect(fig.reload.icon).to be_nil + end + + it 'matches a crop with no parent by the end of its name' do + cherry = create(:crop, name: 'cherry tomato') + described_class.new.call + + expect(cherry.reload.icon).to eq 'tomato' + end + + # A variety that has a parent follows its icon, rather than getting its own. + it 'leaves a variety to follow its parent' do + tomato = create(:crop, name: 'tomato') + cherry = create(:crop, name: 'cherry tomato', parent: tomato) + described_class.new.call + + expect(cherry.reload.icon).to be_nil + expect(cherry.svg_icon).to eq tomato.reload.svg_icon + end + + it "never replaces an icon someone chose" do + tomato = create(:crop, name: 'tomato', icon: 'cherries') + described_class.new.call + + expect(tomato.reload.icon).to eq 'cherries' + end + + it 'changes nothing on a dry run' do + tomato = create(:crop, name: 'tomato') + + expect(described_class.new.call(dry_run: true)).to eq(tomato => 'tomato') + expect(tomato.reload.icon).to be_nil + end + end +end