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' => '' } })
+ 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' => '' } })
+ expect(tomato.svg_icon).to eq ''
+ 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