diff --git a/apps/web/drizzle/0005_add-prevalence-score.sql b/apps/web/drizzle/0005_add-prevalence-score.sql new file mode 100644 index 0000000..486c32b --- /dev/null +++ b/apps/web/drizzle/0005_add-prevalence-score.sql @@ -0,0 +1 @@ +ALTER TABLE `diseases` ADD COLUMN `prevalence_score` integer DEFAULT 0 NOT NULL; diff --git a/apps/web/drizzle/meta/_journal.json b/apps/web/drizzle/meta/_journal.json index fd7cbc6..e8f6898 100644 --- a/apps/web/drizzle/meta/_journal.json +++ b/apps/web/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1751846400000, "tag": "0004_add-flagged-content", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1751846400000, + "tag": "0005_add-prevalence-score", + "breakpoints": true } ] } diff --git a/apps/web/scripts/.ddg-progress.json b/apps/web/scripts/.ddg-progress.json index cb25e0f..0904207 100644 --- a/apps/web/scripts/.ddg-progress.json +++ b/apps/web/scripts/.ddg-progress.json @@ -5599,7 +5599,1146 @@ "chive-leaf-spot-septoriacercospora", "chive-sunscald", "chive-nutrient-deficiency-general", - "chive-overwatering-damage-edema" + "chive-overwatering-damage-edema", + "monstera-fungal-leaf-spot-aroids", + "monstera-leaf-spot-septoriacercospora", + "monstera-sunscald", + "monstera-nutrient-deficiency-general", + "monstera-overwatering-damage-edema", + "pothos-sunscald", + "pothos-nutrient-deficiency-general", + "pothos-overwatering-damage-edema", + "peace-lily-sunscald", + "peace-lily-nutrient-deficiency-general", + "peace-lily-overwatering-damage-edema", + "philodendron-sunscald", + "philodendron-nutrient-deficiency-general", + "philodendron-overwatering-damage-edema", + "anthurium-sunscald", + "anthurium-nutrient-deficiency-general", + "anthurium-overwatering-damage-edema", + "alocasia-sunscald", + "alocasia-nutrient-deficiency-general", + "alocasia-overwatering-damage-edema", + "caladium-sunscald", + "caladium-nutrient-deficiency-general", + "caladium-overwatering-damage-edema", + "aglaonema-sunscald", + "aglaonema-nutrient-deficiency-general", + "aglaonema-overwatering-damage-edema", + "dieffenbachia-sunscald", + "dieffenbachia-nutrient-deficiency-general", + "dieffenbachia-overwatering-damage-edema", + "spathiphyllum-sunscald", + "spathiphyllum-nutrient-deficiency-general", + "spathiphyllum-overwatering-damage-edema", + "asparagus-sunscald", + "asparagus-nutrient-deficiency-general", + "asparagus-overwatering-damage-edema", + "snake-plant-sunscald", + "snake-plant-nutrient-deficiency-general", + "snake-plant-overwatering-damage-edema", + "yucca-sunscald", + "yucca-nutrient-deficiency-general", + "yucca-overwatering-damage-edema", + "dracaena-sunscald", + "dracaena-nutrient-deficiency-general", + "dracaena-overwatering-damage-edema", + "lily-of-the-valley-sunscald", + "lily-of-the-valley-nutrient-deficiency-general", + "lily-of-the-valley-overwatering-damage-edema", + "hosta-sunscald", + "hosta-nutrient-deficiency-general", + "hosta-overwatering-damage-edema", + "orchid-phalaenopsis-sunscald", + "orchid-phalaenopsis-nutrient-deficiency-general", + "orchid-phalaenopsis-overwatering-damage-edema", + "orchid-cattleya-sunscald", + "orchid-cattleya-nutrient-deficiency-general", + "orchid-cattleya-overwatering-damage-edema", + "orchid-dendrobium-sunscald", + "orchid-dendrobium-nutrient-deficiency-general", + "orchid-dendrobium-overwatering-damage-edema", + "orchid-oncidium-sunscald", + "orchid-oncidium-nutrient-deficiency-general", + "orchid-oncidium-overwatering-damage-edema", + "vanilla-sunscald", + "vanilla-nutrient-deficiency-general", + "vanilla-overwatering-damage-edema", + "prickly-pear-mealybugs-succulents", + "prickly-pear-sunscald", + "prickly-pear-nutrient-deficiency-general", + "prickly-pear-overwatering-damage-edema", + "barrel-cactus-mealybugs-succulents", + "barrel-cactus-sunscald", + "barrel-cactus-nutrient-deficiency-general", + "barrel-cactus-overwatering-damage-edema", + "christmas-cactus-mealybugs-succulents", + "christmas-cactus-sunscald", + "christmas-cactus-nutrient-deficiency-general", + "christmas-cactus-overwatering-damage-edema", + "saguaro-mealybugs-succulents", + "saguaro-sunscald", + "saguaro-nutrient-deficiency-general", + "saguaro-overwatering-damage-edema", + "aloe-vera-mealybugs-succulents", + "aloe-vera-sunscald", + "aloe-vera-nutrient-deficiency-general", + "aloe-vera-overwatering-damage-edema", + "agave-sunscald", + "agave-nutrient-deficiency-general", + "agave-overwatering-damage-edema", + "echeveria-mealybugs-succulents", + "echeveria-sunscald", + "echeveria-nutrient-deficiency-general", + "echeveria-overwatering-damage-edema", + "jade-plant-mealybugs-succulents", + "jade-plant-sunscald", + "jade-plant-nutrient-deficiency-general", + "jade-plant-overwatering-damage-edema", + "sedum-mealybugs-succulents", + "sedum-sunscald", + "sedum-nutrient-deficiency-general", + "sedum-overwatering-damage-edema", + "haworthia-mealybugs-succulents", + "haworthia-sunscald", + "haworthia-nutrient-deficiency-general", + "haworthia-overwatering-damage-edema", + "poinsettia-sunscald", + "poinsettia-nutrient-deficiency-general", + "poinsettia-overwatering-damage-edema", + "cassava-sunscald", + "cassava-nutrient-deficiency-general", + "cassava-overwatering-damage-edema", + "castor-bean-sunscald", + "castor-bean-nutrient-deficiency-general", + "castor-bean-overwatering-damage-edema", + "crown-of-thorns-sunscald", + "crown-of-thorns-nutrient-deficiency-general", + "crown-of-thorns-overwatering-damage-edema", + "orange-sunscald", + "orange-nutrient-deficiency-general", + "orange-overwatering-damage-edema", + "lemon-sunscald", + "lemon-nutrient-deficiency-general", + "lemon-overwatering-damage-edema", + "lime-sunscald", + "lime-nutrient-deficiency-general", + "lime-overwatering-damage-edema", + "grapefruit-sunscald", + "grapefruit-nutrient-deficiency-general", + "grapefruit-overwatering-damage-edema", + "mandarin-sunscald", + "mandarin-nutrient-deficiency-general", + "mandarin-overwatering-damage-edema", + "kumquat-sunscald", + "kumquat-nutrient-deficiency-general", + "kumquat-overwatering-damage-edema", + "grape-sunscald", + "grape-nutrient-deficiency-general", + "grape-overwatering-damage-edema", + "muscadine-sunscald", + "muscadine-nutrient-deficiency-general", + "muscadine-overwatering-damage-edema", + "banana-sunscald", + "banana-nutrient-deficiency-general", + "banana-overwatering-damage-edema", + "plantain-sunscald", + "plantain-nutrient-deficiency-general", + "plantain-overwatering-damage-edema", + "bird-of-paradise-sunscald", + "bird-of-paradise-nutrient-deficiency-general", + "bird-of-paradise-overwatering-damage-edema", + "avocado-sunscald", + "avocado-nutrient-deficiency-general", + "avocado-overwatering-damage-edema", + "cinnamon-sunscald", + "cinnamon-nutrient-deficiency-general", + "cinnamon-overwatering-damage-edema", + "bay-laurel-sunscald", + "bay-laurel-nutrient-deficiency-general", + "bay-laurel-overwatering-damage-edema", + "cocoa-sunscald", + "cocoa-nutrient-deficiency-general", + "cocoa-overwatering-damage-edema", + "cotton-sunscald", + "cotton-nutrient-deficiency-general", + "cotton-overwatering-damage-edema", + "okra-sunscald", + "okra-nutrient-deficiency-general", + "okra-overwatering-damage-edema", + "hibiscus-sunscald", + "hibiscus-nutrient-deficiency-general", + "hibiscus-overwatering-damage-edema", + "hollyhock-sunscald", + "hollyhock-nutrient-deficiency-general", + "hollyhock-overwatering-damage-edema", + "baobab-leaf-spot-septoriacercospora", + "baobab-sunscald", + "baobab-nutrient-deficiency-general", + "baobab-overwatering-damage-edema", + "durian-leaf-spot-septoriacercospora", + "durian-sunscald", + "durian-nutrient-deficiency-general", + "durian-overwatering-damage-edema", + "coconut-sunscald", + "coconut-nutrient-deficiency-general", + "coconut-overwatering-damage-edema", + "oil-palm-sunscald", + "oil-palm-nutrient-deficiency-general", + "oil-palm-overwatering-damage-edema", + "date-palm-sunscald", + "date-palm-nutrient-deficiency-general", + "date-palm-overwatering-damage-edema", + "palm-areca-sunscald", + "palm-areca-nutrient-deficiency-general", + "palm-areca-overwatering-damage-edema", + "palm-parlor-sunscald", + "palm-parlor-nutrient-deficiency-general", + "palm-parlor-overwatering-damage-edema", + "palm-kentia-sunscald", + "palm-kentia-nutrient-deficiency-general", + "palm-kentia-overwatering-damage-edema", + "mango-sunscald", + "mango-nutrient-deficiency-general", + "mango-overwatering-damage-edema", + "cashew-sunscald", + "cashew-nutrient-deficiency-general", + "cashew-overwatering-damage-edema", + "pistachio-sunscald", + "pistachio-nutrient-deficiency-general", + "pistachio-overwatering-damage-edema", + "poison-ivy-sunscald", + "poison-ivy-nutrient-deficiency-general", + "poison-ivy-overwatering-damage-edema", + "coffee-sunscald", + "coffee-nutrient-deficiency-general", + "coffee-overwatering-damage-edema", + "gardenia-sunscald", + "gardenia-nutrient-deficiency-general", + "gardenia-overwatering-damage-edema", + "tea-sunscald", + "tea-nutrient-deficiency-general", + "tea-overwatering-damage-edema", + "camellia-sunscald", + "camellia-nutrient-deficiency-general", + "camellia-overwatering-damage-edema", + "pine-sunscald", + "pine-nutrient-deficiency-general", + "pine-overwatering-damage-edema", + "spruce-sunscald", + "spruce-nutrient-deficiency-general", + "spruce-overwatering-damage-edema", + "fir-sunscald", + "fir-nutrient-deficiency-general", + "fir-overwatering-damage-edema", + "cedar-sunscald", + "cedar-nutrient-deficiency-general", + "cedar-overwatering-damage-edema", + "juniper-sunscald", + "juniper-nutrient-deficiency-general", + "juniper-overwatering-damage-edema", + "cypress-sunscald", + "cypress-nutrient-deficiency-general", + "cypress-overwatering-damage-edema", + "arborvitae-sunscald", + "arborvitae-nutrient-deficiency-general", + "arborvitae-overwatering-damage-edema", + "oak-sunscald", + "oak-nutrient-deficiency-general", + "oak-overwatering-damage-edema", + "beech-sunscald", + "beech-nutrient-deficiency-general", + "beech-overwatering-damage-edema", + "chestnut-sunscald", + "chestnut-nutrient-deficiency-general", + "chestnut-overwatering-damage-edema", + "fiddle-leaf-fig-sunscald", + "fiddle-leaf-fig-nutrient-deficiency-general", + "fiddle-leaf-fig-overwatering-damage-edema", + "rubber-tree-sunscald", + "rubber-tree-nutrient-deficiency-general", + "rubber-tree-overwatering-damage-edema", + "weeping-fig-sunscald", + "weeping-fig-nutrient-deficiency-general", + "weeping-fig-overwatering-damage-edema", + "fig-sunscald", + "fig-nutrient-deficiency-general", + "fig-overwatering-damage-edema", + "mulberry-sunscald", + "mulberry-nutrient-deficiency-general", + "mulberry-overwatering-damage-edema", + "breadfruit-sunscald", + "breadfruit-nutrient-deficiency-general", + "breadfruit-overwatering-damage-edema", + "eucalyptus-sunscald", + "eucalyptus-nutrient-deficiency-general", + "eucalyptus-overwatering-damage-edema", + "guava-sunscald", + "guava-nutrient-deficiency-general", + "guava-overwatering-damage-edema", + "clove-sunscald", + "clove-nutrient-deficiency-general", + "clove-overwatering-damage-edema", + "pineapple-sunscald", + "pineapple-nutrient-deficiency-general", + "pineapple-overwatering-damage-edema", + "bromeliad-sunscald", + "bromeliad-nutrient-deficiency-general", + "bromeliad-overwatering-damage-edema", + "spanish-moss-sunscald", + "spanish-moss-nutrient-deficiency-general", + "spanish-moss-overwatering-damage-edema", + "sweet-potato-sunscald", + "sweet-potato-nutrient-deficiency-general", + "sweet-potato-overwatering-damage-edema", + "morning-glory-sunscald", + "morning-glory-nutrient-deficiency-general", + "morning-glory-overwatering-damage-edema", + "spinach-sunscald", + "spinach-nutrient-deficiency-general", + "spinach-overwatering-damage-edema", + "swiss-chard-sunscald", + "swiss-chard-nutrient-deficiency-general", + "swiss-chard-overwatering-damage-edema", + "beet-sunscald", + "beet-nutrient-deficiency-general", + "beet-overwatering-damage-edema", + "quinoa-sunscald", + "quinoa-nutrient-deficiency-general", + "quinoa-overwatering-damage-edema", + "amaranth-sunscald", + "amaranth-nutrient-deficiency-general", + "amaranth-overwatering-damage-edema", + "rhubarb-sunscald", + "rhubarb-nutrient-deficiency-general", + "rhubarb-overwatering-damage-edema", + "buckwheat-sunscald", + "buckwheat-nutrient-deficiency-general", + "buckwheat-overwatering-damage-edema", + "papaya-sunscald", + "papaya-nutrient-deficiency-general", + "papaya-overwatering-damage-edema", + "olive-sunscald", + "olive-nutrient-deficiency-general", + "olive-overwatering-damage-edema", + "jasmine-sunscald", + "jasmine-nutrient-deficiency-general", + "jasmine-overwatering-damage-edema", + "lilac-sunscald", + "lilac-nutrient-deficiency-general", + "lilac-overwatering-damage-edema", + "ash-sunscald", + "ash-nutrient-deficiency-general", + "ash-overwatering-damage-edema", + "hops-sunscald", + "hops-nutrient-deficiency-general", + "hops-overwatering-damage-edema", + "hemp-leaf-spot-septoriacercospora", + "hemp-sunscald", + "hemp-nutrient-deficiency-general", + "hemp-overwatering-damage-edema", + "hemp-sooty-mold", + "fern-boston-leaf-spot-septoriacercospora", + "fern-boston-sunscald", + "fern-boston-nutrient-deficiency-general", + "fern-boston-overwatering-damage-edema", + "fern-boston-sooty-mold", + "fern-maidenhair-leaf-spot-septoriacercospora", + "fern-maidenhair-sunscald", + "fern-maidenhair-nutrient-deficiency-general", + "fern-maidenhair-overwatering-damage-edema", + "fern-maidenhair-sooty-mold", + "spider-plant-leaf-spot-septoriacercospora", + "spider-plant-sunscald", + "spider-plant-nutrient-deficiency-general", + "spider-plant-overwatering-damage-edema", + "spider-plant-sooty-mold", + "zz-plant-fungal-leaf-spot-aroids", + "zz-plant-leaf-spot-septoriacercospora", + "zz-plant-sunscald", + "zz-plant-nutrient-deficiency-general", + "zz-plant-overwatering-damage-edema", + "zz-plant-sooty-mold", + "prayer-plant-leaf-spot-septoriacercospora", + "prayer-plant-sunscald", + "prayer-plant-nutrient-deficiency-general", + "prayer-plant-overwatering-damage-edema", + "prayer-plant-sooty-mold", + "calathea-leaf-spot-septoriacercospora", + "calathea-sunscald", + "calathea-nutrient-deficiency-general", + "calathea-overwatering-damage-edema", + "calathea-sooty-mold", + "pilea-leaf-spot-septoriacercospora", + "pilea-sunscald", + "pilea-nutrient-deficiency-general", + "pilea-overwatering-damage-edema", + "pilea-sooty-mold", + "tradescantia-leaf-spot-septoriacercospora", + "tradescantia-sunscald", + "tradescantia-nutrient-deficiency-general", + "tradescantia-overwatering-damage-edema", + "tradescantia-sooty-mold", + "succulent-echeveria-mealybugs-succulents", + "succulent-echeveria-leaf-spot-septoriacercospora", + "succulent-echeveria-sunscald", + "succulent-echeveria-nutrient-deficiency-general", + "succulent-echeveria-overwatering-damage-edema", + "succulent-echeveria-sooty-mold", + "money-tree-leaf-spot-septoriacercospora", + "money-tree-sunscald", + "money-tree-nutrient-deficiency-general", + "money-tree-overwatering-damage-edema", + "money-tree-sooty-mold", + "palm-cat-leaf-spot-septoriacercospora", + "palm-cat-sunscald", + "palm-cat-nutrient-deficiency-general", + "palm-cat-overwatering-damage-edema", + "palm-cat-sooty-mold", + "ficus-altissima-leaf-spot-septoriacercospora", + "ficus-altissima-sunscald", + "ficus-altissima-nutrient-deficiency-general", + "ficus-altissima-overwatering-damage-edema", + "ficus-altissima-sooty-mold", + "string-of-pearls-leaf-spot-septoriacercospora", + "string-of-pearls-sunscald", + "string-of-pearls-nutrient-deficiency-general", + "string-of-pearls-overwatering-damage-edema", + "string-of-pearls-sooty-mold", + "burros-tail-mealybugs-succulents", + "burros-tail-leaf-spot-septoriacercospora", + "burros-tail-sunscald", + "burros-tail-nutrient-deficiency-general", + "burros-tail-overwatering-damage-edema", + "burros-tail-sooty-mold", + "snake-plant-masoniana-leaf-spot-septoriacercospora", + "snake-plant-masoniana-sunscald", + "snake-plant-masoniana-nutrient-deficiency-general", + "snake-plant-masoniana-overwatering-damage-edema", + "snake-plant-masoniana-sooty-mold", + "passion-fruit-leaf-spot-septoriacercospora", + "passion-fruit-sunscald", + "passion-fruit-nutrient-deficiency-general", + "passion-fruit-overwatering-damage-edema", + "passion-fruit-sooty-mold", + "kiwi-leaf-spot-septoriacercospora", + "kiwi-sunscald", + "kiwi-nutrient-deficiency-general", + "kiwi-overwatering-damage-edema", + "kiwi-sooty-mold", + "lychee-leaf-spot-septoriacercospora", + "lychee-sunscald", + "lychee-nutrient-deficiency-general", + "lychee-overwatering-damage-edema", + "lychee-sooty-mold", + "rambutan-leaf-spot-septoriacercospora", + "rambutan-sunscald", + "rambutan-nutrient-deficiency-general", + "rambutan-overwatering-damage-edema", + "rambutan-sooty-mold", + "jackfruit-leaf-spot-septoriacercospora", + "jackfruit-sunscald", + "jackfruit-nutrient-deficiency-general", + "jackfruit-overwatering-damage-edema", + "jackfruit-sooty-mold", + "dragon-fruit-mealybugs-succulents", + "dragon-fruit-leaf-spot-septoriacercospora", + "dragon-fruit-sunscald", + "dragon-fruit-nutrient-deficiency-general", + "dragon-fruit-overwatering-damage-edema", + "dragon-fruit-sooty-mold", + "pomegranate-leaf-spot-septoriacercospora", + "pomegranate-sunscald", + "pomegranate-nutrient-deficiency-general", + "pomegranate-overwatering-damage-edema", + "pomegranate-sooty-mold", + "persimmon-leaf-spot-septoriacercospora", + "persimmon-sunscald", + "persimmon-nutrient-deficiency-general", + "persimmon-overwatering-damage-edema", + "persimmon-sooty-mold", + "tulip-leaf-spot-septoriacercospora", + "tulip-sunscald", + "tulip-nutrient-deficiency-general", + "tulip-overwatering-damage-edema", + "tulip-sooty-mold", + "daffodil-leaf-spot-septoriacercospora", + "daffodil-sunscald", + "daffodil-nutrient-deficiency-general", + "daffodil-overwatering-damage-edema", + "daffodil-sooty-mold", + "iris-leaf-spot-septoriacercospora", + "iris-sunscald", + "iris-nutrient-deficiency-general", + "iris-overwatering-damage-edema", + "iris-sooty-mold", + "lily-leaf-spot-septoriacercospora", + "lily-sunscald", + "lily-nutrient-deficiency-general", + "lily-overwatering-damage-edema", + "lily-sooty-mold", + "peony-leaf-spot-septoriacercospora", + "peony-sunscald", + "peony-nutrient-deficiency-general", + "peony-overwatering-damage-edema", + "peony-sooty-mold", + "hydrangea-leaf-spot-septoriacercospora", + "hydrangea-sunscald", + "hydrangea-nutrient-deficiency-general", + "hydrangea-overwatering-damage-edema", + "hydrangea-sooty-mold", + "rhododendron-leaf-spot-septoriacercospora", + "rhododendron-sunscald", + "rhododendron-nutrient-deficiency-general", + "rhododendron-overwatering-damage-edema", + "rhododendron-sooty-mold", + "azalea-leaf-spot-septoriacercospora", + "azalea-sunscald", + "azalea-nutrient-deficiency-general", + "azalea-overwatering-damage-edema", + "azalea-sooty-mold", + "magnolia-leaf-spot-septoriacercospora", + "magnolia-sunscald", + "magnolia-nutrient-deficiency-general", + "magnolia-overwatering-damage-edema", + "magnolia-sooty-mold", + "dogwood-leaf-spot-septoriacercospora", + "dogwood-sunscald", + "dogwood-nutrient-deficiency-general", + "dogwood-overwatering-damage-edema", + "dogwood-sooty-mold", + "maple-leaf-spot-septoriacercospora", + "maple-sunscald", + "maple-nutrient-deficiency-general", + "maple-overwatering-damage-edema", + "maple-sooty-mold", + "birch-leaf-spot-septoriacercospora", + "birch-sunscald", + "birch-nutrient-deficiency-general", + "birch-overwatering-damage-edema", + "birch-sooty-mold", + "elm-leaf-spot-septoriacercospora", + "elm-sunscald", + "elm-nutrient-deficiency-general", + "elm-overwatering-damage-edema", + "elm-sooty-mold", + "willow-leaf-spot-septoriacercospora", + "willow-sunscald", + "willow-nutrient-deficiency-general", + "willow-overwatering-damage-edema", + "willow-sooty-mold", + "poplar-leaf-spot-septoriacercospora", + "poplar-sunscald", + "poplar-nutrient-deficiency-general", + "poplar-overwatering-damage-edema", + "poplar-sooty-mold", + "sycamore-leaf-spot-septoriacercospora", + "sycamore-sunscald", + "sycamore-nutrient-deficiency-general", + "sycamore-overwatering-damage-edema", + "sycamore-sooty-mold", + "hickory-leaf-spot-septoriacercospora", + "hickory-sunscald", + "hickory-nutrient-deficiency-general", + "hickory-overwatering-damage-edema", + "hickory-sooty-mold", + "pecan-leaf-spot-septoriacercospora", + "pecan-sunscald", + "pecan-nutrient-deficiency-general", + "pecan-overwatering-damage-edema", + "pecan-sooty-mold", + "walnut-leaf-spot-septoriacercospora", + "walnut-sunscald", + "walnut-nutrient-deficiency-general", + "walnut-overwatering-damage-edema", + "walnut-sooty-mold", + "tomato-physiological-leaf-scorch", + "potato-physiological-leaf-scorch", + "bell-pepper-physiological-leaf-scorch", + "chili-pepper-physiological-leaf-scorch", + "eggplant-physiological-leaf-scorch", + "tobacco-physiological-leaf-scorch", + "tomatillo-physiological-leaf-scorch", + "petunia-physiological-leaf-scorch", + "gooseberry-physiological-leaf-scorch", + "cucumber-physiological-leaf-scorch", + "zucchini-physiological-leaf-scorch", + "summer-squash-physiological-leaf-scorch", + "winter-squash-physiological-leaf-scorch", + "pumpkin-physiological-leaf-scorch", + "watermelon-physiological-leaf-scorch", + "cantaloupe-physiological-leaf-scorch", + "honeydew-physiological-leaf-scorch", + "bitter-melon-physiological-leaf-scorch", + "chayote-physiological-leaf-scorch", + "acorn-squash-physiological-leaf-scorch", + "butternut-squash-physiological-leaf-scorch", + "calabash-physiological-leaf-scorch", + "luffa-physiological-leaf-scorch", + "apple-physiological-leaf-scorch", + "pear-physiological-leaf-scorch", + "peach-physiological-leaf-scorch", + "cherry-physiological-leaf-scorch", + "apricot-physiological-leaf-scorch", + "plum-physiological-leaf-scorch", + "almond-physiological-leaf-scorch", + "strawberry-physiological-leaf-scorch", + "raspberry-physiological-leaf-scorch", + "blackberry-physiological-leaf-scorch", + "blueberry-physiological-leaf-scorch", + "cranberry-physiological-leaf-scorch", + "rose-physiological-leaf-scorch", + "hawthorn-physiological-leaf-scorch", + "quince-physiological-leaf-scorch", + "cabbage-physiological-leaf-scorch", + "broccoli-physiological-leaf-scorch", + "cauliflower-physiological-leaf-scorch", + "brussels-sprouts-physiological-leaf-scorch", + "kale-physiological-leaf-scorch", + "bok-choy-physiological-leaf-scorch", + "radish-physiological-leaf-scorch", + "turnip-physiological-leaf-scorch", + "arugula-physiological-leaf-scorch", + "collard-greens-physiological-leaf-scorch", + "mustard-greens-physiological-leaf-scorch", + "horseradish-physiological-leaf-scorch", + "wasabi-physiological-leaf-scorch", + "green-bean-physiological-leaf-scorch", + "soybean-physiological-leaf-scorch", + "peanut-physiological-leaf-scorch", + "chickpea-physiological-leaf-scorch", + "lentil-physiological-leaf-scorch", + "faba-bean-physiological-leaf-scorch", + "cowpea-physiological-leaf-scorch", + "pigeon-pea-physiological-leaf-scorch", + "alfalfa-physiological-leaf-scorch", + "clover-physiological-leaf-scorch", + "peas-physiological-leaf-scorch", + "lupine-physiological-leaf-scorch", + "wisteria-physiological-leaf-scorch", + "robinia-physiological-leaf-scorch", + "corn-physiological-leaf-scorch", + "wheat-physiological-leaf-scorch", + "rice-physiological-leaf-scorch", + "barley-physiological-leaf-scorch", + "oats-physiological-leaf-scorch", + "sorghum-physiological-leaf-scorch", + "sugarcane-physiological-leaf-scorch", + "bamboo-physiological-leaf-scorch", + "turfgrass-physiological-leaf-scorch", + "millet-physiological-leaf-scorch", + "rye-physiological-leaf-scorch", + "sunflower-physiological-leaf-scorch", + "lettuce-physiological-leaf-scorch", + "artichoke-physiological-leaf-scorch", + "chicory-physiological-leaf-scorch", + "endive-physiological-leaf-scorch", + "daisy-physiological-leaf-scorch", + "marigold-physiological-leaf-scorch", + "zinnia-physiological-leaf-scorch", + "chrysanthemum-physiological-leaf-scorch", + "dahlia-physiological-leaf-scorch", + "calendula-physiological-leaf-scorch", + "echinacea-physiological-leaf-scorch", + "yarrow-physiological-leaf-scorch", + "tarragon-physiological-leaf-scorch", + "stevia-physiological-leaf-scorch", + "basil-physiological-leaf-scorch", + "mint-physiological-leaf-scorch", + "lavender-physiological-leaf-scorch", + "rosemary-physiological-leaf-scorch", + "thyme-physiological-leaf-scorch", + "oregano-physiological-leaf-scorch", + "sage-physiological-leaf-scorch", + "lemon-balm-physiological-leaf-scorch", + "catnip-physiological-leaf-scorch", + "coleus-physiological-leaf-scorch", + "carrot-physiological-leaf-scorch", + "celery-physiological-leaf-scorch", + "parsley-physiological-leaf-scorch", + "cilantro-physiological-leaf-scorch", + "dill-physiological-leaf-scorch", + "fennel-physiological-leaf-scorch", + "parsnip-physiological-leaf-scorch", + "cumin-physiological-leaf-scorch", + "onion-physiological-leaf-scorch", + "garlic-physiological-leaf-scorch", + "leek-physiological-leaf-scorch", + "shallot-physiological-leaf-scorch", + "chive-physiological-leaf-scorch", + "monstera-physiological-leaf-scorch", + "pothos-physiological-leaf-scorch", + "peace-lily-physiological-leaf-scorch", + "philodendron-physiological-leaf-scorch", + "anthurium-physiological-leaf-scorch", + "alocasia-physiological-leaf-scorch", + "caladium-physiological-leaf-scorch", + "aglaonema-physiological-leaf-scorch", + "dieffenbachia-physiological-leaf-scorch", + "spathiphyllum-physiological-leaf-scorch", + "asparagus-physiological-leaf-scorch", + "snake-plant-physiological-leaf-scorch", + "yucca-physiological-leaf-scorch", + "dracaena-physiological-leaf-scorch", + "lily-of-the-valley-physiological-leaf-scorch", + "hosta-physiological-leaf-scorch", + "orchid-phalaenopsis-physiological-leaf-scorch", + "orchid-cattleya-physiological-leaf-scorch", + "orchid-dendrobium-physiological-leaf-scorch", + "orchid-oncidium-physiological-leaf-scorch", + "vanilla-physiological-leaf-scorch", + "prickly-pear-physiological-leaf-scorch", + "barrel-cactus-physiological-leaf-scorch", + "christmas-cactus-physiological-leaf-scorch", + "saguaro-physiological-leaf-scorch", + "aloe-vera-physiological-leaf-scorch", + "agave-physiological-leaf-scorch", + "echeveria-physiological-leaf-scorch", + "jade-plant-physiological-leaf-scorch", + "sedum-physiological-leaf-scorch", + "haworthia-physiological-leaf-scorch", + "poinsettia-physiological-leaf-scorch", + "cassava-physiological-leaf-scorch", + "castor-bean-physiological-leaf-scorch", + "crown-of-thorns-physiological-leaf-scorch", + "orange-physiological-leaf-scorch", + "lemon-physiological-leaf-scorch", + "lime-physiological-leaf-scorch", + "grapefruit-physiological-leaf-scorch", + "mandarin-physiological-leaf-scorch", + "kumquat-physiological-leaf-scorch", + "grape-physiological-leaf-scorch", + "muscadine-physiological-leaf-scorch", + "banana-physiological-leaf-scorch", + "plantain-physiological-leaf-scorch", + "bird-of-paradise-physiological-leaf-scorch", + "avocado-physiological-leaf-scorch", + "cinnamon-physiological-leaf-scorch", + "bay-laurel-physiological-leaf-scorch", + "cocoa-physiological-leaf-scorch", + "cotton-physiological-leaf-scorch", + "okra-physiological-leaf-scorch", + "hibiscus-physiological-leaf-scorch", + "hollyhock-physiological-leaf-scorch", + "baobab-physiological-leaf-scorch", + "durian-physiological-leaf-scorch", + "coconut-physiological-leaf-scorch", + "oil-palm-physiological-leaf-scorch", + "date-palm-physiological-leaf-scorch", + "palm-areca-physiological-leaf-scorch", + "palm-parlor-physiological-leaf-scorch", + "palm-kentia-physiological-leaf-scorch", + "mango-physiological-leaf-scorch", + "cashew-physiological-leaf-scorch", + "pistachio-physiological-leaf-scorch", + "poison-ivy-physiological-leaf-scorch", + "coffee-physiological-leaf-scorch", + "gardenia-physiological-leaf-scorch", + "tea-physiological-leaf-scorch", + "camellia-physiological-leaf-scorch", + "pine-physiological-leaf-scorch", + "spruce-physiological-leaf-scorch", + "fir-physiological-leaf-scorch", + "cedar-physiological-leaf-scorch", + "juniper-physiological-leaf-scorch", + "cypress-physiological-leaf-scorch", + "arborvitae-physiological-leaf-scorch", + "oak-physiological-leaf-scorch", + "beech-physiological-leaf-scorch", + "chestnut-physiological-leaf-scorch", + "fiddle-leaf-fig-physiological-leaf-scorch", + "rubber-tree-physiological-leaf-scorch", + "weeping-fig-physiological-leaf-scorch", + "fig-physiological-leaf-scorch", + "mulberry-physiological-leaf-scorch", + "breadfruit-physiological-leaf-scorch", + "eucalyptus-physiological-leaf-scorch", + "guava-physiological-leaf-scorch", + "clove-physiological-leaf-scorch", + "pineapple-physiological-leaf-scorch", + "bromeliad-physiological-leaf-scorch", + "spanish-moss-physiological-leaf-scorch", + "sweet-potato-physiological-leaf-scorch", + "morning-glory-physiological-leaf-scorch", + "spinach-physiological-leaf-scorch", + "swiss-chard-physiological-leaf-scorch", + "beet-physiological-leaf-scorch", + "quinoa-physiological-leaf-scorch", + "amaranth-physiological-leaf-scorch", + "rhubarb-physiological-leaf-scorch", + "buckwheat-physiological-leaf-scorch", + "papaya-physiological-leaf-scorch", + "olive-physiological-leaf-scorch", + "jasmine-physiological-leaf-scorch", + "lilac-physiological-leaf-scorch", + "ash-physiological-leaf-scorch", + "hops-physiological-leaf-scorch", + "hemp-physiological-leaf-scorch", + "fern-boston-physiological-leaf-scorch", + "fern-maidenhair-physiological-leaf-scorch", + "spider-plant-physiological-leaf-scorch", + "zz-plant-physiological-leaf-scorch", + "prayer-plant-physiological-leaf-scorch", + "calathea-physiological-leaf-scorch", + "pilea-physiological-leaf-scorch", + "tradescantia-physiological-leaf-scorch", + "succulent-echeveria-physiological-leaf-scorch", + "money-tree-physiological-leaf-scorch", + "palm-cat-physiological-leaf-scorch", + "ficus-altissima-physiological-leaf-scorch", + "string-of-pearls-physiological-leaf-scorch", + "burros-tail-physiological-leaf-scorch", + "snake-plant-masoniana-physiological-leaf-scorch", + "passion-fruit-physiological-leaf-scorch", + "kiwi-physiological-leaf-scorch", + "lychee-physiological-leaf-scorch", + "rambutan-physiological-leaf-scorch", + "jackfruit-physiological-leaf-scorch", + "dragon-fruit-physiological-leaf-scorch", + "pomegranate-physiological-leaf-scorch", + "persimmon-physiological-leaf-scorch", + "tulip-physiological-leaf-scorch", + "daffodil-physiological-leaf-scorch", + "iris-physiological-leaf-scorch", + "lily-physiological-leaf-scorch", + "peony-physiological-leaf-scorch", + "hydrangea-physiological-leaf-scorch", + "rhododendron-physiological-leaf-scorch", + "azalea-physiological-leaf-scorch", + "magnolia-physiological-leaf-scorch", + "dogwood-physiological-leaf-scorch", + "maple-physiological-leaf-scorch", + "birch-physiological-leaf-scorch", + "elm-physiological-leaf-scorch", + "willow-physiological-leaf-scorch", + "poplar-physiological-leaf-scorch", + "sycamore-physiological-leaf-scorch", + "hickory-physiological-leaf-scorch", + "pecan-physiological-leaf-scorch", + "walnut-physiological-leaf-scorch", + "fern-staghorn-leaf-spot-septoriacercospora", + "fern-staghorn-sunscald", + "fern-staghorn-nutrient-deficiency-general", + "fern-staghorn-overwatering-damage-edema", + "fern-staghorn-sooty-mold", + "fern-staghorn-physiological-leaf-scorch", + "fern-birds-nest-leaf-spot-septoriacercospora", + "fern-birds-nest-sunscald", + "fern-birds-nest-nutrient-deficiency-general", + "fern-birds-nest-overwatering-damage-edema", + "fern-birds-nest-sooty-mold", + "fern-birds-nest-physiological-leaf-scorch", + "philodendron-brasil-fungal-leaf-spot-aroids", + "philodendron-brasil-leaf-spot-septoriacercospora", + "philodendron-brasil-sunscald", + "philodendron-brasil-nutrient-deficiency-general", + "philodendron-brasil-overwatering-damage-edema", + "philodendron-brasil-sooty-mold", + "philodendron-brasil-physiological-leaf-scorch", + "philodendron-monstera-fungal-leaf-spot-aroids", + "philodendron-monstera-leaf-spot-septoriacercospora", + "philodendron-monstera-sunscald", + "philodendron-monstera-nutrient-deficiency-general", + "philodendron-monstera-overwatering-damage-edema", + "philodendron-monstera-sooty-mold", + "philodendron-monstera-physiological-leaf-scorch", + "pothos-marble-queen-fungal-leaf-spot-aroids", + "pothos-marble-queen-leaf-spot-septoriacercospora", + "pothos-marble-queen-sunscald", + "pothos-marble-queen-nutrient-deficiency-general", + "pothos-marble-queen-overwatering-damage-edema", + "pothos-marble-queen-sooty-mold", + "pothos-marble-queen-physiological-leaf-scorch", + "peace-lily-sensation-fungal-leaf-spot-aroids", + "peace-lily-sensation-leaf-spot-septoriacercospora", + "peace-lily-sensation-sunscald", + "peace-lily-sensation-nutrient-deficiency-general", + "peace-lily-sensation-overwatering-damage-edema", + "peace-lily-sensation-sooty-mold", + "peace-lily-sensation-physiological-leaf-scorch", + "phalaenopsis-orchid-leaf-spot-septoriacercospora", + "phalaenopsis-orchid-sunscald", + "phalaenopsis-orchid-nutrient-deficiency-general", + "phalaenopsis-orchid-overwatering-damage-edema", + "phalaenopsis-orchid-sooty-mold", + "phalaenopsis-orchid-physiological-leaf-scorch", + "cattleya-orchid-leaf-spot-septoriacercospora", + "cattleya-orchid-sunscald", + "cattleya-orchid-nutrient-deficiency-general", + "cattleya-orchid-overwatering-damage-edema", + "cattleya-orchid-sooty-mold", + "cattleya-orchid-physiological-leaf-scorch", + "dendrobium-orchid-leaf-spot-septoriacercospora", + "dendrobium-orchid-sunscald", + "dendrobium-orchid-nutrient-deficiency-general", + "dendrobium-orchid-overwatering-damage-edema", + "dendrobium-orchid-sooty-mold", + "dendrobium-orchid-physiological-leaf-scorch", + "oncidium-orchid-leaf-spot-septoriacercospora", + "oncidium-orchid-sunscald", + "oncidium-orchid-nutrient-deficiency-general", + "oncidium-orchid-overwatering-damage-edema", + "oncidium-orchid-sooty-mold", + "oncidium-orchid-physiological-leaf-scorch", + "begonia-leaf-spot-septoriacercospora", + "begonia-sunscald", + "begonia-nutrient-deficiency-general", + "begonia-overwatering-damage-edema", + "begonia-sooty-mold", + "begonia-physiological-leaf-scorch", + "impatiens-leaf-spot-septoriacercospora", + "impatiens-sunscald", + "impatiens-nutrient-deficiency-general", + "impatiens-overwatering-damage-edema", + "impatiens-sooty-mold", + "impatiens-physiological-leaf-scorch", + "geranium-leaf-spot-septoriacercospora", + "geranium-sunscald", + "geranium-nutrient-deficiency-general", + "geranium-overwatering-damage-edema", + "geranium-sooty-mold", + "geranium-physiological-leaf-scorch", + "cyclamen-leaf-spot-septoriacercospora", + "cyclamen-sunscald", + "cyclamen-nutrient-deficiency-general", + "cyclamen-overwatering-damage-edema", + "cyclamen-sooty-mold", + "cyclamen-physiological-leaf-scorch", + "african-violet-leaf-spot-septoriacercospora", + "african-violet-sunscald", + "african-violet-nutrient-deficiency-general", + "african-violet-overwatering-damage-edema", + "african-violet-sooty-mold", + "african-violet-physiological-leaf-scorch", + "gloxinia-leaf-spot-septoriacercospora", + "gloxinia-sunscald", + "gloxinia-nutrient-deficiency-general", + "gloxinia-overwatering-damage-edema", + "gloxinia-sooty-mold", + "gloxinia-physiological-leaf-scorch", + "cucumber-horned-leaf-spot-septoriacercospora", + "cucumber-horned-sunscald", + "cucumber-horned-nutrient-deficiency-general", + "cucumber-horned-overwatering-damage-edema", + "cucumber-horned-sooty-mold", + "cucumber-horned-physiological-leaf-scorch", + "sweet-potato-leaf-leaf-spot-septoriacercospora", + "sweet-potato-leaf-sunscald", + "sweet-potato-leaf-nutrient-deficiency-general", + "sweet-potato-leaf-overwatering-damage-edema", + "sweet-potato-leaf-sooty-mold", + "sweet-potato-leaf-physiological-leaf-scorch", + "ivy-english-leaf-spot-septoriacercospora", + "ivy-english-sunscald", + "ivy-english-nutrient-deficiency-general", + "ivy-english-overwatering-damage-edema", + "ivy-english-sooty-mold", + "ivy-english-physiological-leaf-scorch", + "ivy-swedish-leaf-spot-septoriacercospora", + "ivy-swedish-sunscald", + "ivy-swedish-nutrient-deficiency-general", + "ivy-swedish-overwatering-damage-edema", + "ivy-swedish-sooty-mold", + "ivy-swedish-physiological-leaf-scorch", + "banana-dwarf-leaf-spot-septoriacercospora", + "banana-dwarf-sunscald", + "banana-dwarf-nutrient-deficiency-general", + "banana-dwarf-overwatering-damage-edema", + "banana-dwarf-sooty-mold", + "banana-dwarf-physiological-leaf-scorch", + "mimosa-leaf-spot-septoriacercospora", + "mimosa-sunscald", + "mimosa-nutrient-deficiency-general", + "mimosa-overwatering-damage-edema", + "mimosa-sooty-mold", + "mimosa-physiological-leaf-scorch", + "kentucky-coffee-leaf-spot-septoriacercospora", + "kentucky-coffee-sunscald", + "kentucky-coffee-nutrient-deficiency-general", + "kentucky-coffee-overwatering-damage-edema", + "kentucky-coffee-sooty-mold", + "kentucky-coffee-physiological-leaf-scorch", + "redbud-leaf-spot-septoriacercospora", + "redbud-sunscald", + "redbud-nutrient-deficiency-general", + "redbud-overwatering-damage-edema", + "redbud-sooty-mold", + "redbud-physiological-leaf-scorch", + "tulip-tree-leaf-spot-septoriacercospora", + "tulip-tree-sunscald", + "tulip-tree-nutrient-deficiency-general", + "tulip-tree-overwatering-damage-edema", + "tulip-tree-sooty-mold", + "tulip-tree-physiological-leaf-scorch", + "sweetgum-leaf-spot-septoriacercospora", + "sweetgum-sunscald", + "sweetgum-nutrient-deficiency-general", + "sweetgum-overwatering-damage-edema", + "sweetgum-sooty-mold", + "sweetgum-physiological-leaf-scorch", + "crabapple-leaf-spot-septoriacercospora", + "crabapple-sunscald", + "crabapple-nutrient-deficiency-general", + "crabapple-overwatering-damage-edema", + "crabapple-sooty-mold", + "crabapple-physiological-leaf-scorch", + "serviceberry-leaf-spot-septoriacercospora", + "serviceberry-sunscald", + "serviceberry-nutrient-deficiency-general", + "serviceberry-overwatering-damage-edema", + "serviceberry-sooty-mold", + "serviceberry-physiological-leaf-scorch", + "chokecherry-leaf-spot-septoriacercospora", + "chokecherry-sunscald", + "chokecherry-nutrient-deficiency-general", + "chokecherry-overwatering-damage-edema", + "chokecherry-sooty-mold", + "chokecherry-physiological-leaf-scorch", + "buckeye-leaf-spot-septoriacercospora", + "buckeye-sunscald", + "buckeye-nutrient-deficiency-general", + "buckeye-overwatering-damage-edema", + "buckeye-sooty-mold", + "buckeye-physiological-leaf-scorch", + "linden-leaf-spot-septoriacercospora", + "linden-sunscald", + "linden-nutrient-deficiency-general", + "linden-overwatering-damage-edema", + "linden-sooty-mold", + "linden-physiological-leaf-scorch", + "ginkgo-leaf-spot-septoriacercospora", + "ginkgo-sunscald", + "ginkgo-nutrient-deficiency-general", + "ginkgo-overwatering-damage-edema", + "ginkgo-sooty-mold", + "ginkgo-physiological-leaf-scorch", + "ficus-microcarpa-leaf-spot-septoriacercospora", + "ficus-microcarpa-sunscald", + "ficus-microcarpa-nutrient-deficiency-general", + "ficus-microcarpa-overwatering-damage-edema", + "ficus-microcarpa-sooty-mold", + "ficus-microcarpa-physiological-leaf-scorch", + "schefflera-leaf-spot-septoriacercospora", + "schefflera-sunscald", + "schefflera-nutrient-deficiency-general", + "schefflera-overwatering-damage-edema", + "schefflera-sooty-mold", + "schefflera-physiological-leaf-scorch", + "maranta-leaf-spot-septoriacercospora", + "maranta-sunscald", + "maranta-nutrient-deficiency-general", + "maranta-overwatering-damage-edema", + "maranta-sooty-mold", + "maranta-physiological-leaf-scorch", + "stromanthe-leaf-spot-septoriacercospora", + "stromanthe-sunscald", + "stromanthe-nutrient-deficiency-general", + "stromanthe-overwatering-damage-edema", + "stromanthe-sooty-mold", + "stromanthe-physiological-leaf-scorch", + "bok-choy-shanghai-leaf-spot-septoriacercospora", + "bok-choy-shanghai-sunscald", + "bok-choy-shanghai-nutrient-deficiency-general", + "bok-choy-shanghai-overwatering-damage-edema", + "bok-choy-shanghai-sooty-mold", + "bok-choy-shanghai-physiological-leaf-scorch", + "tatsoi-leaf-spot-septoriacercospora", + "tatsoi-sunscald", + "tatsoi-nutrient-deficiency-general", + "tatsoi-overwatering-damage-edema", + "tatsoi-sooty-mold", + "tatsoi-physiological-leaf-scorch", + "mizuna-leaf-spot-septoriacercospora", + "mizuna-sunscald", + "mizuna-nutrient-deficiency-general", + "mizuna-overwatering-damage-edema", + "mizuna-sooty-mold", + "mizuna-physiological-leaf-scorch", + "kohlrabi-leaf-spot-septoriacercospora", + "kohlrabi-sunscald", + "kohlrabi-nutrient-deficiency-general", + "kohlrabi-overwatering-damage-edema", + "kohlrabi-sooty-mold", + "kohlrabi-physiological-leaf-scorch", + "rapini-leaf-spot-septoriacercospora", + "rapini-sunscald", + "rapini-nutrient-deficiency-general", + "rapini-overwatering-damage-edema", + "rapini-sooty-mold", + "rapini-physiological-leaf-scorch", + "jicama-leaf-spot-septoriacercospora", + "jicama-sunscald", + "jicama-nutrient-deficiency-general", + "jicama-overwatering-damage-edema", + "jicama-sooty-mold", + "jicama-physiological-leaf-scorch", + "adzuki-bean-leaf-spot-septoriacercospora", + "adzuki-bean-sunscald", + "adzuki-bean-nutrient-deficiency-general", + "adzuki-bean-overwatering-damage-edema", + "adzuki-bean-sooty-mold", + "adzuki-bean-physiological-leaf-scorch", + "mung-bean-leaf-spot-septoriacercospora", + "mung-bean-sunscald", + "mung-bean-nutrient-deficiency-general", + "mung-bean-overwatering-damage-edema", + "mung-bean-sooty-mold", + "mung-bean-physiological-leaf-scorch", + "garbanzo-leaf-spot-septoriacercospora", + "garbanzo-sunscald", + "garbanzo-nutrient-deficiency-general", + "garbanzo-overwatering-damage-edema", + "garbanzo-sooty-mold", + "garbanzo-physiological-leaf-scorch", + "wiki-andean-potato-mottle", + "wiki-apple-powdery-mildew", + "wiki-apricot-moorpark-mottle", + "wiki-apricot-peach-yellow-mottle", + "wiki-areolate-mildew", + "wiki-artichoke-mottled-crinkle", + "wiki-banana-mild-mosaic", + "wiki-barley-powdery-mildew", + "wiki-beet-mild-yellowing", + "wiki-beet-mild-yellows", + "wiki-bidens-mottle", + "wiki-blueberry-leaf-mottle", + "wiki-broad-bean-mottle", + "wiki-carnation-mottle", + "wiki-carnation-necrotic-fleck", + "wiki-carnation-vein-mottle", + "wiki-carrot-mottle-dwarf", + "wiki-cassava-green-mottle", + "wiki-cherry-mottle-leaf", + "wiki-cherry-necrotic-rusty-mottle", + "wiki-cherry-rusty-mottle", + "wiki-chilli-veinal-mottle", + "wiki-chlorotic-leaf-mottle", + "wiki-chrysanthemum-chlorotic-mottle", + "wiki-chrysanthemum-vein-mottle", + "wiki-cladosporium-speckle", + "wiki-cotton-areolate-mildew", + "wiki-dogwood-powdery-mildew", + "wiki-duckweed-chlorosis", + "wiki-false-mildew", + "wiki-fruit-freckle", + "wiki-gray-mildew", + "wiki-green-ear-downy-mildew", + "wiki-grey-fleck", + "wiki-impatiens-downy-mildew", + "wiki-interveinal-chlorosis", + "wiki-leaf-edge-chlorosis", + "wiki-leaf-speckle", + "wiki-pepper-mild-mottle", + "wiki-powdery-mildew-of-apple", + "wiki-powdery-mildew-of-grape", + "wiki-raspberry-vein-chlorosis", + "wiki-septoria-speckled-leaf-blotch", + "wiki-strawberry-mild-yellow-edge", + "wiki-tomato-chlorosis", + "wiki-tomato-infectious-chlorosis", + "wiki-tropical-speckle", + "wiki-viral-chlorosis" ], - "totalFound": 5600 + "totalFound": 6739 } \ No newline at end of file diff --git a/apps/web/scripts/fill-ddg-images.ts b/apps/web/scripts/fill-ddg-images.ts index 0981589..efafda5 100644 --- a/apps/web/scripts/fill-ddg-images.ts +++ b/apps/web/scripts/fill-ddg-images.ts @@ -51,7 +51,7 @@ interface DiseaseRow { // ─── Config ────────────────────────────────────────────────────────────────── -const POLITE_DELAY = 1100; // ms between calls +const POLITE_DELAY = 800; // ms between calls const DB_FLUSH_BATCH = 50; const STATE_FILE = resolve(__dirname, ".ddg-progress.json"); @@ -163,6 +163,8 @@ async function main() { const query1 = `${d.name} on ${plantName} plant disease`; const query2 = `${d.scientificName || d.name} on ${plantName} disease`; const query3 = `${d.name} plant disease ${plantName}`; + const query4 = `${d.name} plant`; + const query5 = `${d.name} symptom`; process.stdout.write( ` [${String(i + 1).padStart(4)}/${pending.length}] [${sev}] ${d.name.substring(0, 42).padEnd(44)} `, @@ -170,7 +172,7 @@ async function main() { // Try queries in order until we get a result let url: string | null = null; - for (const q of [query1, query2, query3]) { + for (const q of [query1, query2, query3, query4, query5]) { url = await searchImage(q); if (url) break; } diff --git a/apps/web/scripts/fill-training-dataset.ts b/apps/web/scripts/fill-training-dataset.ts new file mode 100644 index 0000000..fe16aaf --- /dev/null +++ b/apps/web/scripts/fill-training-dataset.ts @@ -0,0 +1,768 @@ +#!/usr/bin/env node +/** + * fill-training-dataset.ts + * + * Scans the existing dataset directory and downloads any missing images + * to reach the target counts (200 per disease, 400 for healthy). + * + * Does NOT re-run prevalence queries — just fills gaps from image sources. + * Each run scans the directory, reports deficits, then fills them. + * Interrupt-safe: re-run to pick up where you left off. + * + * Usage: cd apps/web && npx tsx scripts/fill-training-dataset.ts + */ + +import "dotenv/config"; +import { readFileSync, readdirSync, writeFileSync, existsSync, mkdirSync } from "fs"; +import { resolve, extname } from "path"; + +// Load .env.development for DB creds +const envPath = resolve(__dirname, "../.env.development"); +try { + const env = readFileSync(envPath, "utf-8"); + for (const line of env.split("\n")) { + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith("#")) { + const eqIdx = trimmed.indexOf("="); + if (eqIdx > 0) { + const key = trimmed.slice(0, eqIdx).trim(); + const val = trimmed.slice(eqIdx + 1).trim(); + if (!process.env[key]) process.env[key] = val; + } + } + } +} catch {} + +import { getDb, closeDb } from "@/lib/db/index"; +import { diseases } from "@/lib/db/schema"; +import { sql } from "drizzle-orm"; + +// ─── Config ───────────────────────────────────────────────────────────────── + +const DATASET_DIR = resolve(__dirname, "../data/dataset"); +const SEEN_CACHE_FILE = resolve(DATASET_DIR, ".fill-seen-urls.json"); + +/** Target images per disease */ +const TARGET_PER_DISEASE = 200; + +/** Target images for the "healthy" class */ +const TARGET_HEALTHY = 400; + +/** Delay between DuckDuckGo search API calls (ms) */ +const SEARCH_DELAY = 1500; + +/** Max concurrent image downloads per disease */ +const CONCURRENT_DOWNLOADS = 30; + +/** Number of diseases to process in parallel */ +const DISEASE_CONCURRENCY = 5; + +/** Minimum image size in bytes to accept */ +const MIN_IMAGE_SIZE = 10_000; // 10KB + +/** Maximum image size in bytes */ +const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB + +/** Allowed file extensions */ +const ALLOWED_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp"]; + +/** User agent for requests */ +const UA = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; + +/** Healthy class directory name */ +const HEALTHY_CLASS = "healthy"; + +// ─── Types ────────────────────────────────────────────────────────────────── + +interface DuckDuckGoImageResult { + image: string; + title: string; + url: string; + thumbnail: string; + height: number; + width: number; +} + +interface DiseaseInfo { + id: string; + name: string; + plantId: string; + have: number; + needed: number; +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Count actual image files in a directory (matching img_* pattern). */ +function countImagesInDir(dir: string): number { + if (!existsSync(dir)) return 0; + try { + const files = readdirSync(dir); + return files.filter((f) => f.startsWith("img_")).length; + } catch { + return 0; + } +} + +/** Format bytes for display */ +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +// ─── Seen-URLs Cache ────────────────────────────────────────────────────── + +/** + * Load the per-disease seen-URLs cache from disk. + * This prevents re-fetching the same URLs across runs. + */ +function loadSeenUrlsCache(): Record { + if (existsSync(SEEN_CACHE_FILE)) { + try { + return JSON.parse(readFileSync(SEEN_CACHE_FILE, "utf-8")); + } catch {} + } + return {}; +} + +/** + * Save the seen-URLs cache to disk. + */ +function saveSeenUrlsCache(cache: Record): void { + writeFileSync(SEEN_CACHE_FILE, JSON.stringify(cache, null, 2)); +} + +// ─── DuckDuckGo API ───────────────────────────────────────────────────────── + +async function getVqdToken(query: string): Promise { + const url = `https://duckduckgo.com/?q=${encodeURIComponent(query)}&t=h_&iax=images&ia=images`; + + const res = await fetch(url, { + headers: { "User-Agent": UA, Accept: "text/html" }, + signal: AbortSignal.timeout(15_000), + }); + + if (!res.ok) throw new Error(`Failed to get vqd token: ${res.status}`); + + const html = await res.text(); + const match = html.match(/vqd['"]?\s*[:=]\s*['"]([a-f0-9-]+)['"]/); + if (!match) throw new Error(`Could not extract vqd token for "${query}"`); + + return match[1]; +} + +async function searchImagesDuckDuckGo( + query: string, + vqd: string, + page: number, +): Promise { + const url = `https://duckduckgo.com/i.js?q=${encodeURIComponent( + query, + )}&vqd=${vqd}&o=json&p=${page}&f=,,,`; + + const res = await fetch(url, { + headers: { + "User-Agent": UA, + Accept: "application/json", + Referer: `https://duckduckgo.com/?q=${encodeURIComponent(query)}&t=h_&iax=images&ia=images`, + }, + signal: AbortSignal.timeout(15_000), + }); + + if (!res.ok) { + if (res.status === 429) { + console.warn(" ⚠ DDG rate limited (429). Waiting 10s..."); + await sleep(10_000); + return searchImagesDuckDuckGo(query, vqd, page); + } + if (res.status === 403) return []; + throw new Error(`DuckDuckGo search failed: ${res.status}`); + } + + const data = (await res.json()) as { results: DuckDuckGoImageResult[] }; + return data.results ?? []; +} + +async function collectImagesDuckDuckGo( + query: string, + target: number, + seenUrls: Set, +): Promise<{ urls: string[]; exhausted: boolean }> { + const results: string[] = []; + let page = 1; + let exhausted = false; + let consecutiveEmpty = 0; + + let vqd: string; + try { + vqd = await getVqdToken(query); + } catch (err) { + console.warn(` ⚠ DDG token failed: ${err instanceof Error ? err.message : "unknown"}`); + return { urls: [], exhausted: true }; + } + + const MAX_PAGES = 5; + let lowNoveltyCount = 0; + + while (results.length < target && page <= MAX_PAGES) { + await sleep(SEARCH_DELAY); + + let pageResults: DuckDuckGoImageResult[]; + try { + pageResults = await searchImagesDuckDuckGo(query, vqd, page); + } catch (err) { + console.warn(` ⚠ DDG error: ${err instanceof Error ? err.message : "unknown"}`); + break; + } + + if (!pageResults || pageResults.length === 0) { + consecutiveEmpty++; + if (consecutiveEmpty >= 3) { + exhausted = true; + break; + } + page++; + continue; + } + + consecutiveEmpty = 0; + let newCount = 0; + + for (const r of pageResults) { + if (results.length >= target) break; + const imgUrl = r.image || r.url; + if (!imgUrl || typeof imgUrl !== "string") continue; + if (seenUrls.has(imgUrl)) continue; + try { + new URL(imgUrl); + } catch { + continue; + } + seenUrls.add(imgUrl); + results.push(imgUrl); + newCount++; + } + + const newRatio = newCount / pageResults.length; + if (newRatio < 0.05) { + lowNoveltyCount++; + if (lowNoveltyCount >= 2) break; + } else { + lowNoveltyCount = 0; + } + + if (results.length < target) page++; + } + + return { urls: results.slice(0, target), exhausted }; +} + +// ─── iNaturalist API ─────────────────────────────────────────────────────── + +async function searchImagesInaturalist( + query: string, + target: number, + seenUrls: Set, +): Promise<{ urls: string[]; exhausted: boolean }> { + const results: string[] = []; + const perPage = Math.min(target, 200); + + const apiUrl = + `https://api.inaturalist.org/v1/observations` + + `?q=${encodeURIComponent(query)}` + + `&photos_only=true` + + `&quality_grade=research` + + `&per_page=${perPage}` + + `&order_by=observed_on&order=desc`; + + try { + const res = await fetch(apiUrl, { + headers: { "User-Agent": UA, Accept: "application/json" }, + signal: AbortSignal.timeout(15_000), + }); + if (!res.ok) return { urls: [], exhausted: false }; + + const data = (await res.json()) as { + results: Array<{ photos: Array<{ url: string }> }>; + }; + + for (const obs of data.results ?? []) { + if (results.length >= target) break; + for (const photo of obs.photos ?? []) { + if (results.length >= target) break; + const url = photo.url; + if (!url || seenUrls.has(url)) continue; + const fullUrl = url.replace("/medium.", "/original."); + seenUrls.add(fullUrl); + results.push(fullUrl); + } + } + + return { urls: results, exhausted: results.length < target }; + } catch { + return { urls: results, exhausted: false }; + } +} + +// ─── Wikimedia Commons API ───────────────────────────────────────────────── + +async function searchImagesCommons( + query: string, + target: number, + seenUrls: Set, +): Promise<{ urls: string[]; exhausted: boolean }> { + const results: string[] = []; + let sroffset = 0; + + while (results.length < target) { + const params = new URLSearchParams({ + action: "query", + list: "search", + srsearch: query, + srnamespace: "6", + srlimit: "50", + sroffset: String(sroffset), + format: "json", + }); + + const url = `https://commons.wikimedia.org/w/api.php?${params}`; + + try { + const res = await fetch(url, { + headers: { "User-Agent": UA }, + signal: AbortSignal.timeout(10_000), + }); + if (!res.ok) break; + + const data = (await res.json()) as { + query?: { search?: Array<{ title: string }> }; + continue?: { sroffset?: number }; + }; + + const hits = data.query?.search ?? []; + if (hits.length === 0) break; + + for (const hit of hits) { + if (results.length >= target) break; + const filename = hit.title.replace(/^File:/, ""); + const imgUrl = `https://commons.wikimedia.org/wiki/Special:FilePath/${encodeURIComponent( + filename, + )}`; + if (seenUrls.has(imgUrl)) continue; + seenUrls.add(imgUrl); + results.push(imgUrl); + } + + sroffset = data.continue?.sroffset ?? sroffset + hits.length; + } catch { + break; + } + } + + return { urls: results, exhausted: results.length < target }; +} + +// ─── Image Download ───────────────────────────────────────────────────────── + +async function downloadImage(url: string, destPath: string): Promise { + try { + const res = await fetch(url, { + headers: { "User-Agent": UA, Accept: "image/webp,image/png,image/jpeg,*/*" }, + signal: AbortSignal.timeout(15_000), + }); + if (!res.ok) return false; + + const contentType = res.headers.get("content-type") || ""; + if (contentType.includes("text/html")) return false; + + const buffer = Buffer.from(await res.arrayBuffer()); + if (buffer.length < MIN_IMAGE_SIZE) return false; + if (buffer.length > MAX_IMAGE_SIZE) return false; + + let ext = extname(new URL(url).pathname).toLowerCase(); + if (!ALLOWED_EXTENSIONS.includes(ext)) { + if (contentType.includes("jpeg") || contentType.includes("jpg")) ext = ".jpg"; + else if (contentType.includes("png")) ext = ".png"; + else if (contentType.includes("webp")) ext = ".webp"; + else ext = ".jpg"; + } + + const filePath = destPath.replace(/\.\w+$/, ext); + writeFileSync(filePath, buffer); + return true; + } catch { + return false; + } +} + +async function downloadBatch( + urls: string[], + classDir: string, + startIndex: number, +): Promise<{ downloaded: number; failed: number; lastIndex: number }> { + let downloaded = 0; + let failed = 0; + let index = startIndex; + + for (let i = 0; i < urls.length; i += CONCURRENT_DOWNLOADS) { + const chunk = urls.slice(i, i + CONCURRENT_DOWNLOADS); + + const results = await Promise.all( + chunk.map(async (url) => { + const paddedIndex = String(index).padStart(4, "0"); + const destPath = resolve(classDir, `img_${paddedIndex}.jpg`); + const success = await downloadImage(url, destPath); + return { success, index: index++ }; + }), + ); + + for (const r of results) { + if (r.success) downloaded++; + else failed++; + } + + const total = downloaded + failed; + if (total % 30 === 0 || total === urls.length) { + process.stdout.write(`\r Progress: ${downloaded}/${urls.length} (${failed} failed)`); + } + } + console.log(); + + return { downloaded, failed, lastIndex: index }; +} + +// ─── Query Building ───────────────────────────────────────────────────────── + +function buildSearchQueries(name: string, plant: string): string[] { + return [`${name} ${plant} leaf disease`, `${plant} ${name} symptoms`, `${name} ${plant}`]; +} + +function buildHealthyQueries(plant: string): string[] { + const name = plant.replace(/-/g, " "); + return [ + `healthy ${name} leaf`, + `${name} leaf closeup`, + `healthy ${name} plant`, + `${name} foliage`, + ]; +} + +// ─── Fill Logic ───────────────────────────────────────────────────────────── + +/** + * Try to collect up to `needed` images for a disease by hitting all three + * sources in order. Returns how many new images were actually downloaded. + */ +async function fillClass( + diseaseId: string, + queries: string[], + needed: number, + classDir: string, + seenUrls: Set, +): Promise { + if (needed <= 0) return 0; + + mkdirSync(classDir, { recursive: true }); + + const allUrls: string[] = []; + + // ── Source 1: DuckDuckGo ─────────────────────────────────────────────── + if (allUrls.length < needed) { + for (const query of queries) { + if (allUrls.length >= needed) break; + process.stdout.write(` DDG: "${query.substring(0, 40)}"... `); + const result = await collectImagesDuckDuckGo(query, needed - allUrls.length, seenUrls); + allUrls.push(...result.urls); + console.log(`${result.urls.length} new`); + if (result.exhausted) break; + } + } + + // ── Source 2: iNaturalist ────────────────────────────────────────────── + if (allUrls.length < needed) { + process.stdout.write(` iNat: Searching... `); + const result = await searchImagesInaturalist(queries[0], needed - allUrls.length, seenUrls); + allUrls.push(...result.urls); + console.log(`${result.urls.length} new`); + } + + // ── Source 3: Wikimedia Commons ──────────────────────────────────────── + if (allUrls.length < needed) { + process.stdout.write(` Commons: Searching... `); + const result = await searchImagesCommons(queries[0], needed - allUrls.length, seenUrls); + allUrls.push(...result.urls); + console.log(`${result.urls.length} new`); + } + + if (allUrls.length === 0) { + console.log(` ✗ No new images found from any source`); + return 0; + } + + console.log(` Downloading ${allUrls.length} images...`); + const startIndex = countImagesInDir(classDir); + const { downloaded, failed } = await downloadBatch(allUrls, classDir, startIndex); + + const newTotal = countImagesInDir(classDir); + const gained = newTotal - startIndex; + console.log( + ` ${downloaded > 0 ? "✓" : "✗"} Downloaded ${downloaded}/${allUrls.length}` + + ` (${failed} failed, ${gained} new files)`, + ); + + return gained; +} + +// ─── Directory Scanner ───────────────────────────────────────────────────── + +interface ScanResult { + /** Disease id → how many images currently on disk */ + diseaseCounts: Map; + /** How many healthy images on disk */ + healthyCount: number; +} + +function scanDataset(): ScanResult { + const diseaseCounts = new Map(); + let healthyCount = 0; + + if (!existsSync(DATASET_DIR)) { + return { diseaseCounts, healthyCount: 0 }; + } + + const entries = readdirSync(DATASET_DIR, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith(".")) continue; + + if (entry.name === HEALTHY_CLASS) { + healthyCount = countImagesInDir(resolve(DATASET_DIR, entry.name)); + } else { + const count = countImagesInDir(resolve(DATASET_DIR, entry.name)); + if (count > 0) { + diseaseCounts.set(entry.name, count); + } + } + } + + return { diseaseCounts, healthyCount }; +} + +// ─── Main ─────────────────────────────────────────────────────────────────── + +async function main() { + console.log("=".repeat(60)); + console.log("TRAINING DATASET FILL — Gap-filling download"); + console.log("=".repeat(60)); + + // Ensure dataset directory exists + mkdirSync(DATASET_DIR, { recursive: true }); + + // ── Step 1: Scan what we already have ──────────────────────────────────── + console.log("\nScanning existing dataset..."); + const { diseaseCounts, healthyCount } = scanDataset(); + console.log(` Found ${diseaseCounts.size} disease directories, ${healthyCount} healthy images`); + + // ── Step 2: Load disease info from DB ──────────────────────────────────── + console.log("\nLoading disease info from database..."); + const db = getDb(); + + const allDiseases = await db + .select({ + id: diseases.id, + plantId: diseases.plantId, + name: diseases.name, + }) + .from(diseases); + + // Build a deduplicated map: disease id → first disease info found + const diseaseInfo = new Map(); + for (const d of allDiseases) { + if (!diseaseInfo.has(d.id)) { + diseaseInfo.set(d.id, { name: d.name, plantId: d.plantId }); + } + } + console.log(` Loaded ${diseaseInfo.size} unique diseases from DB`); + + // ── Step 3: Build deficit list ────────────────────────────────────────── + const deficits: DiseaseInfo[] = []; + + for (const [id, info] of diseaseInfo) { + const have = diseaseCounts.get(id) ?? 0; + const needed = TARGET_PER_DISEASE - have; + if (needed > 0) { + deficits.push({ id, name: info.name, plantId: info.plantId, have, needed }); + } + } + + // Sort by deficit size (largest first) so we prioritize the neediest diseases + deficits.sort((a, b) => b.needed - a.needed); + + const healthyDeficit = TARGET_HEALTHY - healthyCount; + + console.log(`\n${"=".repeat(60)}`); + console.log("DEFICIT REPORT"); + console.log(`${"=".repeat(60)}`); + console.log(` Diseases needing images: ${deficits.length}/${diseaseInfo.size}`); + console.log(` Total images missing: ${deficits.reduce((s, d) => s + d.needed, 0)}`); + console.log(` Healthy deficit: ${Math.max(0, healthyDeficit)}`); + console.log(`${"=".repeat(60)}`); + + if (deficits.length === 0 && healthyDeficit <= 0) { + console.log("\n ✓ Nothing to do — all targets met!\n"); + await closeDb(); + return; + } + + // ── Step 4: Load seen-URLs cache ──────────────────────────────────────── + const seenUrlsCache = loadSeenUrlsCache(); + let totalDownloaded = 0; + let totalFailed = 0; + const startTime = Date.now(); + + // ── Step 5: Fill disease deficits ─────────────────────────────────────── + if (deficits.length > 0) { + console.log("\n" + "─".repeat(60)); + console.log(`FILLING ${deficits.length} DISEASES (target: ${TARGET_PER_DISEASE} each)`); + console.log("─".repeat(60)); + + // Process in parallel batches + for (let i = 0; i < deficits.length; i += DISEASE_CONCURRENCY) { + const batch = deficits.slice(i, i + DISEASE_CONCURRENCY); + const batchNum = Math.floor(i / DISEASE_CONCURRENCY) + 1; + const totalBatches = Math.ceil(deficits.length / DISEASE_CONCURRENCY); + + console.log(`\n[Batch ${batchNum}/${totalBatches}] Processing ${batch.length} diseases...`); + + await Promise.all( + batch.map(async (d) => { + const classDir = resolve(DATASET_DIR, d.id); + const queries = buildSearchQueries(d.name, d.plantId); + const seen = new Set(seenUrlsCache[d.id] ?? []); + + console.log( + ` [${d.id}] have ${d.have}, need ${d.needed} more` + ` (${d.name} / ${d.plantId})`, + ); + + const gained = await fillClass(d.id, queries, d.needed, classDir, seen); + + // Update seen-URLs cache for this disease + seenUrlsCache[d.id] = Array.from(seen); + saveSeenUrlsCache(seenUrlsCache); + + totalDownloaded += gained; + }), + ); + + // Save seen cache after every batch + saveSeenUrlsCache(seenUrlsCache); + + const elapsed = Math.round((Date.now() - startTime) / 1000); + console.log( + ` [Batch ${batchNum}/${totalBatches}] checkpoint — ` + + `${totalDownloaded} downloaded so far (${elapsed}s elapsed)`, + ); + } + } + + // ── Step 6: Fill healthy deficit ──────────────────────────────────────── + if (healthyDeficit > 0) { + console.log("\n" + "─".repeat(60)); + console.log(`FILLING HEALTHY CLASS (target: ${TARGET_HEALTHY})`); + console.log("─".repeat(60)); + + const healthyDir = resolve(DATASET_DIR, HEALTHY_CLASS); + mkdirSync(healthyDir, { recursive: true }); + + // Collect all unique plants from the disease info + const allPlants = [...new Set(diseaseInfo.values())].map((d) => d.plantId); + const allHealthyQueries: string[] = []; + for (const plant of allPlants) { + allHealthyQueries.push(...buildHealthyQueries(plant)); + } + + const healthySeen = new Set(seenUrlsCache[HEALTHY_CLASS] ?? []); + const healthyNeeded = TARGET_HEALTHY - countImagesInDir(healthyDir); + const allUrls: string[] = []; + + // Try each source with up to 20 healthy queries + const sources = [ + { name: "DDG", collector: collectImagesDuckDuckGo }, + { name: "iNat", collector: searchImagesInaturalist }, + { name: "Commons", collector: searchImagesCommons }, + ] as const; + + for (const source of sources) { + if (allUrls.length >= healthyNeeded) break; + console.log(`\n Source: ${source.name}`); + + for (const query of allHealthyQueries.slice(0, 20)) { + if (allUrls.length >= healthyNeeded) break; + + process.stdout.write(` "${query}"... `); + const result = await source.collector(query, healthyNeeded - allUrls.length, healthySeen); + allUrls.push(...result.urls); + console.log(`${result.urls.length} new`); + } + } + + if (allUrls.length > 0) { + console.log(`\n Downloading ${allUrls.length} healthy images...`); + const startIdx = countImagesInDir(healthyDir); + const { downloaded, failed } = await downloadBatch(allUrls, healthyDir, startIdx); + + const newTotal = countImagesInDir(healthyDir); + const gained = newTotal - healthyCount; + totalDownloaded += gained; + totalFailed += failed; + + console.log( + ` ${downloaded > 0 ? "✓" : "✗"} Got ${downloaded} images.` + + ` Total healthy: ${newTotal}/${TARGET_HEALTHY} (${gained} new)`, + ); + } else { + console.log(`\n ✗ No healthy images found`); + } + + // Update seen-URLs cache + seenUrlsCache[HEALTHY_CLASS] = Array.from(healthySeen); + saveSeenUrlsCache(seenUrlsCache); + } + + // ── Summary ────────────────────────────────────────────────────────────── + const elapsed = Math.round((Date.now() - startTime) / 1000); + const mins = Math.floor(elapsed / 60); + const hrs = Math.floor(mins / 60); + + // Final scan + const finalScan = scanDataset(); + const totalHave = [...finalScan.diseaseCounts.values()].reduce((s, c) => s + c, 0); + const atTarget = [...finalScan.diseaseCounts.values()].filter( + (c) => c >= TARGET_PER_DISEASE, + ).length; + + console.log("\n" + "=".repeat(60)); + console.log(" ✅ FILL COMPLETE"); + console.log("=".repeat(60)); + console.log(` Time: ${hrs}h ${mins % 60}m`); + console.log(` Diseases at target: ${atTarget}/${diseaseInfo.size}`); + console.log(` Total images: ${totalHave}`); + console.log(` Healthy images: ${finalScan.healthyCount}/${TARGET_HEALTHY}`); + console.log(` New downloads: ${totalDownloaded}`); + console.log(` Dataset dir: ${DATASET_DIR}/`); + + await closeDb(); + console.log("=".repeat(60)); +} + +main().catch((err) => { + console.error("\nFatal error:", err); + process.exit(1); +}); diff --git a/apps/web/scripts/scrape-training-dataset.ts b/apps/web/scripts/scrape-training-dataset.ts index 8b9cfb9..500fdc2 100644 --- a/apps/web/scripts/scrape-training-dataset.ts +++ b/apps/web/scripts/scrape-training-dataset.ts @@ -4,10 +4,10 @@ * * Collects a training dataset from DuckDuckGo, iNaturalist, and Wikimedia Commons. * - * Targets (tiered by plant type): - * - Core plants (houseplants + common garden): 100 images per disease - * - Full set (all 11,498 DB diseases): 10 images per disease - * - Healthy: 400 images + * Target: Top 200 most common plant diseases (ranked by iNaturalist observation counts) + * - 200 images per disease + * - 200 healthy plant images + * - Processes 5 diseases in parallel with 30 concurrent downloads each * * Sources (all free, no API keys): * 1. DB image_url — existing images already found @@ -42,66 +42,30 @@ try { import { getDb, closeDb } from "@/lib/db/index"; import { diseases } from "@/lib/db/schema"; +import { sql } from "drizzle-orm"; // ─── Config ───────────────────────────────────────────────────────────────── const DATASET_DIR = resolve(__dirname, "../data/dataset"); const PROGRESS_FILE = resolve(DATASET_DIR, ".progress.json"); -/** Target images per disease for CORE plants */ -const TARGET_CORE = 100; +/** Target images per disease */ +const TARGET_PER_DISEASE = 200; -/** Target images per disease for the FULL set */ -const TARGET_FULL = 10; +/** Number of diseases to target (most common first) */ +const TARGET_DISEASE_COUNT = 200; /** Target images for the "healthy" class */ const TARGET_HEALTHY = 400; -/** Core plants that get higher image targets */ -const CORE_PLANTS = new Set([ - // Houseplants - "monstera", - "pothos", - "snake-plant", - "peace-lily", - "orchid", - "succulent", - "fiddle-leaf-fig", - "aloe-vera", - "cactus", - "fern", - // Garden plants - "tomato", - "basil", - "rose", - "pepper", - "strawberry", - "cucumber", - "squash", - "lettuce", - "spinach", - "cabbage", - "lavender", - "mint", - "jasmine", - "sunflower", - "daisy", - "zucchini", - "bean", - "eggplant", - "chili", - // General disease patterns - "general", -]); - /** Delay between DuckDuckGo search API calls (ms) */ const SEARCH_DELAY = 1500; -/** Delay between image downloads (ms) */ -const DOWNLOAD_DELAY = 100; +/** Max concurrent image downloads per disease */ +const CONCURRENT_DOWNLOADS = 30; -/** Max concurrent downloads */ -const CONCURRENT_DOWNLOADS = 10; +/** Number of diseases to process in parallel */ +const DISEASE_CONCURRENCY = 5; /** Minimum image size in bytes to accept */ const MIN_IMAGE_SIZE = 10_000; // 10KB @@ -167,21 +131,246 @@ interface Progress { // ─── DB Loading ────────────────────────────────────────────────────────────── +const INAT_CACHE_FILE = resolve(DATASET_DIR, ".inat-prevalence-cache.json"); + /** - * Load all diseases from the database with their existing image URLs. + * Query iNaturalist for real-world prevalence of a disease. + * Returns observation count (higher = more common in the real world). + */ +async function getInatPrevalence(diseaseName: string, plantName?: string): Promise { + try { + const headers = { "User-Agent": UA, Accept: "application/json" }; + const signal = AbortSignal.timeout(10_000); + const baseUrl = "https://api.inaturalist.org/v1/observations"; + + // Tier 1: disease + plant name, research-grade, Plantae/Fungi/Chromista + // This is the most specific and reliable query — filters to relevant kingdoms + // and only counts community-verified observations. + if (plantName) { + const q = `${diseaseName} ${plantName}`; + const url = + `${baseUrl}?q=${encodeURIComponent(q)}` + + `&quality_grade=research` + + `&iconic_taxon_id=47126,47158,47686` + + `&photos_only=true&per_page=1`; + const res = await fetch(url, { headers, signal }); + if (res.ok) { + const data = (await res.json()) as { total_results: number }; + if ((data.total_results ?? 0) > 0) return data.total_results!; + } + } + + // Fallback: disease name only, all quality grades (original behavior) + const url = `${baseUrl}?q=${encodeURIComponent(diseaseName.toLowerCase())}&photos_only=true&per_page=1`; + const res = await fetch(url, { headers, signal }); + if (!res.ok) return 0; + const data = (await res.json()) as { total_results: number }; + return data.total_results ?? 0; + } catch { + return 0; + } +} + +/** + * Load prevalence data from cache or build it by querying iNaturalist. + * Caches results to avoid re-querying on every run. + */ +async function loadPrevalenceData( + uniqueNames: string[], + plantMap?: Map, +): Promise> { + // Load cache if exists + let cache: Record = {}; + if (existsSync(INAT_CACHE_FILE)) { + try { + cache = JSON.parse(readFileSync(INAT_CACHE_FILE, "utf-8")); + } catch {} + } + + const prevalenceMap = new Map(); + const toQuery: string[] = []; + + // Check which names need querying + for (const name of uniqueNames) { + const key = name.toLowerCase(); + if (key in cache) { + prevalenceMap.set(name, cache[key]); + } else { + toQuery.push(name); + } + } + + if (toQuery.length > 0) { + console.log(`\n Querying iNaturalist for ${toQuery.length} disease prevalence scores...`); + let queried = 0; + + for (const name of toQuery) { + const count = await getInatPrevalence(name, plantMap?.get(name)); + const key = name.toLowerCase(); + cache[key] = count; + prevalenceMap.set(name, count); + queried++; + + // Save cache every 10 queries + if (queried % 10 === 0) { + writeFileSync(INAT_CACHE_FILE, JSON.stringify(cache, null, 2)); + console.log(` Queried ${queried}/${toQuery.length}...`); + } + + // Rate limit: ~100 req/min + await sleep(600); + } + + // Final cache save + writeFileSync(INAT_CACHE_FILE, JSON.stringify(cache, null, 2)); + console.log(` ✓ Queried ${queried} diseases, cached to ${INAT_CACHE_FILE}`); + } + + return prevalenceMap; +} + +/** + * Persist prevalence scores to the database and update prevalence enum. + * Maps observation counts to common/uncommon/rare based on thresholds. + */ +async function persistPrevalenceData( + db: ReturnType, + prevalenceMap: Map, +): Promise { + // Load all diseases to update + const allDiseases = await db + .select({ + id: diseases.id, + name: diseases.name, + }) + .from(diseases); + + // Compute percentile-based thresholds from actual score distribution. + // Top 25% → common, bottom 25% → rare, middle 50% → uncommon. + // This guarantees meaningful classification regardless of absolute scale. + const scores = Array.from(prevalenceMap.values()) + .filter((s) => s > 0) + .sort((a, b) => a - b); + const n = scores.length; + const commonThreshold = n > 0 ? scores[Math.floor(n * 0.75)] : 1000; + const rareThreshold = n > 0 ? scores[Math.floor(n * 0.25)] : 10; + + console.log( + `\n Prevalence distribution: ${n} non-zero scores` + + `, p25=${rareThreshold.toLocaleString()}` + + `, p75=${commonThreshold.toLocaleString()}`, + ); + console.log(` Persisting prevalence data for ${allDiseases.length} diseases...`); + let updated = 0; + + for (const disease of allDiseases) { + const score = prevalenceMap.get(disease.name) ?? 0; + + // Map score to prevalence enum using distribution-based thresholds. + // Score of 0 means no iNaturalist observations found — genuinely rare. + let prevalence: "common" | "uncommon" | "rare" | "very_rare"; + if (score === 0) { + prevalence = "very_rare"; + } else if (score >= commonThreshold) { + prevalence = "common"; + } else if (score > rareThreshold) { + prevalence = "uncommon"; + } else { + prevalence = "rare"; + } + + await db + .update(diseases) + .set({ + prevalenceScore: score, + prevalence, + updatedAt: sql`(datetime('now'))`, + }) + .where(sql`${diseases.id} = ${disease.id}`); + + updated++; + if (updated % 100 === 0) { + console.log(` Updated ${updated}/${allDiseases.length}...`); + } + } + + console.log(` ✓ Updated ${updated} diseases with prevalence data`); +} + +/** + * Load the top 200 most common diseases from the database. + * Ranks by iNaturalist observation counts (real-world prevalence data). */ async function loadDiseasesFromDb(): Promise { const db = getDb(); - const rows = await db + + // Get unique disease names and their most common host plant for better iNaturalist queries. + const nameStats = await db + .select({ + name: diseases.name, + plantId: diseases.plantId, + count: sql`COUNT(*)`.mapWith(Number), + }) + .from(diseases) + .groupBy(diseases.name, diseases.plantId); + + // Aggregate: unique names, name frequency (across all plants), and most common plant per name + const seenNames = new Set(); + const nameFrequency = new Map(); + const plantFreq = new Map>(); + let totalDiseases = 0; + + for (const row of nameStats) { + seenNames.add(row.name); + nameFrequency.set(row.name, (nameFrequency.get(row.name) ?? 0) + row.count); + totalDiseases += row.count; + + if (!plantFreq.has(row.name)) plantFreq.set(row.name, new Map()); + plantFreq.get(row.name)!.set(row.plantId, row.count); + } + + const uniqueNames = [...seenNames]; + + // For each disease name, pick the most frequent host plant for more specific iNaturalist queries + const plantMap = new Map(); + for (const [name, freq] of plantFreq) { + const top = [...freq.entries()].sort((a, b) => b[1] - a[1])[0]; + plantMap.set(name, top[0]); + } + + console.log( + ` Found ${uniqueNames.length} unique disease names across ${totalDiseases} diseases`, + ); + + // Load or build prevalence data from iNaturalist (with plant context for better queries) + const prevalenceMap = await loadPrevalenceData(uniqueNames, plantMap); + + // Persist prevalence scores to database + await persistPrevalenceData(db, prevalenceMap); + + // Load all diseases + const allDiseases = await db .select({ id: diseases.id, plantId: diseases.plantId, name: diseases.name, imageUrl: diseases.imageUrl, }) - .from(diseases) - .orderBy(diseases.id); - return rows; + .from(diseases); + + // Sort by iNaturalist prevalence (descending), then by name frequency as tiebreaker + allDiseases.sort((a, b) => { + const prevA = prevalenceMap.get(a.name) ?? 0; + const prevB = prevalenceMap.get(b.name) ?? 0; + if (prevA !== prevB) return prevB - prevA; + // Tiebreaker: name frequency + const freqA = nameFrequency.get(a.name) ?? 0; + const freqB = nameFrequency.get(b.name) ?? 0; + return freqB - freqA; + }); + + // Return top TARGET_DISEASE_COUNT + return allDiseases.slice(0, TARGET_DISEASE_COUNT); } // ─── DuckDuckGo API ───────────────────────────────────────────────────────── @@ -208,7 +397,9 @@ async function searchImagesDuckDuckGo( vqd: string, page: number, ): Promise { - const url = `https://duckduckgo.com/i.js?q=${encodeURIComponent(query)}&vqd=${vqd}&o=json&p=${page}&f=,,,`; + const url = `https://duckduckgo.com/i.js?q=${encodeURIComponent( + query, + )}&vqd=${vqd}&o=json&p=${page}&f=,,,`; const res = await fetch(url, { headers: { @@ -396,7 +587,9 @@ async function searchImagesCommons( for (const hit of hits) { if (results.length >= target) break; const filename = hit.title.replace(/^File:/, ""); - const imgUrl = `https://commons.wikimedia.org/wiki/Special:FilePath/${encodeURIComponent(filename)}`; + const imgUrl = `https://commons.wikimedia.org/wiki/Special:FilePath/${encodeURIComponent( + filename, + )}`; if (seenUrls.has(imgUrl)) continue; seenUrls.add(imgUrl); results.push(imgUrl); @@ -461,7 +654,6 @@ async function downloadBatch( const paddedIndex = String(index).padStart(4, "0"); const destPath = resolve(classDir, `img_${paddedIndex}.jpg`); const success = await downloadImage(url, destPath); - await sleep(DOWNLOAD_DELAY); return { success, index: index++, url: url.substring(0, 50) }; }), ); @@ -496,19 +688,36 @@ function loadProgress(): Progress { } try { const raw = JSON.parse(readFileSync(PROGRESS_FILE, "utf-8")) as Partial; - // Backward compat: ensure new fields exist - raw.phase ??= 0; - raw.phaseIndex ??= 0; raw.classes ??= {}; + + // Migration: detect old tiered system (phaseIndex > 200 means it's from old core/full system) + const isOldFormat = (raw.phaseIndex ?? 0) > 200 || !raw.phase; + if (isOldFormat) { + console.warn(" ↻ Migrating progress file from old tiered system to new format"); + console.warn(" Phase checkpoint reset to 0 (will re-scan all 200 diseases)"); + console.warn(" Per-class progress (seenUrls, counts) preserved"); + raw.phase = 0; + raw.phaseIndex = 0; + } else { + raw.phase ??= 0; + raw.phaseIndex ??= 0; + } + // Ensure each class has the sources field for (const key of Object.keys(raw.classes)) { const cp = raw.classes[key] as Partial; - cp.sources ??= { - db: { exhausted: false }, - duckduckgo: { exhausted: false }, - inaturalist: { exhausted: false }, - wikimedia: { exhausted: false }, - }; + + // Migrate class-level exhausted to per-source exhausted if needed + if (!cp.sources) { + const classExhausted = cp.exhausted ?? false; + cp.sources = { + db: { exhausted: classExhausted }, + duckduckgo: { exhausted: classExhausted }, + inaturalist: { exhausted: classExhausted }, + wikimedia: { exhausted: classExhausted }, + }; + } + cp.seenUrls ??= []; } return raw as Progress; @@ -608,7 +817,6 @@ async function collectClassImages( progress: Progress, classDir: string, existingUrls: string[] = [], - fastMode = false, // Skip slow DuckDuckGo, use iNat + Commons only ): Promise { const cp = getClassProgress(progress, classId); @@ -664,7 +872,7 @@ async function collectClassImages( } // ── Source 1: DuckDuckGo ────────────────────────────────────────────── - if (!fastMode && !sources.duckduckgo.exhausted && allUrls.length < needed) { + if (!sources.duckduckgo.exhausted && allUrls.length < needed) { for (const query of queries) { if (allUrls.length >= needed) break; process.stdout.write(` DDG: "${query.substring(0, 40)}"... `); @@ -753,7 +961,9 @@ async function collectClassImages( const pct = Math.round((cp.count / target) * 100); console.log( - ` ${downloaded > 0 ? "✓" : "✗"} Got ${downloaded}/${allUrls.length} (${failed} failed). Total: ${cp.count}/${target} (${pct}%)`, + ` ${downloaded > 0 ? "✓" : "✗"} Got ${downloaded}/${ + allUrls.length + } (${failed} failed). Total: ${cp.count}/${target} (${pct}%)`, ); } @@ -761,21 +971,18 @@ async function collectClassImages( async function main() { console.log("=".repeat(60)); - console.log("PLANT DISEASE DATASET COLLECTOR — FULL DB"); + console.log("PLANT DISEASE DATASET COLLECTOR — TOP 200 COMMON DISEASES"); console.log("=".repeat(60)); + // Ensure dataset directory exists before any cache writes + mkdirSync(DATASET_DIR, { recursive: true }); + // Load diseases from DB - console.log("\nLoading diseases from database..."); + console.log("\nLoading top 200 most common diseases from database..."); const dbDiseases = await loadDiseasesFromDb(); console.log(` ${dbDiseases.length} diseases loaded`); - const coreDiseases = dbDiseases.filter((d) => CORE_PLANTS.has(d.plantId)); - const fullDiseases = dbDiseases.filter((d) => !CORE_PLANTS.has(d.plantId)); - console.log(` Core plants: ${coreDiseases.length} diseases (target: ${TARGET_CORE})`); - console.log(` Full set: ${fullDiseases.length} diseases (target: ${TARGET_FULL})`); - // Load progress - mkdirSync(DATASET_DIR, { recursive: true }); const progress = loadProgress(); // If all phases complete, exit early @@ -787,63 +994,57 @@ async function main() { const startTime = Date.now(); - // ── Phase 1: Core set ────────────────────────────────────────────────── + // ── Phase 1: Common diseases (200 images each) ────────────────────────── console.log("\n" + "─".repeat(60)); - console.log("PHASE 1: Core Diseases (100 images each)"); + console.log("PHASE 1: Common Diseases (200 images each)"); console.log("─".repeat(60)); - const coreStart = progress.phase === 0 ? progress.phaseIndex : 0; - if (coreStart > 0) { + const diseaseStart = progress.phase === 0 ? progress.phaseIndex : 0; + if (diseaseStart > 0) { console.log( - ` Resuming from disease #${coreStart + 1} (${((coreStart / coreDiseases.length) * 100).toFixed(0)}% done)`, + ` Resuming from disease #${diseaseStart + 1} (${( + (diseaseStart / dbDiseases.length) * + 100 + ).toFixed(0)}% done)`, ); } - for (let i = coreStart; i < coreDiseases.length; i++) { - const d = coreDiseases[i]; - const classDir = resolve(DATASET_DIR, d.id); - const queries = buildSearchQueries(d); - const existingUrls = d.imageUrl ? [d.imageUrl] : []; + // Process diseases in parallel batches + for (let i = diseaseStart; i < dbDiseases.length; i += DISEASE_CONCURRENCY) { + const batch = dbDiseases.slice(i, i + DISEASE_CONCURRENCY); + const batchNum = Math.floor(i / DISEASE_CONCURRENCY) + 1; + const totalBatches = Math.ceil(dbDiseases.length / DISEASE_CONCURRENCY); + const pct = Math.round((i / dbDiseases.length) * 100); - const pct = Math.round((i / coreDiseases.length) * 100); - console.log(`\n[${i + 1}/${coreDiseases.length}] (${pct}%) ${d.name || d.id} (${d.plantId})`); + console.log( + `\n[Batch ${batchNum}/${totalBatches}] (${pct}%) Processing ${batch.length} diseases in parallel...`, + ); - await collectClassImages(d.id, queries, TARGET_CORE, progress, classDir, existingUrls); + // Process all diseases in this batch concurrently + await Promise.all( + batch.map(async (d, batchIdx) => { + const diseaseIdx = i + batchIdx; + const classDir = resolve(DATASET_DIR, d.id); + const queries = buildSearchQueries(d); + const existingUrls = d.imageUrl ? [d.imageUrl] : []; - // Save checkpoint: phase 0, at index i + console.log(` [${diseaseIdx + 1}/${dbDiseases.length}] ${d.name || d.id} (${d.plantId})`); + + await collectClassImages( + d.id, + queries, + TARGET_PER_DISEASE, + progress, + classDir, + existingUrls, + ); + }), + ); + + // Save checkpoint: phase 0, at index i + batch.length progress.phase = 0; - progress.phaseIndex = i + 1; - saveProgress(progress); - } - - // ── Phase 2: Full set ────────────────────────────────────────────────── - - console.log("\n" + "─".repeat(60)); - console.log("PHASE 2: Full Disease Set (10 images each)"); - console.log("─".repeat(60)); - - const fullStart = progress.phase === 1 ? progress.phaseIndex : 0; - if (fullStart > 0) { - console.log( - ` Resuming from disease #${fullStart + 1} (${((fullStart / fullDiseases.length) * 100).toFixed(0)}% done)`, - ); - } - - for (let i = fullStart; i < fullDiseases.length; i++) { - const d = fullDiseases[i]; - const classDir = resolve(DATASET_DIR, d.id); - const queries = buildSearchQueries(d); - const existingUrls = d.imageUrl ? [d.imageUrl] : []; - - const pct = Math.round((i / fullDiseases.length) * 100); - console.log(`\n[${i + 1}/${fullDiseases.length}] (${pct}%) ${d.id} (${d.plantId})`); - - await collectClassImages(d.id, queries, TARGET_FULL, progress, classDir, existingUrls, true); - - // Save checkpoint: phase 1, at index i - progress.phase = 1; - progress.phaseIndex = i + 1; + progress.phaseIndex = i + batch.length; saveProgress(progress); } diff --git a/apps/web/src/app/browse/[plantId]/DiseaseCards.tsx b/apps/web/src/app/browse/[plantId]/DiseaseCards.tsx index 1e6b7cb..bbbbe2a 100644 --- a/apps/web/src/app/browse/[plantId]/DiseaseCards.tsx +++ b/apps/web/src/app/browse/[plantId]/DiseaseCards.tsx @@ -272,18 +272,22 @@ function PrevalenceBadge({ prevalence }: { prevalence: Prevalence }) { common: "📊", uncommon: "📋", rare: "📌", + very_rare: "🔍", }; const colors: Record = { common: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300", uncommon: "bg-zinc-100 text-zinc-700 dark:bg-zinc-800/60 dark:text-zinc-300", rare: "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300", + very_rare: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300", }; + const label = prevalence.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); + return ( - {icons[prevalence]} {prevalence.charAt(0).toUpperCase() + prevalence.slice(1)} + {icons[prevalence]} {label} ); } @@ -298,9 +302,10 @@ const SEVERITY_RANK: Record = { }; const PREVALENCE_RANK: Record = { - common: 3, - uncommon: 2, - rare: 1, + common: 4, + uncommon: 3, + rare: 2, + very_rare: 1, }; type SortField = "prevalence" | "danger"; diff --git a/apps/web/src/lib/api/browse.ts b/apps/web/src/lib/api/browse.ts index ab644b4..587fa99 100644 --- a/apps/web/src/lib/api/browse.ts +++ b/apps/web/src/lib/api/browse.ts @@ -3,7 +3,7 @@ * for the browse page. Runs server-side only. */ -import { sql, eq } from "drizzle-orm"; +import { sql, eq, inArray, notInArray } from "drizzle-orm"; import { getDb } from "@/lib/db/index"; import { plants, diseases, plantViews } from "@/lib/db/schema"; import type { PlantCardData } from "@/components/PlantCard"; @@ -12,11 +12,13 @@ export type { PlantCardData }; /** * Get all plants with their disease counts for the browse page. + * + * Uses scalar subqueries for COUNT to avoid expensive LEFT JOIN + GROUP BY + * on the large diseases table (11,498 rows). */ export async function getBrowsePlants(): Promise { const db = getDb(); - // LEFT JOIN to include plants with zero diseases const rows = await db .select({ id: plants.id, @@ -27,12 +29,10 @@ export async function getBrowsePlants(): Promise { imageUrl: plants.imageUrl, updatedAt: plants.updatedAt, viewCount: sql`COALESCE(${plantViews.viewCount}, 0)`, - diseaseCount: sql`COUNT(${diseases.id})`, + diseaseCount: sql`(SELECT COUNT(*) FROM ${diseases} WHERE ${diseases.plantId} = ${plants.id})`, }) .from(plants) - .leftJoin(diseases, eq(diseases.plantId, plants.id)) .leftJoin(plantViews, eq(plantViews.plantId, plants.id)) - .groupBy(plants.id) .orderBy(plants.commonName); return rows.map((r) => ({ @@ -61,12 +61,10 @@ export async function getBrowsePlant(id: string): Promise family: plants.family, category: plants.category, imageUrl: plants.imageUrl, - diseaseCount: sql`COUNT(${diseases.id})`, + diseaseCount: sql`(SELECT COUNT(*) FROM ${diseases} WHERE ${diseases.plantId} = ${plants.id})`, }) .from(plants) - .leftJoin(diseases, eq(diseases.plantId, plants.id)) .where(eq(plants.id, id)) - .groupBy(plants.id) .limit(1); return rows[0] ?? null; @@ -91,12 +89,47 @@ const FEATURED_IDS = [ ]; export async function getFeaturedPlants(): Promise { - const all = await getBrowsePlants(); - const featured = all.filter((p) => FEATURED_IDS.includes(p.id)); - // If fewer than expected are found, pad with first available plants - if (featured.length < 6) { - const rest = all.filter((p) => !FEATURED_IDS.includes(p.id)); - return [...featured, ...rest].slice(0, 12); + const db = getDb(); + + const selectFeatured = db + .select({ + id: plants.id, + commonName: plants.commonName, + scientificName: plants.scientificName, + family: plants.family, + category: plants.category, + imageUrl: plants.imageUrl, + updatedAt: plants.updatedAt, + viewCount: sql`COALESCE(${plantViews.viewCount}, 0)`, + diseaseCount: sql`(SELECT COUNT(*) FROM ${diseases} WHERE ${diseases.plantId} = ${plants.id})`, + }) + .from(plants) + .leftJoin(plantViews, eq(plantViews.plantId, plants.id)); + + const rows = await selectFeatured + .where(inArray(plants.id, FEATURED_IDS)) + .orderBy(plants.commonName); + + if (rows.length < 6) { + const padRows = await selectFeatured + .where(notInArray(plants.id, FEATURED_IDS)) + .orderBy(plants.commonName) + .limit(12 - rows.length); + return [...rows, ...padRows].map(mapRow); } - return featured.slice(0, 12); + return rows.slice(0, 12).map(mapRow); +} + +function mapRow(r: Record): PlantCardData { + return { + id: r.id as string, + commonName: r.commonName as string, + scientificName: r.scientificName as string, + family: r.family as string, + category: r.category as string, + imageUrl: r.imageUrl as string, + updatedAt: r.updatedAt as string | undefined, + viewCount: r.viewCount as number, + diseaseCount: r.diseaseCount as number, + }; } diff --git a/apps/web/src/lib/api/diseases-db.ts b/apps/web/src/lib/api/diseases-db.ts index c22fc0b..47fa91a 100644 --- a/apps/web/src/lib/api/diseases-db.ts +++ b/apps/web/src/lib/api/diseases-db.ts @@ -280,7 +280,7 @@ export async function validateKnowledgeBase(): Promise { "environmental", ]; const validSeverities: Severity[] = ["low", "moderate", "high", "critical"]; - const validPrevalences: Prevalence[] = ["common", "uncommon", "rare"]; + const validPrevalences: Prevalence[] = ["common", "uncommon", "rare", "very_rare"]; const db = getDb(); diff --git a/apps/web/src/lib/db/schema.ts b/apps/web/src/lib/db/schema.ts index afc1756..8951154 100644 --- a/apps/web/src/lib/db/schema.ts +++ b/apps/web/src/lib/db/schema.ts @@ -55,10 +55,11 @@ export const diseases = sqliteTable( prevention: text("prevention", { mode: "json" }).notNull().default([]).$type(), lookalikeIds: text("lookalike_ids", { mode: "json" }).notNull().default([]).$type(), prevalence: text("prevalence", { - enum: ["common", "uncommon", "rare"], + enum: ["common", "uncommon", "rare", "very_rare"], }) .notNull() .default("uncommon"), + prevalenceScore: integer("prevalence_score").notNull().default(0), severity: text("severity", { enum: ["low", "moderate", "high", "critical"], }).notNull(), diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index 9a37a2f..3a4d3a5 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -10,7 +10,7 @@ export type CausalAgentType = "fungal" | "bacterial" | "viral" | "environmental" export type Severity = "low" | "moderate" | "high" | "critical"; /** How common/prevalent a disease is in the field */ -export type Prevalence = "common" | "uncommon" | "rare"; +export type Prevalence = "common" | "uncommon" | "rare" | "very_rare"; /** Plant category for grouping and filtering */ export type PlantCategory =