This commit is contained in:
2026-06-08 16:42:04 -04:00
commit 8bda14ab63
179 changed files with 48104 additions and 0 deletions

11
.env.local.example Normal file
View File

@@ -0,0 +1,11 @@
# Plant Disease Identification — Environment Variables
# Copy this file to .env.local and fill in real values.
# Path to compiled ML model files (relative to public/)
NEXT_PUBLIC_MODEL_PATH=/models
# (Future) API key for external plant disease databases
# PLANT_ID_API_KEY=
# (Future) OpenAI / vision model API key for image analysis
# OPENAI_API_KEY=

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# Git LFS for compiled ML model files
public/models/* filter=lfs diff=lfs merge=lfs -text

103
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,103 @@
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/web
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: apps/web/package-lock.json
- name: Install dependencies
run: npm ci
- name: Run ESLint
run: npm run lint
typecheck:
name: Type Check
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/web
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: apps/web/package-lock.json
- name: Install dependencies
run: npm ci
- name: Run TypeScript check
run: npx tsc --noEmit
test:
name: Test
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/web
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: apps/web/package-lock.json
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npx vitest run --reporter=verbose --coverage
build:
name: Build
runs-on: ubuntu-latest
needs: [lint, typecheck, test]
defaults:
run:
working-directory: apps/web
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: apps/web/package-lock.json
- name: Install dependencies
run: npm ci
- name: Build Next.js app
run: npm run build
env:
NODE_ENV: production

50
.gitignore vendored Normal file
View File

@@ -0,0 +1,50 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files — commit templates, not actual secrets
.env*
!.env.local.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# Uploaded images (user-generated content)
/public/uploads/*
!/public/uploads/.gitkeep
# ML model binaries (tracked via Git LFS)
# Add to .gitattributes for LFS:
# public/models/* filter=lfs diff=lfs merge=lfs -text

8
.prettierrc Normal file
View File

@@ -0,0 +1,8 @@
{
"semi": true,
"trailingComma": "all",
"singleQuote": false,
"printWidth": 100,
"tabWidth": 2,
"useTabs": false
}

25
.vercelignore Normal file
View File

@@ -0,0 +1,25 @@
# Next.js build output (Vercel rebuilds)
.next/
# Dependencies (Vercel installs these)
node_modules/
# Python venv and caches
.tfjs-venv/
.ruff_cache/
# Data files (large ML datasets — 37G)
data/
# Test coverage
coverage/
# Git (Vercel prefers no git dir for CLI deploys)
.git/
# Scripts and task files (build-time only)
scripts/
tasks/
# OS files
.DS_Store

5
AGENTS.md Normal file
View File

@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

1
CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
@AGENTS.md

36
README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

1
data/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
dataset

11
drizzle.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/lib/db/schema.ts",
out: "./drizzle",
dialect: "turso",
dbCredentials: {
url: process.env.DATABASE_URL!,
authToken: process.env.DATABASE_TOKEN!,
},
});

View File

@@ -0,0 +1,46 @@
CREATE TABLE `diseases` (
`id` text PRIMARY KEY NOT NULL,
`plant_id` text NOT NULL,
`name` text NOT NULL,
`scientific_name` text DEFAULT '' NOT NULL,
`causal_agent_type` text NOT NULL,
`description` text DEFAULT '' NOT NULL,
`symptoms` text DEFAULT '[]' NOT NULL,
`causes` text DEFAULT '[]' NOT NULL,
`treatment` text DEFAULT '[]' NOT NULL,
`prevention` text DEFAULT '[]' NOT NULL,
`lookalike_ids` text DEFAULT '[]' NOT NULL,
`severity` text NOT NULL,
`source_url` text DEFAULT '' NOT NULL,
`created_at` text DEFAULT (datetime('now')) NOT NULL,
`updated_at` text DEFAULT (datetime('now')) NOT NULL,
FOREIGN KEY (`plant_id`) REFERENCES `plants`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `idx_diseases_plant_id` ON `diseases` (`plant_id`);--> statement-breakpoint
CREATE INDEX `idx_diseases_causal_agent` ON `diseases` (`causal_agent_type`);--> statement-breakpoint
CREATE INDEX `idx_diseases_severity` ON `diseases` (`severity`);--> statement-breakpoint
CREATE TABLE `plants` (
`id` text PRIMARY KEY NOT NULL,
`common_name` text NOT NULL,
`scientific_name` text NOT NULL,
`family` text NOT NULL,
`category` text NOT NULL,
`care_summary` text DEFAULT '' NOT NULL,
`image_url` text DEFAULT '' NOT NULL,
`created_at` text DEFAULT (datetime('now')) NOT NULL,
`updated_at` text DEFAULT (datetime('now')) NOT NULL
);
--> statement-breakpoint
CREATE INDEX `idx_plants_category` ON `plants` (`category`);--> statement-breakpoint
CREATE INDEX `idx_plants_common_name` ON `plants` (`common_name`);--> statement-breakpoint
CREATE TABLE `scrape_sources` (
`id` text PRIMARY KEY NOT NULL,
`source_type` text NOT NULL,
`source_url` text NOT NULL,
`last_scraped_at` text,
`entries_count` integer DEFAULT 0,
`status` text DEFAULT 'pending' NOT NULL,
`error_message` text,
`created_at` text DEFAULT (datetime('now')) NOT NULL
);

View File

@@ -0,0 +1 @@
ALTER TABLE `diseases` ADD `image_url` text DEFAULT '' NOT NULL;

View File

@@ -0,0 +1,2 @@
ALTER TABLE `diseases` ADD `prevalence` text DEFAULT 'uncommon' NOT NULL;--> statement-breakpoint
CREATE INDEX `idx_diseases_prevalence` ON `diseases` (`prevalence`);

View File

@@ -0,0 +1,7 @@
CREATE TABLE `plant_views` (
`plant_id` text PRIMARY KEY NOT NULL,
`view_count` integer DEFAULT 0 NOT NULL,
FOREIGN KEY (`plant_id`) REFERENCES `plants`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `idx_plant_views_count` ON `plant_views` (`view_count`);

View File

@@ -0,0 +1,14 @@
CREATE TABLE `flagged_content` (
`id` text PRIMARY KEY NOT NULL,
`content_type` text NOT NULL,
`content_id` text NOT NULL,
`field_name` text NOT NULL,
`notes` text DEFAULT '',
`flag_count` integer DEFAULT 1 NOT NULL,
`created_at` text DEFAULT (datetime('now')) NOT NULL,
`updated_at` text DEFAULT (datetime('now')) NOT NULL
);
--> statement-breakpoint
CREATE INDEX `idx_flagged_content_type` ON `flagged_content` (`content_type`);
--> statement-breakpoint
CREATE INDEX `idx_flagged_content_id` ON `flagged_content` (`content_id`);

View File

@@ -0,0 +1 @@
ALTER TABLE `diseases` ADD COLUMN `prevalence_score` integer DEFAULT 0 NOT NULL;

View File

@@ -0,0 +1,340 @@
{
"version": "6",
"dialect": "sqlite",
"id": "5471dc75-3736-4b26-b7a9-0629c9b1efa0",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"diseases": {
"name": "diseases",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"plant_id": {
"name": "plant_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"scientific_name": {
"name": "scientific_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"causal_agent_type": {
"name": "causal_agent_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"symptoms": {
"name": "symptoms",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"causes": {
"name": "causes",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"treatment": {
"name": "treatment",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"prevention": {
"name": "prevention",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"lookalike_ids": {
"name": "lookalike_ids",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"severity": {
"name": "severity",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {
"idx_diseases_plant_id": {
"name": "idx_diseases_plant_id",
"columns": [
"plant_id"
],
"isUnique": false
},
"idx_diseases_causal_agent": {
"name": "idx_diseases_causal_agent",
"columns": [
"causal_agent_type"
],
"isUnique": false
},
"idx_diseases_severity": {
"name": "idx_diseases_severity",
"columns": [
"severity"
],
"isUnique": false
}
},
"foreignKeys": {
"diseases_plant_id_plants_id_fk": {
"name": "diseases_plant_id_plants_id_fk",
"tableFrom": "diseases",
"tableTo": "plants",
"columnsFrom": [
"plant_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"plants": {
"name": "plants",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"common_name": {
"name": "common_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"scientific_name": {
"name": "scientific_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"family": {
"name": "family",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"care_summary": {
"name": "care_summary",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {
"idx_plants_category": {
"name": "idx_plants_category",
"columns": [
"category"
],
"isUnique": false
},
"idx_plants_common_name": {
"name": "idx_plants_common_name",
"columns": [
"common_name"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"scrape_sources": {
"name": "scrape_sources",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"source_type": {
"name": "source_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_scraped_at": {
"name": "last_scraped_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entries_count": {
"name": "entries_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"error_message": {
"name": "error_message",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,348 @@
{
"version": "6",
"dialect": "sqlite",
"id": "6f2de82b-c1f9-42de-b03c-1c1f0c02b7c9",
"prevId": "5471dc75-3736-4b26-b7a9-0629c9b1efa0",
"tables": {
"diseases": {
"name": "diseases",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"plant_id": {
"name": "plant_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"scientific_name": {
"name": "scientific_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"causal_agent_type": {
"name": "causal_agent_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"symptoms": {
"name": "symptoms",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"causes": {
"name": "causes",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"treatment": {
"name": "treatment",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"prevention": {
"name": "prevention",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"lookalike_ids": {
"name": "lookalike_ids",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"severity": {
"name": "severity",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {
"idx_diseases_plant_id": {
"name": "idx_diseases_plant_id",
"columns": [
"plant_id"
],
"isUnique": false
},
"idx_diseases_causal_agent": {
"name": "idx_diseases_causal_agent",
"columns": [
"causal_agent_type"
],
"isUnique": false
},
"idx_diseases_severity": {
"name": "idx_diseases_severity",
"columns": [
"severity"
],
"isUnique": false
}
},
"foreignKeys": {
"diseases_plant_id_plants_id_fk": {
"name": "diseases_plant_id_plants_id_fk",
"tableFrom": "diseases",
"tableTo": "plants",
"columnsFrom": [
"plant_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"plants": {
"name": "plants",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"common_name": {
"name": "common_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"scientific_name": {
"name": "scientific_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"family": {
"name": "family",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"care_summary": {
"name": "care_summary",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {
"idx_plants_category": {
"name": "idx_plants_category",
"columns": [
"category"
],
"isUnique": false
},
"idx_plants_common_name": {
"name": "idx_plants_common_name",
"columns": [
"common_name"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"scrape_sources": {
"name": "scrape_sources",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"source_type": {
"name": "source_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_scraped_at": {
"name": "last_scraped_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entries_count": {
"name": "entries_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"error_message": {
"name": "error_message",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,347 @@
{
"version": "6",
"dialect": "sqlite",
"id": "7a3efb1c-d2e3-4f56-a789-0b1234567890",
"prevId": "6f2de82b-c1f9-42de-b03c-1c1f0c02b7c9",
"tables": {
"diseases": {
"name": "diseases",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"plant_id": {
"name": "plant_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"scientific_name": {
"name": "scientific_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"causal_agent_type": {
"name": "causal_agent_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"symptoms": {
"name": "symptoms",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"causes": {
"name": "causes",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"treatment": {
"name": "treatment",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"prevention": {
"name": "prevention",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"lookalike_ids": {
"name": "lookalike_ids",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"severity": {
"name": "severity",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"prevalence": {
"name": "prevalence",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'uncommon'"
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {
"idx_diseases_plant_id": {
"name": "idx_diseases_plant_id",
"columns": ["plant_id"],
"isUnique": false
},
"idx_diseases_causal_agent": {
"name": "idx_diseases_causal_agent",
"columns": ["causal_agent_type"],
"isUnique": false
},
"idx_diseases_severity": {
"name": "idx_diseases_severity",
"columns": ["severity"],
"isUnique": false
},
"idx_diseases_prevalence": {
"name": "idx_diseases_prevalence",
"columns": ["prevalence"],
"isUnique": false
}
},
"foreignKeys": {
"diseases_plant_id_plants_id_fk": {
"name": "diseases_plant_id_plants_id_fk",
"tableFrom": "diseases",
"tableTo": "plants",
"columnsFrom": ["plant_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"plants": {
"name": "plants",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"common_name": {
"name": "common_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"scientific_name": {
"name": "scientific_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"family": {
"name": "family",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"care_summary": {
"name": "care_summary",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {
"idx_plants_category": {
"name": "idx_plants_category",
"columns": ["category"],
"isUnique": false
},
"idx_plants_common_name": {
"name": "idx_plants_common_name",
"columns": ["common_name"],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"scrape_sources": {
"name": "scrape_sources",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"source_type": {
"name": "source_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_scraped_at": {
"name": "last_scraped_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entries_count": {
"name": "entries_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"error_message": {
"name": "error_message",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,410 @@
{
"version": "6",
"dialect": "sqlite",
"id": "04ff83bd-e207-44d3-b8b7-8f82157bbeb8",
"prevId": "7a3efb1c-d2e3-4f56-a789-0b1234567890",
"tables": {
"diseases": {
"name": "diseases",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"plant_id": {
"name": "plant_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"scientific_name": {
"name": "scientific_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"causal_agent_type": {
"name": "causal_agent_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"symptoms": {
"name": "symptoms",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"causes": {
"name": "causes",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"treatment": {
"name": "treatment",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"prevention": {
"name": "prevention",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"lookalike_ids": {
"name": "lookalike_ids",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"prevalence": {
"name": "prevalence",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'uncommon'"
},
"severity": {
"name": "severity",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {
"idx_diseases_plant_id": {
"name": "idx_diseases_plant_id",
"columns": [
"plant_id"
],
"isUnique": false
},
"idx_diseases_causal_agent": {
"name": "idx_diseases_causal_agent",
"columns": [
"causal_agent_type"
],
"isUnique": false
},
"idx_diseases_severity": {
"name": "idx_diseases_severity",
"columns": [
"severity"
],
"isUnique": false
},
"idx_diseases_prevalence": {
"name": "idx_diseases_prevalence",
"columns": [
"prevalence"
],
"isUnique": false
}
},
"foreignKeys": {
"diseases_plant_id_plants_id_fk": {
"name": "diseases_plant_id_plants_id_fk",
"tableFrom": "diseases",
"tableTo": "plants",
"columnsFrom": [
"plant_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"plant_views": {
"name": "plant_views",
"columns": {
"plant_id": {
"name": "plant_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"view_count": {
"name": "view_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {
"idx_plant_views_count": {
"name": "idx_plant_views_count",
"columns": [
"view_count"
],
"isUnique": false
}
},
"foreignKeys": {
"plant_views_plant_id_plants_id_fk": {
"name": "plant_views_plant_id_plants_id_fk",
"tableFrom": "plant_views",
"tableTo": "plants",
"columnsFrom": [
"plant_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"plants": {
"name": "plants",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"common_name": {
"name": "common_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"scientific_name": {
"name": "scientific_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"family": {
"name": "family",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"care_summary": {
"name": "care_summary",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {
"idx_plants_category": {
"name": "idx_plants_category",
"columns": [
"category"
],
"isUnique": false
},
"idx_plants_common_name": {
"name": "idx_plants_common_name",
"columns": [
"common_name"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"scrape_sources": {
"name": "scrape_sources",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"source_type": {
"name": "source_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_scraped_at": {
"name": "last_scraped_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entries_count": {
"name": "entries_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"error_message": {
"name": "error_message",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,469 @@
{
"version": "6",
"dialect": "sqlite",
"id": "04ff83bd-e207-44d3-b8b7-8f82157bbeb9",
"prevId": "04ff83bd-e207-44d3-b8b7-8f82157bbeb8",
"tables": {
"diseases": {
"name": "diseases",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"plant_id": {
"name": "plant_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"scientific_name": {
"name": "scientific_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"causal_agent_type": {
"name": "causal_agent_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"symptoms": {
"name": "symptoms",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"causes": {
"name": "causes",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"treatment": {
"name": "treatment",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"prevention": {
"name": "prevention",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"lookalike_ids": {
"name": "lookalike_ids",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"prevalence": {
"name": "prevalence",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'uncommon'"
},
"severity": {
"name": "severity",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {
"idx_diseases_plant_id": {
"name": "idx_diseases_plant_id",
"columns": ["plant_id"],
"isUnique": false
},
"idx_diseases_causal_agent": {
"name": "idx_diseases_causal_agent",
"columns": ["causal_agent_type"],
"isUnique": false
},
"idx_diseases_severity": {
"name": "idx_diseases_severity",
"columns": ["severity"],
"isUnique": false
},
"idx_diseases_prevalence": {
"name": "idx_diseases_prevalence",
"columns": ["prevalence"],
"isUnique": false
}
},
"foreignKeys": {
"diseases_plant_id_plants_id_fk": {
"name": "diseases_plant_id_plants_id_fk",
"tableFrom": "diseases",
"tableTo": "plants",
"columnsFrom": ["plant_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"flagged_content": {
"name": "flagged_content",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"content_type": {
"name": "content_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content_id": {
"name": "content_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"field_name": {
"name": "field_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "''"
},
"flag_count": {
"name": "flag_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {
"idx_flagged_content_type": {
"name": "idx_flagged_content_type",
"columns": ["content_type"],
"isUnique": false
},
"idx_flagged_content_id": {
"name": "idx_flagged_content_id",
"columns": ["content_id"],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"plant_views": {
"name": "plant_views",
"columns": {
"plant_id": {
"name": "plant_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"view_count": {
"name": "view_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {
"idx_plant_views_count": {
"name": "idx_plant_views_count",
"columns": ["view_count"],
"isUnique": false
}
},
"foreignKeys": {
"plant_views_plant_id_plants_id_fk": {
"name": "plant_views_plant_id_plants_id_fk",
"tableFrom": "plant_views",
"tableTo": "plants",
"columnsFrom": ["plant_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"plants": {
"name": "plants",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"common_name": {
"name": "common_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"scientific_name": {
"name": "scientific_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"family": {
"name": "family",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"care_summary": {
"name": "care_summary",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {
"idx_plants_category": {
"name": "idx_plants_category",
"columns": ["category"],
"isUnique": false
},
"idx_plants_common_name": {
"name": "idx_plants_common_name",
"columns": ["common_name"],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"scrape_sources": {
"name": "scrape_sources",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"source_type": {
"name": "source_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_scraped_at": {
"name": "last_scraped_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entries_count": {
"name": "entries_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"error_message": {
"name": "error_message",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,48 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1780704072268,
"tag": "0000_flippant_talon",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1780710023177,
"tag": "0001_add-disease-images",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1749268800000,
"tag": "0002_add-prevalence",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1749268800000,
"tag": "0003_giant_toad",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1751846400000,
"tag": "0004_add-flagged-content",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1751846400000,
"tag": "0005_add-prevalence-score",
"breakpoints": true
}
]
}

18
eslint.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

39
next.config.ts Normal file
View File

@@ -0,0 +1,39 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Allow remote images from Wikimedia Commons
images: {
remotePatterns: [
{
protocol: "https",
hostname: "upload.wikimedia.org",
port: "",
pathname: "/wikipedia/commons/**",
search: "",
},
],
},
// Turbopack config (Next.js 16 default)
turbopack: {
resolveAlias: {
// Optional ML backends — not installed, dynamic import fallback to mock
"@tensorflow/tfjs": "./src/stubs/empty.ts",
"@tensorflow/tfjs-node": "./src/stubs/empty.ts",
"onnxruntime-node": "./src/stubs/empty.ts",
},
},
// Webpack config (fallback)
webpack: (config) => {
config.resolve.alias = {
...config.resolve.alias,
sharp: false,
"detect-libc": false,
"@tensorflow/tfjs": false,
"@tensorflow/tfjs-node": false,
"onnxruntime-node": false,
};
return config;
},
};
export default nextConfig;

11366
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

48
package.json Normal file
View File

@@ -0,0 +1,48 @@
{
"name": "plant-disease-id",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"test": "vitest run",
"test:watch": "vitest",
"flagged-report": "npx tsx scripts/generate-flagged-report.ts",
"flagged-report:all": "npx tsx scripts/generate-flagged-report.ts --min-flags=1",
"migrate:flag-system": "npx tsx scripts/apply-flag-migration.ts"
},
"dependencies": {
"@libsql/client": "^0.17.3",
"@mudbill/duckduckgo-images-api": "^2.0.1",
"@tensorflow/tfjs": "^4.22.0",
"@tensorflow/tfjs-node": "^4.22.0",
"dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2",
"next": "16.2.7",
"react": "19.2.4",
"react-dom": "19.2.4",
"sharp": "^0.34.5",
"uuid": "^14.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/better-sqlite3": "^7.6.13",
"@types/jsdom": "^28.0.3",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/uuid": "^10.0.0",
"@vitest/coverage-v8": "^4.1.8",
"drizzle-kit": "^0.31.10",
"eslint": "^9",
"eslint-config-next": "16.2.7",
"jsdom": "^29.1.1",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^4.1.8"
}
}

7
postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

0
public/models/.gitkeep Normal file
View File

1
public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,53 @@
/**
* apply-flag-migration.ts
*
* Applies the flagged_content table migration to Turso.
* Run with: npx tsx scripts/apply-flag-migration.ts
*/
import dotenv from "dotenv";
import path from "node:path";
const envFile =
process.env.NODE_ENV === "production" ? "../.env.production" : "../.env.development";
dotenv.config({ path: path.resolve(__dirname, envFile) });
import { createClient } from "@libsql/client";
async function main() {
const db = createClient({
url: process.env.DATABASE_URL!,
authToken: process.env.DATABASE_TOKEN!,
});
console.log("Applying migration: create flagged_content table...");
await db.execute(`
CREATE TABLE IF NOT EXISTS flagged_content (
id text PRIMARY KEY NOT NULL,
content_type text NOT NULL,
content_id text NOT NULL,
field_name text NOT NULL,
notes text DEFAULT '',
flag_count integer DEFAULT 1 NOT NULL,
created_at text DEFAULT (datetime('now')) NOT NULL,
updated_at text DEFAULT (datetime('now')) NOT NULL
)
`);
await db.execute(`
CREATE INDEX IF NOT EXISTS idx_flagged_content_type ON flagged_content (content_type)
`);
await db.execute(`
CREATE INDEX IF NOT EXISTS idx_flagged_content_id ON flagged_content (content_id)
`);
console.log("Migration applied successfully.");
db.close();
}
main().catch((err) => {
console.error("Migration failed:", err);
process.exit(1);
});

View File

@@ -0,0 +1,23 @@
import "dotenv/config";
import { createClient } from "@libsql/client";
async function main() {
const db = createClient({
url: process.env.DATABASE_URL!,
authToken: process.env.DATABASE_TOKEN!,
});
console.log("Applying migration: add image_url to diseases...");
await db.execute("ALTER TABLE diseases ADD COLUMN image_url TEXT DEFAULT ''");
await db.execute("UPDATE diseases SET image_url = '' WHERE image_url IS NULL");
// Mark migration as applied
await db.execute(
"INSERT INTO __drizzle_migrations (hash, created_at) VALUES ('0001_add-disease-images', datetime('now'))",
);
console.log("Migration applied successfully.");
db.close();
}
main().catch(console.error);

View File

@@ -0,0 +1,19 @@
import { createClient } from "@libsql/client";
const c = createClient({
url: process.env.DATABASE_URL,
authToken: process.env.DATABASE_TOKEN,
});
const r = await c.execute("SELECT COUNT(*) as cnt FROM diseases");
const r2 = await c.execute(
`SELECT SUM(CASE WHEN image_url IS NOT NULL AND image_url != '' THEN 1 ELSE 0 END) as has, SUM(CASE WHEN image_url IS NULL OR image_url = '' THEN 1 ELSE 0 END) as miss FROM diseases`,
);
const r3 = await c.execute(
`SELECT severity, COUNT(*) as total, SUM(CASE WHEN image_url IS NOT NULL AND image_url != '' THEN 1 ELSE 0 END) as has FROM diseases GROUP BY severity ORDER BY severity`,
);
console.log(
`Total: ${r.rows[0].cnt} | With images: ${r2.rows[0].has} | Missing: ${r2.rows[0].miss}`,
);
for (const row of r3.rows) {
console.log(` ${row.severity?.padEnd(10)}: ${row.has}/${row.total}`);
}
c.close();

View File

@@ -0,0 +1,296 @@
#!/usr/bin/env python3
"""
Inspect and convert a .keras plant disease model to TF.js GraphModel format.
Uses tensorflowjs_converter CLI to avoid Keras version deserialization issues.
Usage:
pip3 install tensorflowjs # also pulls tensorflow as dependency
python3 scripts/convert-keras-to-tfjs.py
"""
import json
import os
import shutil
import subprocess
import sys
MODEL_PATH = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"public",
"models",
"plant-disease-classifier",
"best_mnv2_pv_original.keras",
)
OUTPUT_DIR = os.path.join(
os.path.dirname(MODEL_PATH),
"tfjs_model",
)
def inspect_keras_metadata():
"""Read .keras archive metadata without loading the model."""
print("=" * 60)
print("MODEL INSPECTION (metadata only)")
print("=" * 60)
try:
import zipfile
except ImportError:
print("ERROR: zipfile not available")
sys.exit(1)
if not os.path.exists(MODEL_PATH):
print(f"ERROR: Model not found at {MODEL_PATH}")
sys.exit(1)
print(f"\nModel file: {MODEL_PATH}")
print(
f"File size: {os.path.getsize(MODEL_PATH):,} bytes ({os.path.getsize(MODEL_PATH) / 1024 / 1024:.1f} MB)"
)
# .keras files are ZIP archives
with zipfile.ZipFile(MODEL_PATH) as zf:
names = zf.namelist()
print(f"\nArchive contents ({len(names)} entries):")
for name in names:
info = zf.getinfo(name)
print(f" {name:<40s} {info.file_size:>10,} bytes")
# Read config.json for model architecture info
config_path = None
for name in names:
if name.endswith("config.json"):
config_path = name
break
if config_path:
print(f"\nReading {config_path}...")
with zf.open(config_path) as f:
config = json.load(f)
# Extract key info
model_type = config.get("class_name", "unknown")
print(f"Model class: {model_type}")
# Try to find output layer info
if "config" in config:
inner_config = config["config"]
# Look for output shape in config
if "output_shape" in inner_config:
print(f"Output shape: {inner_config['output_shape']}")
# Look through layers for the final dense layer
if "layers" in inner_config:
layers = inner_config["layers"]
print(f"\nLayers ({len(layers)} total):")
for layer in layers:
layer_name = layer.get("config", {}).get("name", "?")
layer_class = layer.get("class_name", "?")
layer_module = layer.get("module", "?")
# Extract units/activation for dense layers
layer_config = layer.get("config", {})
units = layer_config.get("units")
activation = layer_config.get("activation")
detail = ""
if units:
detail = f" units={units}"
if activation:
detail += f" activation={activation}"
print(f" {layer_name:<30s} {layer_class:<20s}{detail}")
# Find last dense layer for class count
for layer in reversed(layers):
if layer.get("class_name") == "Dense":
units = layer.get("config", {}).get("units")
activation = layer.get("config", {}).get("activation")
print("\nClassification head:")
print(f" Units (classes): {units}")
print(f" Activation: {activation}")
print(
f" Layer name: {layer.get('config', {}).get('name', '?')}"
)
break
# Check compile config
if "compile_config" in config:
compile_cfg = config["compile_config"]
optimizer = compile_cfg.get("optimizer", {})
if isinstance(optimizer, dict):
opt_name = optimizer.get("class_name", "?")
lr = optimizer.get("config", {}).get("learning_rate")
print("\nTraining config:")
print(f" Optimizer: {opt_name}")
if lr:
print(f" Learning rate: {lr}")
loss = compile_cfg.get("loss", "?")
metrics = compile_cfg.get("metrics", [])
print(f" Loss: {loss}")
print(f" Metrics: {metrics}")
# Check input shape
if "build_config" in config:
build_cfg = config["build_config"]
if "input_shape" in build_cfg:
print(f"\nInput shape: {build_cfg['input_shape']}")
def convert_to_tfjs():
"""Convert using tensorflowjs_converter CLI."""
print("\n" + "=" * 60)
print("CONVERTING TO TF.JS GRAPH MODEL")
print("=" * 60)
# Check tensorflowjs_converter CLI is available
converter = shutil.which("tensorflowjs_converter")
if not converter:
print("ERROR: tensorflowjs_converter not found in PATH.")
print(" pip3 install tensorflowjs")
sys.exit(1)
# Clean output dir
if os.path.exists(OUTPUT_DIR):
print(f"Removing existing output dir: {OUTPUT_DIR}")
shutil.rmtree(OUTPUT_DIR)
os.makedirs(OUTPUT_DIR, exist_ok=True)
print(f"\nConverting {MODEL_PATH} -> {OUTPUT_DIR}/")
print("(this may take a minute...)")
# Use the venv's python to run the converter (avoids import issues)
python_exe = sys.executable # the python running this script
result = subprocess.run(
[
python_exe,
"-m",
"tensorflowjs.converters.converter",
"--input_format=keras",
"--output_format=tfjs_graph_model",
MODEL_PATH,
OUTPUT_DIR,
],
capture_output=True,
text=True,
timeout=300,
)
if result.returncode != 0:
print("\nERROR: Conversion failed!")
print(f"stdout: {result.stdout}")
print(f"stderr: {result.stderr}")
sys.exit(1)
if result.stdout:
print(result.stdout)
if result.stderr:
# Some warnings are normal
print(f"Converter output: {result.stderr}")
# Verify output
model_json_path = os.path.join(OUTPUT_DIR, "model.json")
if not os.path.exists(model_json_path):
print("ERROR: Conversion did not produce model.json")
sys.exit(1)
# List output files
files = os.listdir(OUTPUT_DIR)
total_size = sum(
os.path.getsize(os.path.join(OUTPUT_DIR, f))
for f in files
if os.path.isfile(os.path.join(OUTPUT_DIR, f))
)
print("\nConversion complete!")
print(f"Output directory: {OUTPUT_DIR}/")
print(f"Files: {len(files)}")
for f in sorted(files):
fpath = os.path.join(OUTPUT_DIR, f)
if os.path.isfile(fpath):
size = os.path.getsize(fpath)
print(f" {f:<30s} {size:>10,} bytes")
print(f"Total size: {total_size:,} bytes ({total_size / 1024 / 1024:.1f} MB)")
# Read model.json to check config
with open(model_json_path) as f:
model_json = json.load(f)
print(f"\nTF.js model format: {model_json.get('format', 'unknown')}")
print(f"Generated by: {model_json.get('generatedBy', 'unknown')}")
# Inspect model topology
if "modelTopology" in model_json:
topology = model_json["modelTopology"]
print("\nModel topology:")
print(f" Name: {topology.get('model_name', 'unnamed')}")
print(f" Ops: {len(topology.get('node', []))} nodes")
# Input/output nodes
inputs = topology.get("inputs", {})
outputs = topology.get("outputs", {})
print(f" Inputs: {list(inputs.keys())}")
for name, info in inputs.items():
shape = info.get("tensorShape", {})
print(f" {name}: shape={shape.get('dim', 'unknown')}")
print(f" Outputs: {list(outputs.keys())}")
for name, info in outputs.items():
shape = info.get("tensorShape", {})
print(f" {name}: shape={shape.get('dim', 'unknown')}")
# Check weights specification
if "weightsManifest" in model_json:
manifest = model_json["weightsManifest"]
print(f"\nWeight manifests: {len(manifest)}")
for i, m in enumerate(manifest):
shards = m.get("shards", [])
print(f" Manifest {i}: {len(shards)} shard(s)")
return OUTPUT_DIR
def main():
if not os.path.exists(MODEL_PATH):
print(f"ERROR: Model not found at {MODEL_PATH}")
sys.exit(1)
# Step 1: Inspect metadata
inspect_keras_metadata()
# Step 2: Convert
output_dir = convert_to_tfjs()
# Step 3: Summary
print("\n" + "=" * 60)
print("NEXT STEPS")
print("=" * 60)
print(f"""
1. Move the TF.js model to the expected location:
The model-loader expects model.json at:
public/models/plant-disease-classifier/model.json
Move files:
mv {output_dir}/model.json public/models/plant-disease-classifier/
mv {output_dir}/group1-shard* public/models/plant-disease-classifier/
2. IMPORTANT: This model has 38 output classes (original PlantVillage).
Your labels.ts expects 95 classes (93 diseases + healthy + unknown).
You'll need to either:
a) Fine-tune the model with your 95-class dataset, OR
b) Map the 38 PlantVillage classes to your disease IDs
3. Install @tensorflow/tfjs in your project:
npm install @tensorflow/tfjs
4. Test with your API:
npm run dev
POST /api/identify with an uploaded image
""")
if __name__ == "__main__":
main()

2337
scripts/disease-templates.ts Normal file

File diff suppressed because it is too large Load Diff

691
scripts/expand-diseases.ts Normal file
View File

@@ -0,0 +1,691 @@
/**
* Expand DB with comprehensive plant disease list from Wikipedia.
*
* Reads /tmp/plant_diseases/plant_diseases_comprehensive.txt,
* compares against existing DB entries (by name, case-insensitive),
* and inserts new entries with reasonable defaults.
*
* Usage:
* cd apps/web && export $(grep -v '^#' .env.development | xargs) && npx tsx scripts/expand-diseases.ts
*/
import "dotenv/config";
import { readFileSync } from "fs";
import { eq, sql } from "drizzle-orm";
import { getDb, closeDb } from "../src/lib/db/index";
import { plants, diseases } from "../src/lib/db/schema";
import type { CausalAgentType, Severity } from "../src/lib/types";
// ─── Parse the comprehensive list ─────────────────────────────────────────────
interface DiseaseEntry {
name: string;
sourceUrl: string;
}
function parseComprehensiveList(filePath: string): DiseaseEntry[] {
const content = readFileSync(filePath, "utf-8");
const entries: DiseaseEntry[] = [];
const lines = content.split("\n");
const nameRe = /^\d+\.\s+(.+)$/;
for (let i = 0; i < lines.length; i++) {
const nameMatch = lines[i].match(nameRe);
if (nameMatch) {
const name = nameMatch[1].trim();
const urlLine = lines[i + 1]?.trim() || "";
// Only add if the next line is a valid URL
if (urlLine.startsWith("http")) {
entries.push({ name, sourceUrl: urlLine });
i++; // skip the URL line
} else {
entries.push({ name, sourceUrl: "" });
}
}
}
return entries;
}
// ─── Infer causal agent type from disease name ────────────────────────────────
function inferCausalAgent(name: string): CausalAgentType {
const lower = name.toLowerCase();
// Bacterial indicators
if (
lower.startsWith("bacterial ") ||
lower.includes(" xanthomonas") ||
lower.includes(" pseudomonas") ||
lower.includes(" erwinia") ||
lower.includes(" ralstonia") ||
lower.includes(" clavibacter") ||
lower.includes(" streptomyces") ||
lower.includes(" agrobacterium") ||
lower.includes(" corynebacterium") ||
lower.includes(" pectobacterium") ||
lower.includes(" dickeya")
) {
return "bacterial";
}
// Viral indicators - strong signals
if (
lower.includes(" mosaic") ||
lower.includes(" yellows") ||
lower.includes(" leaf roll") ||
lower.includes(" leafroll") ||
lower.includes(" ringspot") ||
lower.includes(" ring spot") ||
lower.includes(" enation") ||
lower.includes(" phyllody") ||
lower.includes(" witches") ||
lower.includes(" witches'") ||
lower.includes(" crinkle") ||
lower.includes(" rosette") ||
lower.includes(" shoestring") ||
lower.includes(" tristeza") ||
lower.includes(" psorosis") ||
lower.includes(" stubborn") ||
lower.includes(" greening") ||
lower.includes(" vein banding") ||
lower.includes(" vein mottle") ||
lower.includes(" vein clearing") ||
lower.includes(" leaf pucker") ||
lower.includes(" pucker leaf") ||
lower.includes(" latent") ||
lower.includes(" motley") ||
lower.includes(" rugose")
) {
return "viral";
}
// Viral - names containing "virus" or "viroid"
if (lower.includes(" virus") || lower.includes(" viroid") || lower.includes(" virosis")) {
return "viral";
}
// Nematodes
if (
lower.includes(" nematode") ||
lower.includes(" nematodes") ||
lower.includes(" eelworm") ||
lower.includes(" root knot") ||
lower.includes(" root-knot") ||
lower.includes(" cyst ") ||
lower.includes(" dagger ") ||
lower.includes(" lance ") ||
lower.includes(" lesion ") ||
lower.includes(" ring ") ||
lower.includes(" spiral ") ||
lower.includes(" sting ") ||
lower.includes(" stubby ") ||
lower.includes(" needle ") ||
lower.includes(" foliar ") ||
lower.includes(" bulb ") ||
lower.includes(" reniform ") ||
lower.includes(" burrowing ")
) {
// Check if it's really a nematode name
if (lower.includes("nematode")) return "environmental";
}
// Fungal indicators
if (
lower.includes(" mildew") ||
lower.includes(" rust") ||
lower.includes(" smut") ||
lower.includes(" blight") ||
lower.includes(" canker") ||
lower.includes(" rot") ||
lower.includes(" scab") ||
lower.includes(" mold") ||
lower.includes(" anthracnose") ||
lower.includes(" bunt") ||
lower.includes(" ergot") ||
lower.includes(" dieback") ||
lower.includes(" scald") ||
lower.includes(" blotch") ||
lower.includes(" speckle") ||
lower.includes(" sooty") ||
lower.includes(" flyspeck") ||
lower.includes(" fusarium") ||
lower.includes(" alternaria") ||
lower.includes(" botrytis") ||
lower.includes(" rhizoctonia") ||
lower.includes(" pythium") ||
lower.includes(" phytophthora") ||
lower.includes(" sclerotinia") ||
lower.includes(" verticillium") ||
lower.includes(" ascochyta") ||
lower.includes(" cercospora") ||
lower.includes(" septoria") ||
lower.includes(" colletotrichum") ||
lower.includes(" phomopsis") ||
lower.includes(" diaporthe") ||
lower.includes(" diplodia") ||
lower.includes(" macrophomina") ||
lower.includes(" cylindrocladium") ||
lower.includes(" mycosphaerella") ||
lower.includes(" helminthosporium") ||
lower.includes(" curvularia") ||
lower.includes(" bipolaris") ||
lower.includes(" exserohilum") ||
lower.includes(" dothiorella") ||
lower.includes(" fusicoccum") ||
lower.includes(" pestalotia") ||
lower.includes(" glomerella") ||
lower.includes(" nectria") ||
lower.includes(" eutypa") ||
lower.includes(" armillaria") ||
lower.includes(" ganoderma") ||
lower.includes(" phoma") ||
lower.includes(" cladosporium") ||
lower.includes(" penicillium") ||
lower.includes(" aspergillus") ||
lower.includes(" rhizopus") ||
lower.includes(" mucor") ||
lower.includes(" downy mildew") ||
lower.includes(" powdery mildew") ||
lower.includes(" pink rot") ||
lower.includes(" pink mold") ||
lower.includes(" pink root") ||
lower.includes(" gray mold") ||
lower.includes(" grey mold") ||
lower.includes(" white rot") ||
lower.includes(" white mold") ||
lower.includes(" brown rot") ||
lower.includes(" black rot") ||
lower.includes(" soft rot") ||
lower.includes(" dry rot") ||
lower.includes(" fruit rot") ||
lower.includes(" root rot") ||
lower.includes(" stem rot") ||
lower.includes(" ear rot") ||
lower.includes(" crown rot") ||
lower.includes(" collar rot") ||
lower.includes(" pod rot") ||
lower.includes(" kernel rot") ||
lower.includes(" stalk rot") ||
lower.includes(" head rot") ||
lower.includes(" butt rot") ||
lower.includes(" stump rot") ||
lower.includes(" wood rot") ||
lower.includes(" seed rot") ||
lower.includes(" leaf spot") ||
lower.includes(" leaf blight") ||
lower.includes(" leaf blotch") ||
lower.includes(" leaf rust") ||
lower.includes(" brown spot") ||
lower.includes(" black spot") ||
lower.includes(" black leg") ||
lower.includes(" blackleg") ||
lower.includes(" black foot") ||
lower.includes(" white rust") ||
lower.includes(" white smut") ||
lower.includes(" white scab") ||
lower.includes(" tar spot") ||
lower.includes(" target spot") ||
lower.includes(" dollar spot") ||
lower.includes(" fairy ring") ||
lower.includes(" snow mold") ||
lower.includes(" pink disease") ||
lower.includes(" thread blight") ||
lower.includes(" web blight") ||
lower.includes(" sclerotial") ||
lower.includes(" sore shin") ||
lower.includes(" wart") ||
lower.includes(" scurf") ||
lower.includes(" silver scurf") ||
lower.includes(" shot hole") ||
lower.includes(" timber rot") ||
lower.includes(" cottony rot") ||
lower.includes(" watery rot") ||
lower.includes(" sour rot") ||
lower.includes(" seepage") ||
lower.includes(" bunch rot") ||
lower.includes(" noble rot") ||
lower.includes(" bitter rot") ||
lower.includes(" ripe rot") ||
lower.includes(" ring rot") ||
lower.includes(" coral spot") ||
lower.includes(" stem canker") ||
lower.includes(" branch canker") ||
lower.includes(" perennial canker") ||
lower.includes(" brand canker") ||
lower.includes(" blister canker") ||
lower.includes(" bleeding canker") ||
lower.includes(" bark canker") ||
lower.includes(" gum canker") ||
lower.includes(" collar crack") ||
lower.includes(" fasciation") ||
lower.includes(" exobasidium") ||
lower.includes(" mycorrhiza") ||
lower.includes(" lichen") ||
lower.includes(" algal") ||
lower.includes(" chlorosis") ||
lower.includes(" leaf blister") ||
lower.includes(" leaf curl")
) {
return "fungal";
}
// Physiological / environmental indicators
if (
lower.includes(" sunscald") ||
lower.includes(" sunburn") ||
lower.includes(" chilling") ||
lower.includes(" blossom end rot") ||
lower.includes(" edema") ||
lower.includes(" deficiency") ||
lower.includes(" toxicity") ||
lower.includes(" ozone") ||
lower.includes(" drought") ||
lower.includes(" frost") ||
lower.includes(" herbicide") ||
lower.includes(" pesticide") ||
lower.includes(" phytotoxicity") ||
lower.includes(" catface") ||
lower.includes(" fruit cracking") ||
lower.includes(" russeting") ||
lower.includes(" growth crack") ||
lower.includes(" mealiness") ||
lower.includes(" wind scar") ||
lower.includes(" hail") ||
lower.includes(" salt ") ||
lower.includes(" nutritional") ||
lower.includes(" mineral") ||
lower.includes(" overwatering") ||
lower.includes(" under watering") ||
lower.includes(" waterlogging") ||
lower.includes(" chemical injury") ||
lower.includes(" spray injury") ||
lower.includes(" fertilizer burn") ||
lower.includes(" lightning") ||
lower.includes(" bruising") ||
lower.includes(" pressure bruise") ||
lower.includes(" impact damage") ||
lower.includes(" transit rot")
) {
return "environmental";
}
// Insect/mite/pest indicators
if (
lower.includes(" mite") ||
lower.includes(" beetle") ||
lower.includes(" weevil") ||
lower.includes(" aphid") ||
lower.includes(" bollworm") ||
lower.includes(" leaf miner") ||
lower.includes(" mealybug") ||
lower.includes(" thrips") ||
lower.includes(" whitefly") ||
lower.includes(" caterpillar") ||
lower.includes(" sawfly") ||
lower.includes(" scale ") ||
lower.includes(" leafhopper") ||
lower.includes(" psylla") ||
lower.includes(" slug") ||
lower.includes(" snail") ||
lower.includes(" borer") ||
lower.includes(" maggot") ||
lower.includes(" grub") ||
lower.includes(" earwig") ||
lower.includes(" grasshopper")
) {
return "environmental";
}
// Fungal genus names
const fungalGenera = [
"armillaria",
"aspergillus",
"alternaria",
"botrytis",
"cercospora",
"cladosporium",
"colletotrichum",
"curvularia",
"cylindrocladium",
"diplodia",
"fusarium",
"ganoderma",
"glomerella",
"helminthosporium",
"macrophomina",
"mycosphaerella",
"nectria",
"penicillium",
"pestalotia",
"phoma",
"phomopsis",
"phytophthora",
"pythium",
"rhizoctonia",
"sclerotinia",
"septoria",
"verticillium",
"ascochyta",
"cercoseptoria",
"phaeoisariopsis",
"phaeoseptoria",
"stagonospora",
"stemphylium",
"myrothecium",
"myriogenospora",
"dactuliophora",
"dilophospora",
"coniothecium",
"coniosporium",
"cryptostictis",
"catacauma",
"botryodiplodia",
"botryosphaeria",
"cephalosporium",
"ceratocystis",
"chalara",
"choanephora",
"clitocybe",
"coprinus",
"cordana",
"corticium",
"corynespora",
"coryneum",
"cylindrocarpon",
"cylindrocladiella",
"cylindrosporium",
"cytospora",
"cytosporina",
"dematophora",
"didymella",
"dothiorella",
"drechslera",
"endothia",
"eutypa",
"eutypella",
"exobasidium",
"fusicladium",
"fusicoccum",
"gibberella",
"glomerella",
"gnomonia",
"graphiola",
"guignardia",
"hendersonia",
"hendersonula",
"hymenochaete",
"hypoxylon",
"lasiodiplodia",
"leptosphaeria",
"leucostoma",
"lophodermium",
"macrophoma",
"marasmiellus",
"marasmius",
"massaria",
"monilia",
"monosporascus",
"mystrosporium",
"neocosmospora",
"nigrospora",
"omphalia",
"ophiobolus",
"ovulinia",
"ozonium",
"panagrolaimus",
"periconia",
"pestalosphaeria",
"pestalotiopsis",
"phialophora",
"phymatotrichum",
"physalospora",
"phytophthora",
"plasmodiophora",
"plectosporium",
"polyporus",
"poria",
"pseudocercosporella",
"pseudopeziza",
"pseudoseptoria",
"puccinia",
"pyrenochaeta",
"pythium",
"ramularia",
"rhizoctonia",
"rhizopus",
"rhynchosporium",
"rosellinia",
"sclerophthora",
"sclerotinia",
"sclerotium",
"septoria",
"sphaceloma",
"sphaeropsis",
"spongospora",
"stagonospora",
"stemphylium",
"stereum",
"stigmina",
"thanatephorus",
"thielaviopsis",
"tippula",
"typhula",
"ulocladium",
"uredo",
"ustilago",
"valsa",
"venturia",
"verticillium",
"xylaria",
];
for (const genus of fungalGenera) {
if (lower.includes(genus)) return "fungal";
}
// Default to fungal (most plant diseases are fungal)
return "fungal";
}
// ─── Infer severity ───────────────────────────────────────────────────────────
function inferSeverity(name: string): Severity {
const lower = name.toLowerCase();
if (
lower.includes(" lethal") ||
lower.includes(" devastating") ||
lower.includes(" destructive") ||
lower.includes(" fatal") ||
lower.includes(" severe") ||
lower.includes(" blight") ||
lower.includes(" wilt") ||
lower.includes(" canker") ||
lower.includes(" dieback") ||
lower.includes(" decline") ||
lower.includes(" rot") ||
lower.includes(" gall") ||
lower.includes(" gummosis") ||
lower.includes(" necrosis") ||
lower.includes(" erwinia")
) {
return "high";
}
if (
lower.includes(" minor") ||
lower.includes(" mild") ||
lower.includes(" slight") ||
lower.includes(" speckle") ||
lower.includes(" fleck") ||
lower.includes(" freckle") ||
lower.includes(" chlorosis") ||
lower.includes(" translucence") ||
lower.includes(" superficial")
) {
return "low";
}
return "moderate";
}
// ─── Generate a deterministic slug ────────────────────────────────────────────
function toSlug(name: string): string {
return (
"wiki-" +
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.replace(/-+/g, "-")
);
}
// ─── Main ─────────────────────────────────────────────────────────────────────
async function main() {
const db = getDb();
// 1. Get existing disease names from DB
const existingDiseases = await db.select({ name: diseases.name }).from(diseases);
const existingNames = new Set(existingDiseases.map((d) => d.name.toLowerCase().trim()));
console.log(`Existing diseases in DB: ${existingNames.size}`);
// 2. Parse the comprehensive list
const entries = parseComprehensiveList("/tmp/plant_diseases/plant_diseases_comprehensive.txt");
console.log(`Total entries in comprehensive file: ${entries.length}`);
// 3. Find or create catch-all plants
for (const plantId of ["general", "unknown"]) {
const existing = await db.select().from(plants).where(eq(plants.id, plantId)).get();
if (!existing) {
console.log(`Creating '${plantId}' plant for catch-all diseases...`);
await db.insert(plants).values({
id: plantId,
commonName: plantId === "general" ? "General (Multiple Plants)" : "Unknown Plant",
scientificName: "Various",
family: "Various",
category: "houseplant",
careSummary:
plantId === "general"
? "General plant diseases affecting multiple species."
: "Plant disease with unknown host plant.",
imageUrl: "",
});
console.log(`Created '${plantId}' plant.`);
}
}
// 4. Filter new entries (deduplicate within file + against DB)
const newEntries: DiseaseEntry[] = [];
const skipped: string[] = [];
const seen = new Set<string>();
for (const entry of entries) {
const key = entry.name.toLowerCase().trim();
if (seen.has(key)) continue;
seen.add(key);
if (existingNames.has(key)) {
skipped.push(entry.name);
} else {
newEntries.push(entry);
}
}
console.log(`\nNew entries to insert: ${newEntries.length}`);
console.log(`Already existing (skipped): ${skipped.length}`);
if (skipped.length > 0) {
console.log(`\nFirst 10 skipped (of ${skipped.length}):`);
skipped.slice(0, 10).forEach((s) => console.log(` - ${s}`));
}
// 5. Insert new entries in batches
if (newEntries.length === 0) {
console.log("\n✅ No new diseases to insert.");
closeDb();
return;
}
const BATCH_SIZE = 50;
let inserted = 0;
let errors = 0;
for (let i = 0; i < newEntries.length; i += BATCH_SIZE) {
const batch = newEntries.slice(i, i + BATCH_SIZE);
const values = batch.map((entry) => {
const causalAgent = inferCausalAgent(entry.name);
const severity = inferSeverity(entry.name);
return {
id: toSlug(entry.name),
plantId: "general",
name: entry.name,
scientificName: "",
causalAgentType: causalAgent,
description: `A plant disease known as "${entry.name}". Source: Wikipedia.`,
symptoms: [],
causes: [],
treatment: [],
prevention: [],
lookalikeIds: [],
severity,
sourceUrl: entry.sourceUrl,
imageUrl: "",
};
});
try {
await db.insert(diseases).values(values).onConflictDoNothing();
inserted += values.length;
} catch (err) {
// Fall back to individual inserts for this batch if batch fails
console.log(` Batch failed, trying individually...`);
for (const val of values) {
try {
await db.insert(diseases).values(val).onConflictDoNothing();
inserted++;
} catch (e2) {
// If it's a duplicate key, count it as skipped
if (String(e2).includes("UNIQUE") || String(e2).includes("duplicate")) {
// Already handled by onConflictDoNothing, shouldn't happen
inserted++;
} else {
console.error(` Error inserting "${val.name}":`, e2);
errors++;
}
}
}
}
if ((i + BATCH_SIZE) % 200 === 0 || i + BATCH_SIZE >= newEntries.length) {
console.log(
` Progress: ${Math.min(i + BATCH_SIZE, newEntries.length)}/${newEntries.length} (${inserted} inserted, ${errors} errors)`,
);
}
}
// 6. Summary
const totalDiseases = await db
.select({ count: sql<number>`COUNT(*)` })
.from(diseases)
.get();
const totalPlants = await db
.select({ count: sql<number>`COUNT(*)` })
.from(plants)
.get();
console.log(`\n📊 Results:`);
console.log(` Inserted: ${inserted}`);
console.log(` Errors: ${errors}`);
console.log(` Skipped (already existed): ${skipped.length}`);
console.log(`\n📊 Database now has:`);
console.log(` ${totalPlants?.count ?? 0} plants`);
console.log(` ${totalDiseases?.count ?? 0} diseases`);
closeDb();
}
main().catch((err) => {
console.error("❌ Failed:", err);
process.exit(1);
});

View File

@@ -0,0 +1,414 @@
#!/usr/bin/env node
/**
* fill-brave-images-v2.ts — Brave Image Search for remaining disease images.
*
* Prioritizes by severity (critical → high → moderate → low).
* Runs at 1 request/sec (Brave free tier rate limit).
* Updates Turso DB directly with found images.
* When current key is exhausted, prompts for next key.
* Falls back to duckduckgo-images-api when all keys are spent.
*
* Usage:
* cd apps/web && npx tsx scripts/fill-brave-images-v2.ts
*
* Pass additional API keys as args:
* npx tsx scripts/fill-brave-images-v2.ts KEY2 KEY3
*/
import { readFileSync, writeFileSync } from "fs";
import { resolve } from "path";
// Load env
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 {}
// Also try .env.local for BRAVE_API_KEY
try {
const envLocal = readFileSync(resolve(__dirname, "../.env.local"), "utf-8");
for (const line of envLocal.split("\n")) {
const trimmed = line.trim();
if (trimmed.startsWith("BRAVE_API_KEY=")) {
const val = trimmed.slice("BRAVE_API_KEY=".length).trim();
if (!process.env.BRAVE_API_KEY) process.env.BRAVE_API_KEY = val;
}
}
} catch {}
import { getDb, closeDb } from "../src/lib/db/index";
import { diseases } from "../src/lib/db/schema";
import { createClient } from "@libsql/client";
import { sql } from "drizzle-orm";
interface DiseaseRow {
id: string;
name: string;
scientificName: string;
severity: string;
plantId: string;
}
// ─── Config ──────────────────────────────────────────────────────────────────
const BRAVE_DELAY = 1100; // ms between calls (1 req/sec)
const DB_FLUSH_BATCH = 50;
const MAX_PER_KEY = 1800; // Leave 200 buffer of the 2000/mo limit
const STATE_FILE = resolve(__dirname, ".brave-progress.json");
let currentKeyIndex = 0;
let braveKeys: string[] = [];
let callsThisKey = 0;
let totalFound = 0;
// totalSkipped tracking removed — not needed for v2
// ─── State persistence ───────────────────────────────────────────────────────
interface RunState {
processedIds: string[];
currentKeyIndex: number;
callsThisKey: number;
totalFound: number;
}
function loadState(): RunState | null {
try {
return JSON.parse(readFileSync(STATE_FILE, "utf-8"));
} catch {
return null;
}
}
function saveState(processedIds: string[]) {
writeFileSync(
STATE_FILE,
JSON.stringify(
{
processedIds,
currentKeyIndex,
callsThisKey,
totalFound,
},
null,
2,
),
"utf-8",
);
}
// ─── Brave API ───────────────────────────────────────────────────────────────
async function braveImageSearch(query: string): Promise<string | null> {
const key = braveKeys[currentKeyIndex];
if (!key) return null;
const url = new URL("https://api.search.brave.com/res/v1/images/search");
url.searchParams.set("q", query);
url.searchParams.set("count", "3");
for (let attempt = 0; attempt < 3; attempt++) {
try {
const res = await fetch(url.toString(), {
headers: { "X-Subscription-Token": key, Accept: "application/json" },
});
if (res.status === 429) {
console.log("\n [RATE LIMITED] Key " + (currentKeyIndex + 1) + " exhausted!");
return "RATE_LIMITED";
}
if (!res.ok) return null;
callsThisKey++;
const data = (await res.json()) as {
results?: Array<{ url: string; thumbnail?: { src?: string } }>;
};
const results = data?.results ?? [];
if (results.length === 0) return null;
// Prefer non-stock images
for (const r of results) {
const src = r.thumbnail?.src ?? r.url;
if (src && !/(dreamstime|shutterstock|alamy|istock|123rf)/i.test(src)) {
return src;
}
}
return results[0].thumbnail?.src ?? results[0].url;
} catch {
await new Promise((r) => setTimeout(r, 2000));
}
}
return null;
}
// ─── DuckDuckGo fallback ────────────────────────────────────────────────────
async function ddgFallbackSearch(query: string): Promise<string | null> {
try {
// Try to use duckduckgo-images-api if installed
const ddg = await import("duckduckgo-images-api").catch(() => null);
if (ddg) {
const results = await ddg.image_search({ query, moderate: true });
if (results && results.length > 0) {
for (const r of results) {
if (r.image && !/(dreamstime|shutterstock|alamy|istock|123rf)/i.test(r.image)) {
return r.image;
}
}
return results[0].image || null;
}
}
} catch {
// duckduckgo-images-api not installed
}
return null;
}
// ─── Main ────────────────────────────────────────────────────────────────────
async function main() {
console.log("\n🔍 Brave Disease Image Filler v2\n");
// Parse keys from args + env
const argsKeys = process.argv.slice(2).filter((a) => !a.startsWith("-"));
const envKey = process.env.BRAVE_API_KEY;
braveKeys = [envKey, ...argsKeys].filter(Boolean) as string[];
braveKeys = [...new Set(braveKeys)]; // dedup
if (braveKeys.length === 0) {
console.log("❌ No Brave API keys found.");
console.log(" Set BRAVE_API_KEY in .env.local or pass as argument.\n");
process.exit(1);
}
console.log(`🔑 ${braveKeys.length} Brave API key(s) available\n`);
// Load state
const state = loadState();
if (state) {
currentKeyIndex = state.currentKeyIndex;
callsThisKey = state.callsThisKey;
totalFound = state.totalFound;
console.log(
`📋 Resuming from previous run (${state.processedIds.length} processed, ${totalFound} found)\n`,
);
}
// Get diseases from DB
const db = getDb();
const allDiseases = (await db
.select({
id: diseases.id,
name: diseases.name,
scientificName: diseases.scientificName,
severity: diseases.severity,
plantId: diseases.plantId,
})
.from(diseases)
.where(sql`(image_url IS NULL OR image_url = '')`)
.all()) as DiseaseRow[];
console.log(`📋 ${allDiseases.length} diseases need images\n`);
if (allDiseases.length === 0) {
console.log("✅ All diseases already have images!\n");
closeDb();
return;
}
// Sort by severity priority
const severityOrder = { critical: 0, high: 1, moderate: 2, low: 3 };
allDiseases.sort(
(a, b) =>
(severityOrder[a.severity as keyof typeof severityOrder] || 99) -
(severityOrder[b.severity as keyof typeof severityOrder] || 99),
);
// Filter out already-processed from state
const processedSet = new Set(state?.processedIds || []);
const pending = allDiseases.filter((d) => !processedSet.has(d.id));
console.log(
`📊 Prioritization: critical=${allDiseases.filter((d) => d.severity === "critical" && !processedSet.has(d.id)).length}, high=${allDiseases.filter((d) => d.severity === "high" && !processedSet.has(d.id)).length}, moderate=${allDiseases.filter((d) => d.severity === "moderate" && !processedSet.has(d.id)).length}, low=${allDiseases.filter((d) => d.severity === "low" && !processedSet.has(d.id)).length}\n`,
);
if (pending.length === 0) {
console.log("✅ All remaining diseases already attempted\n");
closeDb();
return;
}
const raw = createClient({
url: process.env.DATABASE_URL!,
authToken: process.env.DATABASE_TOKEN!,
});
let updates: Array<{ id: string; url: string }> = [];
const processedIds: string[] = state?.processedIds || [];
let found = totalFound;
let ddgMode = false;
for (let i = 0; i < pending.length; i++) {
const d = pending[i];
// Check if current key needs rotating
if (!ddgMode && callsThisKey >= MAX_PER_KEY) {
if (currentKeyIndex < braveKeys.length - 1) {
currentKeyIndex++;
callsThisKey = 0;
console.log(`\n 🔄 Rotating to key ${currentKeyIndex + 1}/${braveKeys.length}\n`);
} else {
console.log(
`\n ⚠️ All ${braveKeys.length} Brave keys exhausted. Switching to DuckDuckGo fallback.\n`,
);
ddgMode = true;
// Install duckduckgo-images-api if not available
try {
await import("duckduckgo-images-api");
} catch {
console.log(" Installing duckduckgo-images-api...");
const { execSync } = await import("child_process");
execSync("npm install duckduckgo-images-api", {
cwd: resolve(__dirname, ".."),
stdio: "pipe",
});
console.log(" Done.\n");
}
}
}
// Build search query
const plantName = d.plantId.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
const query = `${d.name} ${d.scientificName} ${plantName} plant disease`;
const sev = d.severity.padEnd(8);
process.stdout.write(
` [${String(i + 1).padStart(4)}/${pending.length}] [${sev}] ${d.name.substring(0, 40).padEnd(42)} `,
);
let url: string | null = null;
if (ddgMode) {
url = await ddgFallbackSearch(query);
if (!url) {
// Try a simpler query
url = await ddgFallbackSearch(`${d.name} disease`);
}
} else {
url = await braveImageSearch(query);
if (url === "RATE_LIMITED") {
// Key exhausted mid-query, try next
if (currentKeyIndex < braveKeys.length - 1) {
currentKeyIndex++;
callsThisKey = 0;
console.log("\n 🔄 Rotating key...");
url = await braveImageSearch(query);
} else {
console.log("\n ⚠️ All keys exhausted mid-batch!");
ddgMode = true;
url = await ddgFallbackSearch(query);
}
}
}
if (url) {
updates.push({ id: d.id, url });
found++;
processedIds.push(d.id);
console.log("✅");
} else {
processedIds.push(d.id); // Mark as attempted even if not found
console.log("❌");
}
// Flush to DB
if (updates.length >= DB_FLUSH_BATCH) {
await raw.batch(
updates.map((u) => ({
sql: "UPDATE diseases SET image_url = ?, updated_at = datetime() WHERE id = ?",
args: [u.url, u.id],
})),
"write",
);
console.log(` → Flushed ${updates.length} to DB`);
updates = [];
}
// Save state every 50
if ((i + 1) % 50 === 0) {
saveState(processedIds);
}
// Rate limit (even for DDG to be polite)
await new Promise((r) => setTimeout(r, ddgMode ? 500 : BRAVE_DELAY));
}
// Final flush
if (updates.length > 0) {
await raw.batch(
updates.map((u) => ({
sql: "UPDATE diseases SET image_url = ?, updated_at = datetime() WHERE id = ?",
args: [u.url, u.id],
})),
"write",
);
console.log(` → Flushed ${updates.length} to DB`);
}
saveState(processedIds);
raw.close();
// Final report
const finalList = await db
.select({ id: diseases.id, name: diseases.name, imageUrl: diseases.imageUrl })
.from(diseases)
.all();
const w = finalList.filter((d) => d.imageUrl);
const wo = finalList.filter((d) => !d.imageUrl);
console.log(`\n${"═".repeat(50)}`);
console.log(`📊 BRAVE IMAGE SEARCH COMPLETE`);
console.log(`${"═".repeat(50)}`);
console.log(` Processed: ${pending.length}`);
console.log(` Found this run: ${found - totalFound}`);
console.log(` Total with images: ${w.length}/${finalList.length}`);
console.log(` Still missing: ${wo.length}`);
console.log(` Brave keys used: ${currentKeyIndex + 1}`);
console.log(` Calls on current key: ${callsThisKey}`);
console.log(` DuckDuckGo mode: ${ddgMode}`);
if (wo.length > 0) {
const rp = resolve(__dirname, ".disease-image-review-needed.md");
let report = "# Disease Images - Still Missing\n\n";
report += `Generated: ${new Date().toISOString()}\n\n`;
report += `## Summary\n\n`;
report += `- Total: ${finalList.length}\n`;
report += `- With images: ${w.length}\n`;
report += `- Still missing: ${wo.length}\n\n`;
report += `## Missing Diseases\n\n`;
for (const d of wo) {
report += `- ${d.name} (\`${d.id}\`)\n`;
}
writeFileSync(rp, report, "utf-8");
console.log(`\n📝 Report: ${rp}`);
} else {
console.log("\n✅ ALL diseases now have images!");
}
closeDb();
console.log("\n");
}
main().catch((err) => {
console.error("\n❌", err);
process.exit(1);
});

View File

@@ -0,0 +1,152 @@
#!/usr/bin/env node
/**
* fill-brave-images.ts — Brave-only pass for remaining disease images.
*
* Runs at 1 request/sec (Brave rate limit).
* Updates diseases.json and Turso DB.
*
* Usage: cd apps/web && npx tsx scripts/fill-brave-images.ts
*/
import dotenv from "dotenv"; dotenv.config({ path: resolve(__dirname, "../.env.local") });
import { readFileSync, writeFileSync } from "fs";
import { resolve } from "path";
import { createClient } from "@libsql/client";
import { closeDb } from "../src/lib/db/index";
const DISEASES_JSON = resolve(__dirname, "../src/data/diseases.json");
const BRAVE_KEY = process.env.BRAVE_API_KEY ?? "";
interface DiseaseSeed {
id: string;
plantId: string;
name: string;
scientificName: string;
imageUrl?: string;
[key: string]: unknown;
}
function load(): DiseaseSeed[] {
return JSON.parse(readFileSync(DISEASES_JSON, "utf-8")) as DiseaseSeed[];
}
async function searchBraveImage(query: string): Promise<string | null> {
const url = new URL("https://api.search.brave.com/res/v1/images/search");
url.searchParams.set("q", query);
url.searchParams.set("count", "3");
for (let attempt = 0; attempt < 3; attempt++) {
try {
const res = await fetch(url.toString(), {
headers: { "X-Subscription-Token": BRAVE_KEY, Accept: "application/json" },
});
if (res.status === 429) {
await new Promise((r) => setTimeout(r, 5000 * 2 ** attempt));
continue;
}
if (!res.ok) return null;
const data = (await res.json()) as {
results?: Array<{ url: string; thumbnail?: { src?: string } }>;
};
const results = data?.results ?? [];
if (results.length === 0) return null;
// Prefer non-stock direct-looking images
for (const r of results) {
const src = r.thumbnail?.src ?? r.url;
if (src && !/(dreamstime|shutterstock|alamy|istock|123rf)/i.test(src)) return src;
}
return results[0].thumbnail?.src ?? results[0].url;
} catch {
await new Promise((r) => setTimeout(r, 2000));
}
}
return null;
}
async function main() {
console.log("\n🔍 Brave Image Search — remaining disease images\n");
if (!BRAVE_KEY) {
console.log("❌ No BRAVE_API_KEY in .env.local\n");
process.exit(1);
}
const diseases = load();
const pending = diseases.filter((d) => !d.imageUrl);
console.log(`📋 ${pending.length} diseases need images\n`);
let found = 0;
for (let i = 0; i < pending.length; i++) {
const d = pending[i];
const plant = diseases.find((p) => p.id === d.plantId);
const plantName = plant?.name ?? d.plantId;
const query = `${d.name} ${plantName} plant disease symptom`;
process.stdout.write(` [${String(i + 1).padStart(2, " ")}/${pending.length}] ${d.name.padEnd(35)} `);
const url = await searchBraveImage(query);
if (url) {
d.imageUrl = url;
found++;
console.log(``);
} else {
console.log(``);
}
// 1 req/sec rate limit
await new Promise((r) => setTimeout(r, 1100));
}
// Write updated JSON
writeFileSync(DISEASES_JSON, JSON.stringify(diseases, null, 2) + "\n", "utf-8");
console.log(`\n✅ diseases.json updated: ${found}/${pending.length} images found\n`);
// Update DB
try {
const dbUrl = process.env.DATABASE_URL;
const dbToken = process.env.DATABASE_TOKEN;
if (dbUrl && dbToken) {
const raw = createClient({ url: dbUrl, authToken: dbToken });
const updates = pending.filter((d) => d.imageUrl);
for (let i = 0; i < updates.length; i += 50) {
await raw.batch(
updates.slice(i, i + 50).map((d) => ({
sql: "UPDATE diseases SET image_url = ? WHERE id = ?",
args: [d.imageUrl!, d.id],
})),
"write",
);
}
raw.close();
console.log(`✅ Turso DB updated: ${updates.length} rows`);
} else {
console.log("⏭️ Skipping DB — no DATABASE_URL/TOKEN");
}
} catch (err) {
console.log(` ⚠️ DB: ${err instanceof Error ? err.message : err}`);
}
// Summary
const finalDiseases = JSON.parse(readFileSync(DISEASES_JSON, "utf-8")) as DiseaseSeed[];
const stillMissing = finalDiseases.filter((d) => !d.imageUrl);
console.log(`\n${"═".repeat(50)}`);
console.log(`📊 FINAL: ${finalDiseases.length} total`);
console.log(` With images: ${finalDiseases.length - stillMissing.length}`);
console.log(` Still missing: ${stillMissing.length}`);
if (stillMissing.length > 0) {
console.log(`\nStill need human curation:`);
for (const d of stillMissing) {
console.log(`${d.name} (${d.id})`);
}
}
console.log(`${"═".repeat(50)}\n`);
closeDb();
}
main().catch((err) => {
console.error("\n❌ Fatal:", err);
process.exit(1);
});

268
scripts/fill-ddg-images.ts Normal file
View File

@@ -0,0 +1,268 @@
#!/usr/bin/env node
/**
* fill-ddg-images.ts — DuckDuckGo Image Search for remaining disease images.
*
* No API key needed. Searches DuckDuckGo Images API for each disease
* without an image and updates the Turso DB.
*
* Prioritizes by severity (critical → high → moderate → low).
* Runs at 1 request/sec to be polite to DuckDuckGo.
* Resumable via state file (scripts/.ddg-progress.json).
*
* Usage:
* cd apps/web && npx tsx scripts/fill-ddg-images.ts
*/
import { readFileSync, writeFileSync } from "fs";
import { resolve } 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 "../src/lib/db/index";
import { diseases } from "../src/lib/db/schema";
import { createClient } from "@libsql/client";
import { sql } from "drizzle-orm";
// DuckDuckGo
import { imageSearch } from "@mudbill/duckduckgo-images-api";
interface DiseaseRow {
id: string;
name: string;
scientificName: string;
severity: string;
plantId: string;
}
// ─── Config ──────────────────────────────────────────────────────────────────
const POLITE_DELAY = 800; // ms between calls
const DB_FLUSH_BATCH = 50;
const STATE_FILE = resolve(__dirname, ".ddg-progress.json");
interface RunState {
processedIds: string[];
totalFound: number;
}
function loadState(): RunState | null {
try {
return JSON.parse(readFileSync(STATE_FILE, "utf-8"));
} catch {
return null;
}
}
function saveState(processedIds: string[], totalFound: number) {
writeFileSync(STATE_FILE, JSON.stringify({ processedIds, totalFound }, null, 2), "utf-8");
}
// ─── DuckDuckGo Search ───────────────────────────────────────────────────────
async function searchImage(query: string): Promise<string | null> {
try {
const results = await imageSearch({ query, safe: true, iterations: 1, retries: 2 });
if (!results || results.length === 0) return null;
// Prefer non-stock images
for (const r of results) {
if (r.image && !/(dreamstime|shutterstock|alamy|istock|123rf)/i.test(r.image)) {
return r.image;
}
}
return results[0].image || results[0].thumbnail || null;
} catch {
// DuckDuckGo may block or timeout; silently skip
return null;
}
}
// ─── Main ────────────────────────────────────────────────────────────────────
async function main() {
console.log("\n🦆 DuckDuckGo Disease Image Filler\n");
const db = getDb();
// Load state
const state = loadState();
const processedSet = new Set(state?.processedIds || []);
const totalFoundPrev = state?.totalFound ?? 0;
// Get all diseases that still need images
const allDiseases = (await db
.select({
id: diseases.id,
name: diseases.name,
scientificName: diseases.scientificName,
severity: diseases.severity,
plantId: diseases.plantId,
})
.from(diseases)
.where(sql`(image_url IS NULL OR image_url = '')`)
.all()) as DiseaseRow[];
console.log(`📋 ${allDiseases.length} diseases need images\n`);
if (allDiseases.length === 0) {
console.log("✅ All diseases already have images!\n");
closeDb();
return;
}
// Sort by severity: critical > high > moderate > low
const severityOrder: Record<string, number> = { critical: 0, high: 1, moderate: 2, low: 3 };
allDiseases.sort((a, b) => (severityOrder[a.severity] ?? 99) - (severityOrder[b.severity] ?? 99));
// Filter out already-processed
const pending = allDiseases.filter((d) => !processedSet.has(d.id));
console.log(
`📊 Remaining: critical=${allDiseases.filter((d) => d.severity === "critical" && !processedSet.has(d.id)).length}, ` +
`high=${allDiseases.filter((d) => d.severity === "high" && !processedSet.has(d.id)).length}, ` +
`moderate=${allDiseases.filter((d) => d.severity === "moderate" && !processedSet.has(d.id)).length}, ` +
`low=${allDiseases.filter((d) => d.severity === "low" && !processedSet.has(d.id)).length}\n`,
);
if (pending.length === 0) {
console.log("✅ All remaining diseases already attempted\n");
closeDb();
return;
}
const raw = createClient({
url: process.env.DATABASE_URL!,
authToken: process.env.DATABASE_TOKEN!,
});
const processedIds: string[] = state?.processedIds ?? [];
let found = totalFoundPrev;
let updates: Array<{ id: string; url: string }> = [];
for (let i = 0; i < pending.length; i++) {
const d = pending[i];
const sev = d.severity.padEnd(8);
// Build search query — "[disease] on [plant]" phrasing for better specificity
const plantName = d.plantId.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
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)} `,
);
// Try queries in order until we get a result
let url: string | null = null;
for (const q of [query1, query2, query3, query4, query5]) {
url = await searchImage(q);
if (url) break;
}
if (url) {
updates.push({ id: d.id, url });
found++;
processedIds.push(d.id);
console.log("✅");
} else {
processedIds.push(d.id);
console.log("❌");
}
// Flush to DB in batches
if (updates.length >= DB_FLUSH_BATCH) {
await raw.batch(
updates.map((u) => ({
sql: "UPDATE diseases SET image_url = ?, updated_at = datetime() WHERE id = ?",
args: [u.url, u.id],
})),
"write",
);
console.log(` → Flushed ${updates.length} to DB`);
updates = [];
}
// Save state every 50
if ((i + 1) % 50 === 0) {
saveState(processedIds, found);
}
// Be polite — 1 req/sec
await new Promise((r) => setTimeout(r, POLITE_DELAY));
}
// Final flush
if (updates.length > 0) {
await raw.batch(
updates.map((u) => ({
sql: "UPDATE diseases SET image_url = ?, updated_at = datetime() WHERE id = ?",
args: [u.url, u.id],
})),
"write",
);
console.log(` → Flushed ${updates.length} to DB`);
}
saveState(processedIds, found);
raw.close();
// Final report
const finalList = await db
.select({ id: diseases.id, name: diseases.name, imageUrl: diseases.imageUrl })
.from(diseases)
.all();
const w = finalList.filter((d) => d.imageUrl);
const wo = finalList.filter((d) => !d.imageUrl);
console.log(`\n${"═".repeat(50)}`);
console.log(`🦆 DUCKDUCKGO SEARCH COMPLETE`);
console.log(`${"═".repeat(50)}`);
console.log(` Processed: ${pending.length}`);
console.log(` Found this run: ${found - totalFoundPrev}`);
console.log(` Total with images: ${w.length}/${finalList.length}`);
console.log(` Still missing: ${wo.length}`);
if (wo.length > 0) {
const reportPath = resolve(__dirname, ".ddg-image-review-needed.md");
let report = "# Disease Images - Still Missing (DDG)\n\n";
report += `Generated: ${new Date().toISOString()}\n\n`;
report += `## Summary\n\n`;
report += `- Total: ${finalList.length}\n`;
report += `- With images: ${w.length}\n`;
report += `- Still missing: ${wo.length}\n\n`;
report += `## Missing Diseases\n\n`;
for (const d of wo) {
report += `- ${d.name} (\`${d.id}\`)\n`;
}
writeFileSync(reportPath, report, "utf-8");
console.log(`\n📝 Missing report: ${reportPath}`);
} else {
console.log("\n✅ ALL diseases now have images!");
}
closeDb();
console.log();
}
main().catch((err) => {
console.error("\n❌ Fatal:", err);
process.exit(1);
});

View File

@@ -0,0 +1,440 @@
#!/usr/bin/env node
/**
* fill-disease-images.ts — Three-stage disease image pipeline
*
* For every disease without an imageUrl, tries:
* Stage 1 — Wikipedia search → pageimages
* Stage 2 — Wikimedia Commons search
* Stage 3 — Brave Image Search API (fallback, 1 req/sec, 2000/mo)
*
* Updates both diseases.json (seed) and the Turso DB.
* Flags anything found only via Brave for human review.
*
* Usage: cd apps/web && npx tsx scripts/fill-disease-images.ts
*/
import "dotenv/config";
import { readFileSync, writeFileSync, existsSync } from "fs";
import { resolve } from "path";
import { createClient } from "@libsql/client";
import { closeDb } from "../src/lib/db/index";
// ─── Types & Config ──────────────────────────────────────────────────────────
interface DiseaseSeed {
id: string;
plantId: string;
name: string;
scientificName: string;
commonName?: string;
[key: string]: unknown;
}
interface ImageResult {
url: string;
source: "wikipedia" | "commons" | "brave" | "missing";
quality: "good" | "fallback" | "missing";
}
const DISEASES_JSON = resolve(__dirname, "../src/data/diseases.json");
const RESULTS_FILE = resolve(__dirname, ".image-results.json");
const REPORT_FILE = resolve(__dirname, ".image-review-needed.md");
const WIKI_API = "https://en.wikipedia.org/w/api.php";
const COMMONS_API = "https://commons.wikimedia.org/w/api.php";
const BRAVE_KEY = process.env.BRAVE_API_KEY ?? "";
const BRAVE_DELAY = 1100;
const MAX_BRAVE = 2000;
const UA = "PlantHealthKB/1.0 (plant-disease-id)";
const ORIGIN = "*";
let braveCount = 0;
// ─── Wikipedia Stage ─────────────────────────────────────────────────────────
/**
* Search Wikipedia and get thumbnails in ONE API call using generator=search.
* Returns first thumbnail found, or null.
*/
async function wikiSearchAndThumb(query: string): Promise<string | null> {
const params = new URLSearchParams({
action: "query",
generator: "search",
gsrsearch: query,
gsrlimit: "5",
prop: "pageimages",
pithumbsize: "600",
format: "json",
origin: ORIGIN,
});
for (let attempt = 0; attempt < 3; attempt++) {
try {
const res = await fetchWithTimeout(`${WIKI_API}?${params}`, {
headers: { "User-Agent": UA },
});
if (res.status === 429) {
await delay(3000 * 2 ** attempt);
continue;
}
if (!res.ok) return null;
const data = (await res.json()) as {
query?: { pages?: Record<string, { thumbnail?: { source: string } }> };
};
const pages = data?.query?.pages;
if (!pages) return null;
for (const [, p] of Object.entries(pages)) {
const src = (p as { thumbnail?: { source: string } })?.thumbnail?.source;
if (src) return src;
}
return null;
} catch {
await delay(2000);
}
}
return null;
}
/**
* Try to find a Wikipedia image for a disease.
* Uses generator=search which combines search + thumbnails in one call.
*/
async function wikiStage(d: DiseaseSeed, plantName: string): Promise<string | null> {
// Try 1: disease name + plant name (most specific)
return wikiSearchAndThumb(`"${d.name}" ${plantName}`);
}
// ─── Commons Stage ───────────────────────────────────────────────────────────
/** Fetch with timeout. Aborts after `ms` milliseconds. */
async function fetchWithTimeout(url: string, opts: RequestInit, ms = 15000): Promise<Response> {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), ms);
try {
const res = await fetch(url, { ...opts, signal: ctrl.signal });
return res;
} finally {
clearTimeout(timer);
}
}
async function commonsSearchAndThumb(query: string): Promise<string | null> {
const params = new URLSearchParams({
action: "query",
list: "search",
srsearch: query,
srnamespace: "6",
srlimit: "5",
format: "json",
origin: ORIGIN,
});
for (let attempt = 0; attempt < 3; attempt++) {
try {
const res = await fetchWithTimeout(`${COMMONS_API}?${params}`, {
headers: { "User-Agent": UA },
});
if (res.status === 429) {
await delay(3000 * 2 ** attempt);
continue;
}
if (!res.ok) return null;
const data = (await res.json()) as {
query?: { search?: Array<{ pageid: number; title: string }> };
};
const hits = data?.query?.search ?? [];
if (hits.length === 0) return null;
// Batch-fetch imageinfo for all found page IDs
const pageids = hits.map((h) => h.pageid).join("|");
const imgParams = new URLSearchParams({
action: "query",
pageids,
prop: "imageinfo",
iiprop: "url",
iiurlwidth: "600",
format: "json",
origin: ORIGIN,
});
const imgRes = await fetchWithTimeout(`${COMMONS_API}?${imgParams}`, {
headers: { "User-Agent": UA },
});
if (!imgRes.ok) return null;
const imgData = (await imgRes.json()) as {
query?: { pages?: Record<string, unknown> };
};
const imgPages = imgData?.query?.pages;
if (!imgPages) return null;
for (const [, pg] of Object.entries(imgPages)) {
const p = pg as Record<string, unknown>;
const info = (p.imageinfo as Array<Record<string, string>> | undefined)?.[0];
if (info?.thumburl) return info.thumburl as string;
if (info?.url) return info.url as string;
}
return null;
} catch {
await delay(2000);
}
}
return null;
}
async function commonsStage(d: DiseaseSeed, plantName: string): Promise<string | null> {
let q: string;
if (d.scientificName && !d.scientificName.includes("spp.") && !d.scientificName.includes("/")) {
q = `${d.scientificName} ${plantName}`;
} else {
q = `${d.name} ${plantName} disease`;
}
const url = await commonsSearchAndThumb(q);
return url ?? null;
}
// ─── Brave Stage ─────────────────────────────────────────────────────────────
async function braveStage(d: DiseaseSeed, plantName: string): Promise<string | null> {
if (!BRAVE_KEY || braveCount >= MAX_BRAVE) return null;
const url = new URL("https://api.search.brave.com/res/v1/images/search");
url.searchParams.set("q", `${d.name} ${plantName} plant disease symptom`);
url.searchParams.set("count", "5");
for (let attempt = 0; attempt < 3; attempt++) {
try {
const res = await fetchWithTimeout(url.toString(), {
headers: { "X-Subscription-Token": BRAVE_KEY, Accept: "application/json" },
});
if (res.status === 429) {
await delay(5000 * 2 ** attempt);
continue;
}
if (!res.ok) return null;
braveCount++;
const data = (await res.json()) as {
results?: Array<{ url: string; thumbnail?: { src?: string } }>;
};
const results = data?.results ?? [];
if (results.length === 0) return null;
// Prefer non-stock thumbnails
for (const r of results) {
const src = r.thumbnail?.src ?? r.url;
if (src && !src.includes("dreamstime") && !src.includes("shutterstock") &&
!src.includes("alamy") && !src.includes("istock") && !src.includes("123rf")) {
return src;
}
}
return results[0].thumbnail?.src ?? results[0].url;
} catch {
await delay(2000);
}
}
return null;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function delay(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
function loadDiseases(): DiseaseSeed[] {
return JSON.parse(readFileSync(DISEASES_JSON, "utf-8")) as DiseaseSeed[];
}
function getPlantName(diseases: DiseaseSeed[], diseaseId: string): string {
const plant = diseases.find((p) => p.id === diseaseId);
return plant?.commonName ?? plant?.name ?? diseaseId;
}
// ─── Main ────────────────────────────────────────────────────────────────────
async function main() {
console.log("\n🔍 Plant Disease Image Filler\n");
const diseases = loadDiseases();
console.log(`📋 ${diseases.length} diseases loaded\n`);
// Load existing results
let results: Record<string, ImageResult> = {};
if (existsSync(RESULTS_FILE)) {
try { results = JSON.parse(readFileSync(RESULTS_FILE, "utf-8")); } catch { /* fresh */ }
}
const pending = diseases.filter((d) => {
if ((d.imageUrl as string)?.length) return false;
return !results[d.id];
});
if (pending.length === 0) {
console.log("✅ All done\n");
await applyResults(diseases, results);
return;
}
console.log(`${pending.length} need images\n`);
// ── Stage 1: Wikipedia ──────────────────────────────────────────────
const s1 = pending.filter((d) => !results[d.id]);
let s1ok = 0;
console.log("─── Wikipedia ───\n");
for (let i = 0; i < s1.length; i++) {
const d = s1[i];
const plantName = getPlantName(diseases, d.plantId);
const url = await wikiStage(d, plantName);
if (url) {
results[d.id] = { url, source: "wikipedia", quality: "good" };
s1ok++;
}
const pct = ((i + 1) / s1.length * 100).toFixed(0);
process.stdout.write(` [${pct}% ${i + 1}/${s1.length}] ${d.name.substring(0, 40).padEnd(42)} ${url ? "✅" : "⏭️"}\n`);
if ((i + 1) % 25 === 0) writeFileSync(RESULTS_FILE, JSON.stringify(results, null, 2));
}
writeFileSync(RESULTS_FILE, JSON.stringify(results, null, 2));
console.log(`\n → ${s1ok}/${s1.length} found\n`);
// ── Stage 2: Commons ─────────────────────────────────────────────────
const s2 = pending.filter((d) => !results[d.id]);
let s2ok = 0;
if (s2.length > 0) {
console.log("─── Wikimedia Commons ───\n");
for (let i = 0; i < s2.length; i++) {
const d = s2[i];
const plantName = getPlantName(diseases, d.plantId);
let url: string | null = null;
try {
const result = await Promise.race([
commonsStage(d, plantName),
new Promise<null>((_, reject) => setTimeout(() => reject(new Error("timeout")), 25000)),
]);
url = result;
} catch { /* timeout */ }
if (url) {
results[d.id] = { url, source: "commons", quality: "good" };
s2ok++;
}
const pct = ((i + 1) / s2.length * 100).toFixed(0);
process.stdout.write(` [${pct}% ${i + 1}/${s2.length}] ${d.name.substring(0, 40).padEnd(42)} ${url ? "✅" : "⏭️"}\n`);
if ((i + 1) % 10 === 0) writeFileSync(RESULTS_FILE, JSON.stringify(results, null, 2));
}
writeFileSync(RESULTS_FILE, JSON.stringify(results, null, 2));
console.log(`\n → ${s2ok}/${s2.length} found\n`);
}
// ── Stage 3: Brave ───────────────────────────────────────────────────
const s3 = pending.filter((d) => !results[d.id]);
let s3ok = 0;
if (s3.length > 0 && BRAVE_KEY) {
console.log("─── Brave Image Search ───\n");
for (const d of s3) {
if (braveCount >= MAX_BRAVE) {
results[d.id] = { url: "", source: "missing", quality: "missing" };
continue;
}
const plantName = getPlantName(diseases, d.plantId);
const url = await braveStage(d, plantName);
if (url) {
results[d.id] = { url, source: "brave", quality: "fallback" };
s3ok++;
process.stdout.write(`${d.name}\n`);
} else {
results[d.id] = { url: "", source: "missing", quality: "missing" };
process.stdout.write(`${d.name}\n`);
}
await delay(BRAVE_DELAY);
}
writeFileSync(RESULTS_FILE, JSON.stringify(results, null, 2));
console.log(`\n → ${s3ok}/${s3.length} found via Brave\n`);
} else if (s3.length > 0) {
console.log("─── Brave Image Search ─── → skipped (no key)\n");
for (const d of s3) results[d.id] = { url: "", source: "missing", quality: "missing" };
}
// ── Apply ───────────────────────────────────────────────────────────
await applyResults(diseases, results);
// ── Report ──────────────────────────────────────────────────────────
const good = Object.values(results).filter((r) => r.quality === "good").length;
const fallback = Object.values(results).filter((r) => r.quality === "fallback").length;
const missing = Object.values(results).filter((r) => r.quality === "missing").length;
let report = `# Disease Images — Human Review Needed\n\n`;
report += `Generated: ${new Date().toISOString()}\n\n`;
for (const [label, ids, type] of [
["Fallback (Brave)", Object.entries(results).filter(([, r]) => r.quality === "fallback").map(([id]) => id), "fallback"],
["Missing", Object.entries(results).filter(([, r]) => r.quality === "missing").map(([id]) => id), "missing"],
] as const) {
if (ids.length === 0) continue;
report += `## ${type === "fallback" ? "⚠️" : "🚫"} ${label}\n\n`;
for (const id of ids) {
const d = diseases.find((x) => x.id === id);
const r = results[id];
report += `- **${d?.name ?? id}** (${d?.scientificName ?? ""}) on \`${d?.plantId ?? ""}\``;
if (r?.url) report += `\n ${r.url}`;
report += `\n\n`;
}
}
if (good === diseases.length) report += `## ✅ All images found!\n`;
writeFileSync(REPORT_FILE, report, "utf-8");
console.log(`📝 Review report: ${REPORT_FILE}`);
console.log(`\n${"═".repeat(50)}`);
console.log(`📊 Total: ${diseases.length} Good: ${good} Fallback: ${fallback} Missing: ${missing}`);
console.log(` Brave calls: ${braveCount}`);
console.log(`${"═".repeat(50)}\n`);
closeDb();
}
// ─── Apply results to JSON + DB ──────────────────────────────────────────────
async function applyResults(diseases: DiseaseSeed[], results: Record<string, ImageResult>) {
const urlMap = new Map(
Object.entries(results).filter(([id, r]) => r.url.length > 0 && diseases.some((d) => d.id === id)),
);
if (urlMap.size === 0) return console.log("⏭️ No images to apply");
// JSON
let n = 0;
const updated = diseases.map((d) => {
const img = urlMap.get(d.id);
if (img) { n++; return { ...d, imageUrl: img.url, imageQuality: img.quality }; }
return d;
});
writeFileSync(DISEASES_JSON, JSON.stringify(updated, null, 2) + "\n");
console.log(`✅ diseases.json: ${n} images`);
// DB
try {
const dbUrl = process.env.DATABASE_URL;
const dbToken = process.env.DATABASE_TOKEN;
if (!dbUrl || !dbToken) return console.log(" ⏭️ DB: no DATABASE_URL/TOKEN");
const raw = createClient({ url: dbUrl, authToken: dbToken });
const entries = Array.from(urlMap.entries());
for (let i = 0; i < entries.length; i += 50) {
await raw.batch(
entries.slice(i, i + 50).map(([id, img]) => ({
sql: "UPDATE diseases SET image_url = ? WHERE id = ?",
args: [img.url, id],
})),
"write",
);
}
raw.close();
console.log(`✅ Turso DB: ${entries.length} rows`);
} catch (err) {
console.log(` ⚠️ DB: ${err instanceof Error ? err.message : err}`);
}
}
main().catch((err) => { console.error("\n❌", err); process.exit(1); });

View File

@@ -0,0 +1,301 @@
#!/usr/bin/env node
/**
* fill-plant-images-v2.ts — Batch Wikipedia image fetch for remaining plants.
*
* Phase 1: Query 50 scientific names at a time via pageimages.
* Phase 2: Query 50 common names at a time.
* Phase 3: Search individually for stragglers.
*
* Usage: cd apps/web && npx tsx scripts/fill-plant-images-v2.ts
*/
import { readFileSync, writeFileSync } from "fs";
import { resolve } from "path";
// Load env
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 (e) {}
import { getDb, closeDb } from "../src/lib/db/index";
import { plants } from "../src/lib/db/schema";
import { createClient } from "@libsql/client";
import { sql } from "drizzle-orm";
const API = "https://en.wikipedia.org/w/api.php";
const UA = "PlantHealthKB/1.0";
const BATCH = 50;
interface PlantRow {
id: string;
commonName: string;
scientificName: string;
}
function clean(s: string): string {
return s
.replace(/[xX]/g, "x")
.replace(/\s*spp\.?\s*/gi, "")
.replace(/[.\u00d7']/g, "")
.trim();
}
async function fetchThumbs(titles: string[]): Promise<Map<string, string>> {
if (titles.length === 0) {
return new Map();
}
const p = new URLSearchParams({
action: "query",
titles: titles.join("|"),
prop: "pageimages",
pithumbsize: "400",
redirects: "1",
format: "json",
});
for (let a = 0; a < 3; a++) {
try {
const r = await fetch(API + "?" + p.toString(), {
headers: { "User-Agent": UA },
});
if (r.status === 429) {
await new Promise((rr) => setTimeout(rr, 5000 * Math.pow(2, a)));
continue;
}
if (!r.ok) {
return new Map();
}
const d = (await r.json()) as any;
const pages = d?.query?.pages;
if (!pages) {
return new Map();
}
const m = new Map<string, string>();
for (const [, pg] of Object.entries(pages)) {
const p2 = pg as any;
if (!p2.missing && p2.thumbnail?.source) {
m.set(p2.title.toLowerCase(), p2.thumbnail.source);
}
}
return m;
} catch (e) {
await new Promise((rr) => setTimeout(rr, 2000));
}
}
return new Map();
}
async function searchOne(query: string): Promise<string | null> {
const p = new URLSearchParams({
action: "query",
generator: "search",
gsrsearch: query,
gsrlimit: "3",
prop: "pageimages",
pithumbsize: "400",
format: "json",
});
for (let a = 0; a < 3; a++) {
try {
const r = await fetch(API + "?" + p.toString(), {
headers: { "User-Agent": UA },
});
if (r.status === 429) {
await new Promise((rr) => setTimeout(rr, 5000 * Math.pow(2, a)));
continue;
}
if (!r.ok) {
return null;
}
const d = (await r.json()) as any;
const pages = d?.query?.pages;
if (!pages) {
return null;
}
for (const [, pg] of Object.entries(pages)) {
const p2 = pg as any;
if (p2.thumbnail?.source) {
return p2.thumbnail.source;
}
}
return null;
} catch (e) {
await new Promise((rr) => setTimeout(rr, 2000));
}
}
return null;
}
async function batchPhase(
plants: PlantRow[],
titleFn: (p: PlantRow) => string,
label: string,
dbClient: any,
): Promise<PlantRow[]> {
const remaining: PlantRow[] = [];
const updates: Array<{ id: string; url: string }> = [];
for (let i = 0; i < plants.length; i += BATCH) {
const chunk = plants.slice(i, i + BATCH);
const titles = chunk.map(titleFn).filter((t) => t.length > 2);
console.log(
" [" +
label +
"] " +
(i + 1) +
"-" +
Math.min(i + BATCH, plants.length) +
"/" +
plants.length +
" ",
);
const imageMap = await fetchThumbs(titles);
let n = 0;
for (const pl of chunk) {
const t = titleFn(pl).toLowerCase();
const img = imageMap.get(t);
if (img) {
updates.push({ id: pl.id, url: img });
n++;
} else {
remaining.push(pl);
}
}
console.log(" found: " + n);
if (updates.length >= 100) {
await dbClient.batch(
updates.map((u) => ({
sql: "UPDATE plants SET image_url = ?, updated_at = datetime('now') WHERE id = ?",
args: [u.url, u.id],
})),
"write",
);
updates.length = 0;
}
await new Promise((r) => setTimeout(r, 1500));
}
if (updates.length > 0) {
await dbClient.batch(
updates.map((u) => ({
sql: "UPDATE plants SET image_url = ?, updated_at = datetime('now') WHERE id = ?",
args: [u.url, u.id],
})),
"write",
);
}
return remaining;
}
async function main() {
console.log("\nPlant Image Filler v2\n");
const db = getDb();
const allPlants = (await db
.select({
id: plants.id,
commonName: plants.commonName,
scientificName: plants.scientificName,
})
.from(plants)
.where(sql`(image_url IS NULL OR image_url = '')`)
.all()) as PlantRow[];
console.log("Plants needing images: " + allPlants.length + "\n");
if (allPlants.length === 0) {
console.log("All plants have images!\n");
closeDb();
return;
}
const raw = createClient({
url: process.env.DATABASE_URL!,
authToken: process.env.DATABASE_TOKEN!,
});
let found = 0;
// Phase 1: Scientific name
console.log("--- Phase 1: Scientific names ---\n");
let remaining = await batchPhase(allPlants, (p) => clean(p.scientificName), "sci", raw);
// Phase 2: Common name
if (remaining.length > 0) {
console.log("\n--- Phase 2: Common names (" + remaining.length + ") ---\n");
remaining = await batchPhase(remaining, (p) => p.commonName, "common", raw);
}
// Phase 3: Search
if (remaining.length > 0) {
console.log("\n--- Phase 3: Search (" + remaining.length + ") ---\n");
for (let i = 0; i < remaining.length; i++) {
const pl = remaining[i];
const q = clean(pl.scientificName) + " " + pl.commonName;
console.log(" [" + (i + 1) + "/" + remaining.length + "] " + pl.commonName);
const img = await searchOne(q);
if (img) {
await raw.execute({
sql: "UPDATE plants SET image_url = ?, updated_at = datetime('now') WHERE id = ?",
args: [img, pl.id],
});
found++;
console.log(" OK");
} else {
console.log(" MISS");
}
await new Promise((r) => setTimeout(r, 500));
}
}
raw.close();
// Report
const finalList = await db
.select({
id: plants.id,
commonName: plants.commonName,
imageUrl: plants.imageUrl,
})
.from(plants)
.all();
const w = finalList.filter((p) => p.imageUrl);
const wo = finalList.filter((p) => !p.imageUrl);
console.log("\n" + "=".repeat(50));
console.log("FINAL: " + finalList.length + " plants");
console.log(" With images: " + w.length);
console.log(" Missing: " + wo.length);
if (wo.length > 0) {
const rp = resolve(__dirname, ".plant-image-review-needed.md");
let report = "# Plant Images - Still Missing\n\n";
report += "Generated: " + new Date().toISOString() + "\n\n";
report += "## Missing (" + wo.length + ")\n\n";
for (const p of wo) {
report += "- " + p.commonName + " (" + p.id + ")\n";
}
writeFileSync(rp, report, "utf-8");
console.log("Report: " + rp);
} else {
console.log("\nALL PLANTS HAVE IMAGES!");
}
closeDb();
}
main().catch((err: any) => {
console.error("Error:", err);
process.exit(1);
});

View File

@@ -0,0 +1,308 @@
#!/usr/bin/env node
/**
* fill-plant-images.ts — Fetch plant images from Wikipedia for plants missing them.
*
* Uses the Wikipedia API to search for the plant's scientific name
* and grab the page thumbnail.
*
* Usage: cd apps/web && npx tsx scripts/fill-plant-images.ts
*/
import { readFileSync, writeFileSync } from "fs";
import { resolve } from "path";
// Load env
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 "../src/lib/db/index";
import { plants } from "../src/lib/db/schema";
import { createClient } from "@libsql/client";
import { sql } from "drizzle-orm";
const WIKI_API = "https://en.wikipedia.org/w/api.php";
const UA = "PlantHealthKB/1.0 (plant-images)";
const DELAY_MS = 500;
const BATCH_SIZE = 50;
/** Direct page lookup by title — more reliable for known scientific names. */
async function directPageLookup(title: string): Promise<string | null> {
const params = new URLSearchParams({
action: "query",
titles: title,
prop: "pageimages",
pithumbsize: "400",
format: "json",
origin: "*",
});
for (let attempt = 0; attempt < 3; attempt++) {
try {
const res = await fetch(`${WIKI_API}?${params}`, {
headers: { "User-Agent": UA },
});
if (res.status === 429) {
await new Promise((r) => setTimeout(r, 3000 * 2 ** attempt));
continue;
}
if (!res.ok) return null;
const data = (await res.json()) as {
query?: { pages?: Record<string, { thumbnail?: { source: string }; missing?: boolean }> };
};
const pages = data?.query?.pages;
if (!pages) return null;
for (const [, p] of Object.entries(pages)) {
if (!p.missing && p.thumbnail?.source) return p.thumbnail.source;
}
return null;
} catch {
await new Promise((r) => setTimeout(r, 2000));
}
}
return null;
}
async function main() {
console.log("\n🌿 Fetching plant images from Wikipedia\n");
const db = getDb();
const allPlants = await db
.select({ id: plants.id, commonName: plants.commonName, scientificName: plants.scientificName })
.from(plants)
.where(sql`(image_url IS NULL OR image_url = '')`)
.all();
console.log(`📋 ${allPlants.length} plants need images\n`);
if (allPlants.length === 0) {
console.log("✅ All plants already have images!\n");
closeDb();
return;
}
const rawClient = createClient({
url: process.env.DATABASE_URL!,
authToken: process.env.DATABASE_TOKEN!,
});
let found = 0;
const updates: { id: string; url: string }[] = [];
// Phase 1: Try direct page lookup by scientific name (most accurate)
console.log("─── Phase 1: Direct page lookup ───\n");
for (let i = 0; i < allPlants.length; i++) {
const plant = allPlants[i];
const sciName = plant.scientificName
.replace(/[×'"]/g, "")
.replace(/\s*spp\.?\s*/i, "")
.trim();
process.stdout.write(
` [${String(i + 1).padStart(3)}/${allPlants.length}] ${plant.commonName.padEnd(30)} `,
);
let url: string | null = null;
// Try scientific name first
if (sciName && sciName !== "Unknown" && sciName !== "Various") {
url = await directPageLookup(sciName);
}
// Try common name if scientific name didn't work
if (!url) {
url = await directPageLookup(plant.commonName);
}
// Try genus name
if (!url && sciName) {
const genus = sciName.split(/\s+/)[0];
if (genus && genus.length > 3) {
url = await directPageLookup(genus);
}
}
if (url) {
updates.push({ id: plant.id, url });
found++;
process.stdout.write("✅\n");
} else {
process.stdout.write("⏭️\n");
}
// Flush to DB in batches
if (updates.length >= BATCH_SIZE) {
await rawClient.batch(
updates.map((u) => ({
sql: "UPDATE plants SET image_url = ?, updated_at = datetime('now') WHERE id = ?",
args: [u.url, u.id],
})),
"write",
);
console.log(` → Flushed ${updates.length} to DB`);
updates.length = 0;
}
await new Promise((r) => setTimeout(r, DELAY_MS));
}
// Flush remaining
if (updates.length > 0) {
await rawClient.batch(
updates.map((u) => ({
sql: "UPDATE plants SET image_url = ?, updated_at = datetime('now') WHERE id = ?",
args: [u.url, u.id],
})),
"write",
);
console.log(` → Flushed ${updates.length} to DB`);
updates.length = 0;
}
console.log(`\n✅ Phase 1 done: ${found}/${allPlants.length} plants got images\n`);
// Phase 2: Try remaining via search API
const stillMissing = await db
.select({ id: plants.id, commonName: plants.commonName, scientificName: plants.scientificName })
.from(plants)
.where(sql`(image_url IS NULL OR image_url = '')`)
.all();
if (stillMissing.length > 0) {
console.log(`─── Phase 2: Search API for ${stillMissing.length} remaining ───\n`);
for (let i = 0; i < stillMissing.length; i++) {
const plant = stillMissing[i];
const sciName = plant.scientificName.replace(/[×'"]/g, "").trim();
process.stdout.write(
` [${String(i + 1).padStart(3)}/${stillMissing.length}] ${plant.commonName.padEnd(30)} `,
);
// Search with scientific name
const searchTerm = `${sciName} ${plant.commonName}`;
const params = new URLSearchParams({
action: "query",
list: "search",
srsearch: searchTerm,
srlimit: "3",
format: "json",
origin: "*",
});
let url: string | null = null;
for (let attempt = 0; attempt < 3; attempt++) {
try {
const res = await fetch(`${WIKI_API}?${params}`, {
headers: { "User-Agent": UA },
});
if (res.status === 429) {
await new Promise((r) => setTimeout(r, 3000 * 2 ** attempt));
continue;
}
if (!res.ok) break;
const data = (await res.json()) as {
query?: { search?: Array<{ title: string; pageid: number }> };
};
const hits = data?.query?.search ?? [];
if (hits.length === 0) break;
// Get thumbnail for first result
for (const hit of hits) {
const pageParams = new URLSearchParams({
action: "query",
pageids: String(hit.pageid),
prop: "pageimages",
pithumbsize: "400",
format: "json",
origin: "*",
});
const pageRes = await fetch(`${WIKI_API}?${pageParams}`, {
headers: { "User-Agent": UA },
});
if (!pageRes.ok) continue;
const pageData = (await pageRes.json()) as {
query?: { pages?: Record<string, { thumbnail?: { source: string } }> };
};
const pages = pageData?.query?.pages;
if (!pages) continue;
for (const [, p] of Object.entries(pages)) {
if (p.thumbnail?.source) {
url = p.thumbnail.source;
break;
}
}
if (url) break;
}
break;
} catch {
await new Promise((r) => setTimeout(r, 2000));
}
}
if (url) {
await rawClient.execute({
sql: "UPDATE plants SET image_url = ?, updated_at = datetime('now') WHERE id = ?",
args: [url, plant.id],
});
found++;
process.stdout.write("✅\n");
} else {
process.stdout.write("❌\n");
}
await new Promise((r) => setTimeout(r, DELAY_MS));
}
}
// Final count
const final = await db
.select({ id: plants.id, commonName: plants.commonName, imageUrl: plants.imageUrl })
.from(plants)
.all();
const withImg = final.filter((p) => p.imageUrl);
const withoutImg = final.filter((p) => !p.imageUrl);
console.log(`\n${"═".repeat(50)}`);
console.log(`📊 FINAL: ${final.length} plants`);
console.log(` With images: ${withImg.length}`);
console.log(` Missing images: ${withoutImg.length}`);
if (withoutImg.length > 0) {
console.log(`\n📝 Plants still needing images:`);
withoutImg.forEach((p) => console.log(`${p.id}: ${p.commonName}`));
// Save to file for reference
const reportPath = resolve(__dirname, ".plant-image-review-needed.md");
let report = "# Plant Images — Still Missing\n\n";
report += `Generated: ${new Date().toISOString()}\n\n`;
report += `## 🚫 Plants without images (${withoutImg.length})\n\n`;
for (const p of withoutImg) {
report += `- **${p.commonName}** (\`${p.id}\`)\n`;
}
writeFileSync(reportPath, report, "utf-8");
console.log(` 📝 Review report: ${reportPath}`);
} else {
console.log("\n✅ All plants now have images!");
}
rawClient.close();
closeDb();
}
main().catch((err) => {
console.error("\n❌", err);
process.exit(1);
});

View File

@@ -0,0 +1,927 @@
#!/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.
*
* Parallelism strategy:
* - Disease-level: 30 diseases processed concurrently
* - Per disease: all 3 DDG queries run in parallel
* - Per query: all search pages fetched in parallel
* - Per disease: DDG, iNaturalist, and Wikimedia Commons all run concurrently
* - A shared DDG token-bucket rate limiter prevents bans
*
* 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";
// ─── 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;
/**
* How many diseases to process in parallel.
* Each disease is I/O-bound (HTTP requests), so high concurrency is safe.
* The global DDG rate limiter prevents us from overwhelming DuckDuckGo.
*/
const DISEASE_CONCURRENCY = 20;
/**
* Max DDG requests per second (shared across all concurrent diseases).
* DuckDuckGo is fairly tolerant, but we still want to be polite.
* With DISEASE_CONCURRENCY=30, each disease fires 3 parallel queries with
* parallel pages = 9 parallel DDG requests per disease at peak.
* The rate limiter serializes this so we don't get banned.
*/
const DDG_RATE_LIMIT_RPS = 2;
/** Max concurrent image downloads per disease */
const CONCURRENT_DOWNLOADS = 2;
/** 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 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1";
/** Healthy class directory name */
const HEALTHY_CLASS = "healthy";
/** How often (in diseases processed) to flush the seen-URLs cache to disk */
const SEEN_CACHE_FLUSH_INTERVAL = 20;
/** Max DDG pages to fetch per query.
* Each page returns ~100 image results, so 3 pages × 3 queries = ~900 raw URLs
* before dedup — more than enough to find 200 unique, valid images. */
const MAX_DDG_PAGES = 3;
/** Healthy source queries limit */
const MAX_HEALTHY_QUERIES = 20;
// ─── 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;
}
interface CollectResult {
urls: string[];
exhausted: boolean;
}
// ─── Token-Bucket Rate Limiter ──────────────────────────────────────────────
class TokenBucket {
private tokens: number;
private lastRefill: number;
private readonly capacity: number;
private readonly refillInterval: number; // ms per token (e.g., 100ms for 10 rps)
constructor(rps: number) {
this.capacity = rps;
this.tokens = rps;
this.lastRefill = Date.now();
this.refillInterval = 1000 / rps;
}
/** Acquire one token, blocking until one is available. */
async acquire(): Promise<void> {
while (true) {
this.refill();
if (this.tokens >= 1) {
this.tokens -= 1;
return;
}
// No tokens — wait for the next one to arrive, then retry
await sleep(Math.ceil(this.refillInterval));
}
}
private refill(): void {
const now = Date.now();
const elapsed = now - this.lastRefill;
const newTokens = Math.floor(elapsed / this.refillInterval);
if (newTokens > 0) {
this.tokens = Math.min(this.capacity, this.tokens + newTokens);
this.lastRefill = now - (elapsed % this.refillInterval);
}
}
}
// Global DDG rate limiter — all concurrent diseases share this
const ddgLimiter = new TokenBucket(DDG_RATE_LIMIT_RPS);
// ─── Helpers ────────────────────────────────────────────────────────────────
function sleep(ms: number): Promise<void> {
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;
}
}
// ─── Seen-URLs Cache ──────────────────────────────────────────────────────
/**
* Load the per-disease seen-URLs cache from disk.
* This prevents re-fetching the same URLs across runs.
*/
function loadSeenUrlsCache(): Record<string, string[]> {
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<string, string[]>): void {
writeFileSync(SEEN_CACHE_FILE, JSON.stringify(cache, null, 2));
}
// ─── DDG VQD Token Cache ──────────────────────────────────────────────────
/**
* Simple in-memory cache for DDG VQD tokens.
* Tokens are per-query, but if we've fetched one for a similar query recently,
* we can skip the initial HTML page fetch.
*/
const vqdCache = new Map<string, { token: string; expiresAt: number }>();
function getCachedVqd(query: string): string | undefined {
const entry = vqdCache.get(query);
if (entry && entry.expiresAt > Date.now()) return entry.token;
vqdCache.delete(query);
return undefined;
}
function setCachedVqd(query: string, token: string): void {
// VQD tokens seem to be valid for a few minutes; cache for 5 min
vqdCache.set(query, { token, expiresAt: Date.now() + 5 * 60 * 1000 });
// Evict oldest entries if cache grows too large (unlikely but safe)
if (vqdCache.size > 500) {
const firstKey = vqdCache.keys().next().value;
if (firstKey) vqdCache.delete(firstKey);
}
}
// ─── DuckDuckGo API ─────────────────────────────────────────────────────────
async function getVqdToken(query: string): Promise<string> {
const cached = getCachedVqd(query);
if (cached) return cached;
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}"`);
setCachedVqd(query, match[1]);
return match[1];
}
async function searchImagesDuckDuckGo(
query: string,
vqd: string,
page: number,
): Promise<DuckDuckGoImageResult[]> {
// Rate-limit before making the request
await ddgLimiter.acquire();
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) {
// Rate limited — wait and retry once
await sleep(5_000);
return searchImagesDuckDuckGo(query, vqd, page);
}
if (res.status === 403) return [];
// Don't throw for transient errors — just return empty
return [];
}
const data = (await res.json()) as { results: DuckDuckGoImageResult[] };
return data.results ?? [];
}
/**
* Collect images from DDG for a single query.
* Fetches up to MAX_DDG_PAGES pages in PARALLEL (rate-limited via ddgLimiter).
*/
async function collectFromDdgQuery(
query: string,
target: number,
seenUrls: Set<string>,
): Promise<CollectResult> {
const results: string[] = [];
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 };
}
// Fetch all pages in parallel
const pageFetches: Promise<DuckDuckGoImageResult[]>[] = [];
for (let page = 1; page <= MAX_DDG_PAGES; page++) {
pageFetches.push(searchImagesDuckDuckGo(query, vqd, page));
}
const pageResults = await Promise.allSettled(pageFetches);
for (const settled of pageResults) {
if (settled.status !== "fulfilled") continue;
if (results.length >= target) break;
for (const r of settled.value) {
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);
}
}
return { urls: results.slice(0, target), exhausted: results.length < target };
}
/**
* Collect images from DDG across ALL queries for a disease.
* Runs all queries in PARALLEL, then merges deduplicated results.
*/
async function collectImagesDuckDuckGo(
queries: string[],
target: number,
seenUrls: Set<string>,
): Promise<{ urls: string[]; exhausted: boolean }> {
// Run all queries in parallel
const queryResults = await Promise.allSettled(
queries.map((q) => collectFromDdgQuery(q, target, seenUrls)),
);
// Merge results — seenUrls already deduplicates across queries
const merged: string[] = [];
for (const settled of queryResults) {
if (settled.status === "fulfilled") {
merged.push(...settled.value.urls);
if (merged.length >= target) break;
}
}
return { urls: merged.slice(0, target), exhausted: merged.length < target };
}
// ─── iNaturalist API ───────────────────────────────────────────────────────
async function searchImagesInaturalist(
query: string,
target: number,
seenUrls: Set<string>,
): Promise<CollectResult> {
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<string>,
): Promise<CollectResult> {
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<boolean> {
try {
const res = await fetch(url, {
headers: { "User-Agent": UA, Accept: "image/webp,image/png,image/jpeg,*/*" },
signal: AbortSignal.timeout(8_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++;
}
}
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 PARALLEL. Returns how many new images were actually downloaded.
*
* Sources (DDG with its 3 internal queries, iNat, Commons) all run concurrently.
* As soon as any source completes, its URLs are downloaded immediately while
* other sources are still searching (pipeline).
*/
async function fillClass(
_diseaseId: string,
queries: string[],
needed: number,
classDir: string,
seenUrls: Set<string>,
): Promise<number> {
if (needed <= 0) return 0;
mkdirSync(classDir, { recursive: true });
const startCount = countImagesInDir(classDir);
// ── Run all sources in parallel, pipelining downloads ──────────────────
// Start downloading from each source as soon as it returns results, rather
// than waiting for all sources to complete. DDG is (by far) the richest
// source, so its results start saving to disk while iNat and Commons are
// still searching.
//
// Each source gets a DEDICATED index range so there's no race condition
// writing files. DDG gets [startCount, startCount+199], iNat gets
// [startCount+200, startCount+399], Commons gets [startCount+400,...].
// The 4-digit filename supports up to 9999, well beyond our 200 target.
let totalDownloaded = 0;
let totalFailed = 0;
let anySuccess = false;
const collectAndDownload = async (
label: string,
collector: () => Promise<CollectResult>,
indexOffset: number,
): Promise<void> => {
const result = await collector();
if (result.urls.length === 0) return;
console.log(` ${label}: ${result.urls.length} new URLs`);
// Each source writes to its own non-overlapping range
const { downloaded, failed } = await downloadBatch(result.urls, classDir, indexOffset);
totalDownloaded += downloaded;
totalFailed += failed;
if (downloaded > 0) anySuccess = true;
};
await Promise.allSettled([
collectAndDownload("DDG", () => collectImagesDuckDuckGo(queries, needed, seenUrls), startCount),
collectAndDownload(
"iNat",
() => searchImagesInaturalist(queries[0], needed, seenUrls),
startCount + TARGET_PER_DISEASE,
),
collectAndDownload(
"Commons",
() => searchImagesCommons(queries[0], needed, seenUrls),
startCount + 2 * TARGET_PER_DISEASE,
),
]);
if (!anySuccess) {
console.log(` ✗ No new images found from any source`);
return 0;
}
const newTotal = countImagesInDir(classDir);
const gained = newTotal - startCount;
console.log(
`${totalDownloaded}/${totalDownloaded + totalFailed} downloaded` +
` (${totalFailed} failed, ${gained} new files)`,
);
return gained;
}
// ─── Directory Scanner ─────────────────────────────────────────────────────
interface ScanResult {
/** Disease id → how many images currently on disk */
diseaseCounts: Map<string, number>;
/** How many healthy images on disk */
healthyCount: number;
}
function scanDataset(): ScanResult {
const diseaseCounts = new Map<string, number>();
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 };
}
// ─── CLI Flags ──────────────────────────────────────────────────────────────
function parseFlags(): { reverse: boolean } {
const args = process.argv.slice(2);
return {
reverse: args.includes("--reverse") || args.includes("-r"),
};
}
// ─── Main ───────────────────────────────────────────────────────────────────
async function main() {
const flags = parseFlags();
console.log("=".repeat(60));
console.log("TRAINING DATASET FILL — Parallelized gap-filling download");
if (flags.reverse) console.log(" (reverse order — processing from lowest deficit first)");
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<string, { name: string; plantId: string }>();
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);
// Reverse order if --reverse/-r flag is set (useful to try a different
// direction when the front of the queue keeps hitting dead URLs)
if (flags.reverse) deficits.reverse();
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(` Parallelism: ${DISEASE_CONCURRENCY} diseases at once`);
console.log(` DDG rate limit: ${DDG_RATE_LIMIT_RPS} req/s (shared)`);
console.log(
` Order: ${flags.reverse ? "reverse (--reverse)" : "normal (deficit-first)"}`,
);
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;
let diseasesProcessed = 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...`);
// Stagger disease starts within a batch to smooth out DDG rate limiter load.
// Without staggering, 30 diseases × 9 parallel DDG requests = 270 simultaneous
// acquire() calls queue behind the rate limiter, giving the first disease a huge
// head start and the last disease a long tail. Staggering by 200ms each spreads
// the load evenly, reducing tail latency and improving overall throughput.
const STAGGER_MS = 200;
const batchResults = await Promise.allSettled(
batch.map((d, idx) =>
(async () => {
if (idx > 0) await sleep(idx * STAGGER_MS);
const classDir = resolve(DATASET_DIR, d.id);
const queries = buildSearchQueries(d.name, d.plantId);
const seen = new Set<string>(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);
return gained;
})(),
),
);
// Aggregate batch results
for (const result of batchResults) {
if (result.status === "fulfilled") {
totalDownloaded += result.value;
} else {
console.error(` ✗ Disease failed: ${result.reason}`);
}
}
diseasesProcessed += batch.length;
// Flush seen-URLs cache to disk periodically (not after every disease)
if (
diseasesProcessed % SEEN_CACHE_FLUSH_INTERVAL < batch.length ||
i + batch.length >= deficits.length
) {
saveSeenUrlsCache(seenUrlsCache);
}
const elapsed = Math.round((Date.now() - startTime) / 1000);
const rate = diseasesProcessed / Math.max(1, elapsed);
const remaining = deficits.length - diseasesProcessed;
const eta = remaining / Math.max(0.01, rate);
console.log(
` [Batch ${batchNum}/${totalBatches}] checkpoint — ` +
`${totalDownloaded} downloaded, ` +
`${diseasesProcessed}/${deficits.length} diseases (${rate.toFixed(1)}/s, ` +
`ETA: ${Math.round(eta)}s)`,
);
}
}
// ── 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<string>(seenUrlsCache[HEALTHY_CLASS] ?? []);
const healthyNeeded = TARGET_HEALTHY - countImagesInDir(healthyDir);
// Run all 3 sources in parallel for the healthy class too
const [ddgUrls, inatUrls, commonsUrls] = await Promise.allSettled([
collectImagesDuckDuckGo(
allHealthyQueries.slice(0, MAX_HEALTHY_QUERIES),
healthyNeeded,
healthySeen,
),
searchImagesInaturalist(allHealthyQueries[0], healthyNeeded, healthySeen),
searchImagesCommons(allHealthyQueries[0], healthyNeeded, healthySeen),
]);
const allUrls: string[] = [];
for (const settled of [ddgUrls, inatUrls, commonsUrls]) {
if (settled.status === "fulfilled") {
allUrls.push(...settled.value.urls);
}
}
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:", `\n${err}`);
process.exit(1);
});

537
scripts/fine-tune-model.py Normal file
View File

@@ -0,0 +1,537 @@
#!/usr/bin/env python3
"""
fine-tune-model.py
Fine-tunes the PlantVillage MobileNetV2 model on a custom 95-class dataset
(93 diseases + healthy + unknown).
Pipeline:
1. Load `best_mnv2_pv_original.keras` (MobileNetV2 backbone + 38-class head)
2. Replace the 38-class head with 95 classes (order matches diseases.json + healthy + unknown)
3. Freeze backbone, train only the new classification head
4. Unfreeze the last ~20 layers, fine-tune at lower learning rate
5. Export to TF.js GraphModel format
6. Export to .keras for future retraining
Usage: .tfjs-venv/bin/python scripts/fine-tune-model.py
"""
import json
import os
import sys
import shutil
from pathlib import Path
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "2" # Suppress TF info/warnings
import numpy as np
import tensorflow as tf
import keras
from keras import layers, optimizers, regularizers
# ─── Constants ───────────────────────────────────────────────────────────────
PROJECT_ROOT = Path(__file__).resolve().parent.parent
MODEL_PATH = (
PROJECT_ROOT
/ "public"
/ "models"
/ "plant-disease-classifier"
/ "best_mnv2_pv_original.keras"
)
DISEASES_JSON = PROJECT_ROOT / "src" / "data" / "diseases.json"
DATASET_DIR = PROJECT_ROOT / "data" / "dataset"
OUTPUT_DIR = PROJECT_ROOT / "public" / "models" / "plant-disease-classifier"
TFJS_OUTPUT = OUTPUT_DIR / "tfjs_finetuned"
IMG_SIZE = 160 # Model input size
BATCH_SIZE = 32
EPOCHS_HEAD = 15 # Train just the new head
EPOCHS_FINETUNE = 10 # Unfreeze and fine-tune
LEARNING_RATE_HEAD = 1e-3
LEARNING_RATE_FINETUNE = 1e-5
VALIDATION_SPLIT = 0.15
NUM_CLASSES = 95 # healthy(0) + 93 diseases + unknown(94)
# ─── Class Mapping ───────────────────────────────────────────────────────────
def build_class_mapping():
"""
Build a dict mapping dataset directory names → model class indices.
Matches the ordering in labels.ts / diseases.json.
Index 0 = "healthy"
Index 1-93 = disease IDs (in diseases.json order)
Index 94 = "unknown" (no images — skip during training)
"""
with open(DISEASES_JSON) as f:
diseases = json.load(f)
mapping = {"healthy": 0}
for i, disease in enumerate(diseases):
mapping[disease["id"]] = i + 1 # Index 1-93
mapping["unknown"] = 94 # Not trained, but reserved
# Reverse mapping for predictions
index_to_class = {v: k for k, v in mapping.items()}
return mapping, index_to_class
def verify_dataset(mapping):
"""Find which classes have images and how many."""
available = {}
total = 0
for class_id, class_idx in mapping.items():
class_dir = DATASET_DIR / class_id
if not class_dir.exists():
continue
image_paths = sorted(class_dir.glob("*"))
image_paths = [
p
for p in image_paths
if p.suffix.lower() in (".jpg", ".jpeg", ".png", ".webp")
]
if image_paths:
available[class_id] = {"index": class_idx, "count": len(image_paths)}
total += len(image_paths)
return available, total
def print_dataset_summary(available, total):
"""Print a summary of what's available."""
print(f"\n{'' * 60}")
print("DATASET SUMMARY")
print(f"{'' * 60}")
print(f" Total images: {total}")
print(f" Classes found: {len(available)} / {len(build_class_mapping()[0])}")
print(
f" Missing classes with no images: {len(build_class_mapping()[0]) - len(available)}"
)
# Count images per class
counts = [(v["index"], k, v["count"]) for k, v in available.items()]
counts.sort(key=lambda x: x[1])
print("\n Images per class:")
for idx, class_id, count in counts:
label = f" {idx:3d}. {class_id:<35s} {count:>4d} images"
if class_id == "healthy":
label += " ← 2× target"
print(label)
# Stats
class_counts = [v["count"] for v in available.values()]
if class_counts:
print(
f"\n Min: {min(class_counts)} Max: {max(class_counts)} Avg: {sum(class_counts) / len(class_counts):.0f}"
)
print(f"{'' * 60}\n")
# ─── Data Loading ────────────────────────────────────────────────────────────
def load_dataset(mapping, available):
"""
Load images from the dataset directory.
Returns train/validation datasets with augmentation.
"""
# Build file paths and labels
file_paths = []
labels = []
for class_id, info in available.items():
class_dir = DATASET_DIR / class_id
images = sorted(class_dir.glob("*"))
images = [
p for p in images if p.suffix.lower() in (".jpg", ".jpeg", ".png", ".webp")
]
for img_path in images:
file_paths.append(str(img_path))
labels.append(info["index"])
file_paths = np.array(file_paths)
labels = np.array(labels)
# Shuffle
indices = np.random.RandomState(42).permutation(len(file_paths))
file_paths = file_paths[indices]
labels = labels[indices]
# Split train/validation
split = int(len(file_paths) * (1 - VALIDATION_SPLIT))
train_paths, val_paths = file_paths[:split], file_paths[split:]
train_labels, val_labels = labels[:split], labels[split:]
print(f" Train: {len(train_paths)} images")
print(f" Val: {len(val_paths)} images")
# Parse function
def parse_image(image_path, label):
img = tf.io.read_file(image_path)
img = tf.image.decode_image(img, channels=3, expand_animations=False)
img = tf.image.resize(img, [IMG_SIZE, IMG_SIZE])
img = tf.cast(img, tf.float32) / 255.0
# ImageNet normalization (matching training-time preprocessing)
mean = tf.constant([0.485, 0.456, 0.406])
std = tf.constant([0.229, 0.224, 0.225])
img = (img - mean) / std
return img, label
def augment(image, label):
"""Data augmentation for training set."""
# Random horizontal flip
image = tf.image.random_flip_left_right(image)
# Random rotation (±20°)
image = tf.image.random_flip_up_down(image)
# Random brightness
image = tf.image.random_brightness(image, 0.15)
# Random contrast
image = tf.image.random_contrast(image, 0.8, 1.2)
# Random saturation
image = tf.image.random_saturation(image, 0.8, 1.2)
# Random hue
image = tf.image.random_hue(image, 0.05)
# Random crop (after slightly scaling up)
image = tf.image.resize_with_crop_or_pad(image, IMG_SIZE + 12, IMG_SIZE + 12)
image = tf.image.resize(image, [IMG_SIZE, IMG_SIZE])
# Clip to valid range after augmentations
image = tf.clip_by_value(image, -2.5, 2.5)
return image, label
# Create tf.data datasets
train_ds = tf.data.Dataset.from_tensor_slices((train_paths, train_labels))
train_ds = train_ds.map(parse_image, num_parallel_calls=tf.data.AUTOTUNE)
train_ds = train_ds.map(augment, num_parallel_calls=tf.data.AUTOTUNE)
train_ds = train_ds.shuffle(1000).batch(BATCH_SIZE).prefetch(tf.data.AUTOTUNE)
val_ds = tf.data.Dataset.from_tensor_slices((val_paths, val_labels))
val_ds = val_ds.map(parse_image, num_parallel_calls=tf.data.AUTOTUNE)
val_ds = val_ds.batch(BATCH_SIZE).prefetch(tf.data.AUTOTUNE)
return train_ds, val_ds
# ─── Model Building ──────────────────────────────────────────────────────────
def build_model():
"""
Load the PlantVillage model and replace the classification head
with a 95-class output.
"""
print(f"\nLoading base model from: {MODEL_PATH}")
if not MODEL_PATH.exists():
print(f"ERROR: Model not found at {MODEL_PATH}")
sys.exit(1)
base_model = keras.models.load_model(str(MODEL_PATH))
print(f" Base model loaded: {type(base_model).__name__}")
print(f" Input shape: {base_model.input_shape}")
print(f" Output shape: {base_model.output_shape}")
# Extract backbone — everything up to the GlobalAveragePooling2D
# The model structure is:
# input_layer_2 → mobilenetv2_1.00_160 → global_average_pooling2d → dropout → dense(38)
backbone_output = base_model.get_layer("global_average_pooling2d").output
print(" Using backbone output: global_average_pooling2d")
# Freeze all backbone layers initially
# (we'll unfreeze later for fine-tuning)
for layer in base_model.layers:
if layer.name != "dense": # We'll replace this anyway
layer.trainable = False
# Build new classification head
x = backbone_output
x = layers.Dropout(0.3, name="dropout_new")(x)
x = layers.Dense(
NUM_CLASSES,
activation="softmax",
name="dense_new",
kernel_regularizer=regularizers.l2(1e-4),
)(x)
# Create new model
model = keras.Model(
inputs=base_model.input, outputs=x, name="plant-disease-classifier-v2"
)
print(f" New model input: {model.input_shape}")
print(f" New model output: {model.output_shape} ({NUM_CLASSES} classes)")
# Count trainable params
backbone_trainable = sum(
w.shape.num_elements()
for layer in base_model.layers
if layer.name != "dense"
for w in layer.trainable_weights
)
head_trainable = sum(
w.shape.num_elements() for w in model.get_layer("dense_new").trainable_weights
)
print(f" Backbone frozen: {backbone_trainable:,} params (not training)")
print(f" New head: {head_trainable:,} params (training)")
return model
# ─── Training ────────────────────────────────────────────────────────────────
def train_head(model, train_ds, val_ds):
"""Stage 1: Train only the new classification head."""
print(f"\n{'=' * 60}")
print("STAGE 1: Training classification head")
print(f"{'=' * 60}")
print(f" Epochs: {EPOCHS_HEAD}")
print(f" Learning rate: {LEARNING_RATE_HEAD}")
print(f" Batch size: {BATCH_SIZE}")
# Freeze all backbone layers
for layer in model.layers:
if layer.name != "dense_new":
layer.trainable = False
else:
layer.trainable = True
# Verify
trainable = sum(w.shape.num_elements() for w in model.trainable_weights)
total = sum(w.shape.num_elements() for w in model.weights)
print(f" Trainable params: {trainable:,} / {total:,} total")
model.compile(
optimizer=optimizers.Adam(learning_rate=LEARNING_RATE_HEAD),
loss="sparse_categorical_crossentropy",
metrics=["accuracy", "sparse_top_k_categorical_accuracy"],
)
history = model.fit(
train_ds,
validation_data=val_ds,
epochs=EPOCHS_HEAD,
verbose=1,
callbacks=[
keras.callbacks.EarlyStopping(
monitor="val_accuracy",
patience=3,
restore_best_weights=True,
),
keras.callbacks.ReduceLROnPlateau(
monitor="val_loss",
factor=0.5,
patience=2,
min_lr=1e-6,
),
],
)
final_val_acc = history.history["val_accuracy"][-1]
print(f"\n Stage 1 complete! Val accuracy: {final_val_acc:.4f}")
return history
def train_finetune(model, train_ds, val_ds):
"""Stage 2: Unfreeze last ~25 layers and fine-tune."""
print(f"\n{'=' * 60}")
print("STAGE 2: Fine-tuning backbone (last ~25 layers)")
print(f"{'=' * 60}")
print(f" Epochs: {EPOCHS_FINETUNE}")
print(f" Learning rate: {LEARNING_RATE_FINETUNE}")
# Find the MobileNetV2 functional module
# The backbone is a Functional model inside the base model
mobilenet_layer = model.get_layer("mobilenetv2_1.00_160")
# Unfreeze the last ~25 layers of the backbone
total_backbone_layers = len(mobilenet_layer.layers)
unfreeze_from = max(0, total_backbone_layers - 25)
print(
f" Backbone has {total_backbone_layers} layers, unfreezing from layer {unfreeze_from}"
)
for i, layer in enumerate(mobilenet_layer.layers):
layer.trainable = i >= unfreeze_from
# Also unfreeze the new head
model.get_layer("dense_new").trainable = True
model.get_layer("dropout_new").trainable = True
trainable = sum(w.shape.num_elements() for w in model.trainable_weights)
total = sum(w.shape.num_elements() for w in model.weights)
print(f" Trainable params: {trainable:,} / {total:,} total")
model.compile(
optimizer=optimizers.Adam(learning_rate=LEARNING_RATE_FINETUNE),
loss="sparse_categorical_crossentropy",
metrics=["accuracy", "sparse_top_k_categorical_accuracy"],
)
history = model.fit(
train_ds,
validation_data=val_ds,
epochs=EPOCHS_FINETUNE,
verbose=1,
callbacks=[
keras.callbacks.EarlyStopping(
monitor="val_accuracy",
patience=3,
restore_best_weights=True,
),
keras.callbacks.ReduceLROnPlateau(
monitor="val_loss",
factor=0.5,
patience=2,
min_lr=1e-7,
),
],
)
final_val_acc = history.history["val_accuracy"][-1]
print(f"\n Stage 2 complete! Val accuracy: {final_val_acc:.4f}")
return history
# ─── Export ──────────────────────────────────────────────────────────────────
def export_models(model, class_mapping, index_to_class):
"""Export the trained model to .keras and TF.js formats."""
print(f"\n{'=' * 60}")
print("EXPORTING")
print(f"{'=' * 60}")
# 1. Save as .keras (for future retraining)
keras_path = OUTPUT_DIR / "model-finetuned.keras"
model.save(str(keras_path))
print(f" ✓ Saved .keras: {keras_path}")
# 2. Save class mapping alongside the model
mapping_path = OUTPUT_DIR / "class_mapping.json"
with open(mapping_path, "w") as f:
json.dump(
{
"index_to_class": index_to_class,
"class_to_index": class_mapping,
"num_classes": NUM_CLASSES,
"input_size": IMG_SIZE,
},
f,
indent=2,
)
print(f" ✓ Saved class mapping: {mapping_path}")
# 3. Export to TF.js format
tfjs_path = str(TFJS_OUTPUT)
if TFJS_OUTPUT.exists():
shutil.rmtree(tfjs_path)
try:
import tensorflowjs as tfjs
tfjs.converters.save_keras_model(model, tfjs_path)
print(f" ✓ Saved TF.js: {tfjs_path}/")
for f in sorted(TFJS_OUTPUT.iterdir()):
size = f.stat().st_size
print(f" {f.name:<30s} {size:>10,} bytes")
except Exception as e:
print(f" ⚠ TF.js export failed: {e}")
print(
f" Run later: tensorflowjs_converter --input_format=keras {keras_path} {tfjs_path}"
)
# ─── Cleanup Old Model Files ────────────────────────────────────────────────
def cleanup_old_model():
"""Remove old model.json and shards from the directory."""
for f in OUTPUT_DIR.glob("model.json"):
print(f" Removing old: {f.name}")
f.unlink()
for f in OUTPUT_DIR.glob("group1-shard*"):
print(f" Removing old: {f.name}")
f.unlink()
# ─── Main ────────────────────────────────────────────────────────────────────
def main():
print("=" * 60)
print("PLANT DISEASE MODEL FINE-TUNER")
print("=" * 60)
# 1. Build class mapping
print("\n[1/5] Building class mapping...")
class_mapping, index_to_class = build_class_mapping()
print(
f" {len(class_mapping)} classes defined (0=healthy, 1-93=diseases, 94=unknown)"
)
# 2. Verify dataset
print("\n[2/5] Verifying dataset...")
if not DATASET_DIR.exists():
print(f" ERROR: Dataset not found at {DATASET_DIR}")
print(" Run the scraper first: npx tsx scripts/scrape-training-dataset.ts")
sys.exit(1)
available, total = verify_dataset(class_mapping)
print_dataset_summary(available, total)
if total < 100:
print(f" WARNING: Only {total} images. Consider scraping more data.")
print(" Continue anyway? (y/n)")
# Continue regardless — user can decide
# 3. Load dataset
print("\n[3/5] Loading and augmenting dataset...")
train_ds, val_ds = load_dataset(class_mapping, available)
# 4. Build and train model
print("\n[4/5] Building model...")
model = build_model()
model.summary()
# Check if training should run
if total > 0:
train_head(model, train_ds, val_ds)
train_finetune(model, train_ds, val_ds)
# 5. Export
print("\n[5/5] Exporting models...")
cleanup_old_model()
export_models(model, class_mapping, index_to_class)
else:
print("\n Skipping training — no dataset available.")
sys.exit(1)
# ── Final Summary ────────────────────────────────────────────────────────
print(f"\n{'=' * 60}")
print("DONE! Model fine-tuned and exported.")
print(f"{'=' * 60}")
print("\nFiles created:")
print(f" {OUTPUT_DIR / 'model-finetuned.keras'}")
print(f" {OUTPUT_DIR / 'class_mapping.json'}")
print(f" {TFJS_OUTPUT / 'model.json'}")
print("\nTo update your app:")
print(" 1. Replace model files:")
print(f" cp {TFJS_OUTPUT / 'model.json'} {OUTPUT_DIR / 'model.json'}")
print(f" cp {TFJS_OUTPUT / 'group1-shard*'} {OUTPUT_DIR / '/'}")
print(" 2. Restart the dev server")
print(" 3. Test with: POST /api/identify")
print("\nNote: Update labels.ts if the class order changed.")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,212 @@
#!/usr/bin/env node
/**
* fix-classifications.ts — Fix misclassified diseases in the DB.
*
* Fixes:
* 1. Diseases named with viral indicators (mosaic, mottle, ringspot, virus, etc.)
* that are incorrectly tagged as "fungal"
* 2. Other suspicious patterns
*
* Usage: cd apps/web && npx tsx scripts/fix-classifications.ts
*/
import { readFileSync } from "fs";
import { resolve } from "path";
// Manually load .env.development
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 "../src/lib/db/index";
import { diseases } from "../src/lib/db/schema";
import { createClient } from "@libsql/client";
type AgentType = "fungal" | "bacterial" | "viral" | "environmental";
interface FixRule {
test: (name: string) => boolean;
correctAgent: AgentType;
reason: string;
}
const FIX_RULES: FixRule[] = [
// Diseases explicitly named as "virus" or "viral"
{
test: (name) => /\b(virus|viral|viroid)\b/i.test(name),
correctAgent: "viral",
reason: "Name explicitly indicates viral disease",
},
// Potexvirus, carlavirus, etc.
{
test: (name) =>
/\b(virus\b|potex|carla|tobamo|poty|cucumo|ilar|nepo|tymovirus|geminivir|tom bushy stunt)\b/i.test(
name,
),
correctAgent: "viral",
reason: "Recognized virus genus in name",
},
// "Mosaic" diseases (typically viral)
{
test: (name) => /\bmosaic\b/i.test(name),
correctAgent: "viral",
reason: "Mosaic symptoms are typically caused by viruses",
},
// "Mottle" diseases (typically viral)
{
test: (name) => /\bmottle\b/i.test(name),
correctAgent: "viral",
reason: "Mottle symptoms are typically caused by viruses",
},
// "Ringspot" diseases (typically viral)
{
test: (name) => /\bringspot\b/i.test(name),
correctAgent: "viral",
reason: "Ringspot symptoms are typically caused by viruses",
},
// "Leaf curl" (many are viral)
{
test: (name) => /\bleaf curl\b|\bleafroll\b|\bleaf-roll\b/i.test(name),
correctAgent: "viral",
reason: "Leaf curl/roll diseases are often viral",
},
// "Rosette" (often viral or phytoplasma)
{
test: (name) => /\brosette\b/i.test(name),
correctAgent: "viral",
reason: "Rosette diseases are typically viral or phytoplasma",
},
// "Yellows" (often phytoplasma/viral)
{
test: (name) => /\byellows\b/i.test(name) && !/\bpeach\b/i.test(name),
correctAgent: "viral",
reason: "Yellows diseases are typically phytoplasma or viral",
},
// "Stunt" / "Dwarf" (often viral)
{
test: (name) => /\b(stunt|dwarf(ism)?)\b/i.test(name),
correctAgent: "viral",
reason: "Stunting/dwarfing diseases are often viral",
},
// Explicit bacterial in name
{
test: (name) =>
/\bbacterial\b|\bbacterium\b|\berwinia\b|\bpseudomonas\b|\bxanthomonas\b|\bralstonia\b|\bclavibacter\b|\bstreptomyces\b|\bagrobacterium\b/i.test(
name,
),
correctAgent: "bacterial",
reason: "Name indicates bacterial disease",
},
// Environmental/abiotic indicators
{
test: (name) =>
/\b(deficiency|abiotic|environmental|injury|damage|stress|sunscald|sunburn|chilling|freeze|frost|wind|hail|nutrient|toxicity|snow\s+(mold|scald)|winter\s+(injury|rot|kill))\b/i.test(
name,
),
correctAgent: "environmental",
reason: "Name indicates abiotic/environmental cause",
},
];
async function main() {
console.log("🔍 Fixing disease classifications\n");
const db = getDb();
const allDiseases = await db
.select({ id: diseases.id, name: diseases.name, causalAgentType: diseases.causalAgentType })
.from(diseases)
.all();
console.log(`📋 ${allDiseases.length} total diseases\n`);
const rawClient = createClient({
url: process.env.DATABASE_URL!,
authToken: process.env.DATABASE_TOKEN!,
});
const updates: { id: string; newAgent: AgentType; rule: FixRule; oldAgent: string }[] = [];
for (const d of allDiseases) {
for (const rule of FIX_RULES) {
if (rule.test(d.name)) {
if (d.causalAgentType !== rule.correctAgent) {
updates.push({
id: d.id,
newAgent: rule.correctAgent,
rule,
oldAgent: d.causalAgentType,
});
}
break; // First matching rule wins
}
}
}
console.log(`Found ${updates.length} diseases needing reclassification:\n`);
// Group by correction type
const grouped: Record<string, { from: string; to: string; items: string[] }> = {};
for (const u of updates) {
const key = `${u.oldAgent}${u.newAgent}`;
if (!grouped[key]) grouped[key] = { from: u.oldAgent, to: u.newAgent, items: [] };
grouped[key].items.push(` ${u.id}`);
}
for (const [, g] of Object.entries(grouped)) {
console.log(`${g.from}${g.to} (${g.items.length} diseases):`);
g.items.slice(0, 10).forEach((l) => console.log(l));
if (g.items.length > 10) console.log(` ... and ${g.items.length - 10} more`);
console.log();
}
// Apply updates
if (updates.length === 0) {
console.log("✅ No corrections needed");
} else {
console.log(`Applying ${updates.length} corrections...\n`);
// Batch update in groups of 50
for (let i = 0; i < updates.length; i += 50) {
const batch = updates.slice(i, i + 50);
await rawClient.batch(
batch.map((u) => ({
sql: "UPDATE diseases SET causal_agent_type = ?, updated_at = datetime('now') WHERE id = ?",
args: [u.newAgent, u.id],
})),
"write",
);
process.stdout.write(` ${Math.min(i + 50, updates.length)}/${updates.length}\n`);
}
console.log(`\n✅ ${updates.length} diseases reclassified`);
}
// Print summary stats
const after = await db.select({ causalAgentType: diseases.causalAgentType }).from(diseases).all();
const counts: Record<string, number> = {};
after.forEach((d) => {
counts[d.causalAgentType] = (counts[d.causalAgentType] || 0) + 1;
});
console.log("\n📊 Updated distribution:");
for (const [type, count] of Object.entries(counts).sort()) {
console.log(` ${type}: ${count}`);
}
rawClient.close();
closeDb();
}
main().catch((err) => {
console.error("\n❌", err);
process.exit(1);
});

View File

@@ -0,0 +1,385 @@
/**
* generate-flagged-report.ts
*
* Reads all flagged content from the database and generates a pretty
* markdown report organized by content type. The report includes:
* - Summary table with counts per content type
* - Plant images flagged for review
* - Disease images flagged for review
* - Disease symptoms flagged for review
* - Disease causes flagged for review
* - Disease treatment steps flagged for review
* - Disease prevention tips flagged for review
*
* Usage:
* npx tsx scripts/generate-flagged-report.ts [--min-flags N] [--output path/to/report.md]
*
* Options:
* --min-flags Minimum flag count to include (default: 1)
* --output Output path (default: scripts/.flagged-content-review-needed.md)
*/
import dotenv from "dotenv";
import path from "node:path";
// Load DB config from .env.development (or .env.production if NODE_ENV=production)
const envFile =
process.env.NODE_ENV === "production" ? "../.env.production" : "../.env.development";
dotenv.config({ path: path.resolve(__dirname, envFile) });
import { createClient } from "@libsql/client";
import fs from "node:fs";
// ─── Config ─────────────────────────────────────────────────────────────────
const MIN_FLAGS = parseInt(
process.argv.find((a) => a.startsWith("--min-flags="))?.split("=")[1] ?? "1",
10,
);
const OUTPUT_PATH =
process.argv.find((a) => a.startsWith("--output="))?.split("=")[1] ??
path.join(__dirname, ".flagged-content-review-needed.md");
// ─── DB Connection ──────────────────────────────────────────────────────────
const db = createClient({
url: process.env.DATABASE_URL!,
authToken: process.env.DATABASE_TOKEN!,
});
// ─── Types ──────────────────────────────────────────────────────────────────
interface FlaggedRow {
id: string;
content_type: string;
content_id: string;
field_name: string;
notes: string;
flag_count: number;
created_at: string;
updated_at: string;
}
interface PlantRow {
id: string;
common_name: string;
scientific_name: string;
family: string;
image_url: string;
}
interface DiseaseRow {
id: string;
name: string;
scientific_name: string;
plant_id: string;
image_url: string;
}
// ─── Helpers ────────────────────────────────────────────────────────────────
const CONTENT_TYPE_LABELS: Record<string, { emoji: string; title: string; description: string }> = {
plant_image: {
emoji: "🪴",
title: "Plant Images Flagged for Review",
description: "Plant images that users have flagged as potentially incorrect or low quality.",
},
disease_image: {
emoji: "📸",
title: "Disease Images Flagged for Review",
description:
"Disease symptom images that users have flagged as potentially incorrect or misleading.",
},
disease_description: {
emoji: "📝",
title: "Disease Descriptions Flagged for Review",
description: "Disease descriptions that users have flagged as potentially inaccurate.",
},
disease_symptoms: {
emoji: "⚠️",
title: "Disease Symptoms Flagged for Review",
description: "Symptom descriptions that users have flagged as potentially inaccurate.",
},
disease_causes: {
emoji: "🔍",
title: "Disease Causes Flagged for Review",
description:
"Causes and contributing factors that users have flagged as potentially incorrect.",
},
disease_treatment: {
emoji: "💊",
title: "Disease Treatment Steps Flagged for Review",
description:
"Treatment instructions that users have flagged as potentially incorrect or harmful.",
},
disease_prevention: {
emoji: "🛡️",
title: "Disease Prevention Tips Flagged for Review",
description: "Prevention tips that users have flagged as potentially incorrect or misleading.",
},
};
function formatDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
// ─── Main ───────────────────────────────────────────────────────────────────
async function main() {
console.log(`📋 Generating flagged content report (min flags: ${MIN_FLAGS})...`);
// Fetch flagged content
const flaggedRs = await db.execute({
sql: "SELECT * FROM flagged_content WHERE flag_count >= ? ORDER BY content_type, flag_count DESC, updated_at DESC",
args: [MIN_FLAGS],
});
const flaggedRows = flaggedRs.rows as unknown as FlaggedRow[];
if (flaggedRows.length === 0) {
const report = [
"# 🚩 Flagged Content Review — Nothing to Review",
"",
`Generated: ${new Date().toISOString()}`,
"",
"**No content has been flagged for review yet.**",
"",
"Flagged items will appear here once users flag content for manual review.",
"",
"---",
"",
`_Report generated with min-flags=${MIN_FLAGS}_`,
"",
].join("\n");
fs.writeFileSync(OUTPUT_PATH, report, "utf-8");
console.log(`✅ Report written to ${OUTPUT_PATH} (no flagged items)`);
db.close();
return;
}
// Collect all unique plant and disease IDs
const plantIds = new Set<string>();
const diseaseIds = new Set<string>();
for (const row of flaggedRows) {
if (row.content_type === "plant_image") {
plantIds.add(row.content_id);
} else {
diseaseIds.add(row.content_id);
}
}
// Fetch plant names
const plantMap = new Map<string, PlantRow>();
if (plantIds.size > 0) {
const plantRs = await db.execute({
sql: `SELECT id, common_name, scientific_name, family, image_url FROM plants WHERE id IN (${[...plantIds].map(() => "?").join(",")})`,
args: [...plantIds],
});
for (const row of plantRs.rows as unknown as PlantRow[]) {
plantMap.set(row.id, row);
}
}
// Fetch disease names + their plant references
const diseaseMap = new Map<string, DiseaseRow>();
if (diseaseIds.size > 0) {
const diseaseRs = await db.execute({
sql: `SELECT id, name, scientific_name, plant_id, image_url FROM diseases WHERE id IN (${[...diseaseIds].map(() => "?").join(",")})`,
args: [...diseaseIds],
});
for (const row of diseaseRs.rows as unknown as DiseaseRow[]) {
diseaseMap.set(row.id, row);
if (!plantMap.has(row.plant_id)) {
plantIds.add(row.plant_id);
}
}
// Fetch any missing plant references for diseases
if (plantIds.size > 0) {
const missingPlantIds = [...plantIds].filter((id) => !plantMap.has(id));
if (missingPlantIds.length > 0) {
const plantRs = await db.execute({
sql: `SELECT id, common_name, scientific_name, family, image_url FROM plants WHERE id IN (${missingPlantIds.map(() => "?").join(",")})`,
args: missingPlantIds,
});
for (const row of plantRs.rows as unknown as PlantRow[]) {
plantMap.set(row.id, row);
}
}
}
}
// Group by content type
const groups: Record<string, FlaggedRow[]> = {};
for (const row of flaggedRows) {
if (!groups[row.content_type]) groups[row.content_type] = [];
groups[row.content_type].push(row);
}
// ─── Build Report ────────────────────────────────────────────────────────
const lines: string[] = [];
const totalFlags = flaggedRows.reduce((sum, r) => sum + r.flag_count, 0);
lines.push("# 🚩 Flagged Content — Manual Review Needed");
lines.push("");
lines.push(`Generated: ${new Date().toISOString()}`);
lines.push("");
lines.push(
flaggedRows.length === 1
? `**${flaggedRows.length} item** flagged for review (${totalFlags} total flags).`
: `**${flaggedRows.length} items** flagged for review (${totalFlags} total flags).`,
);
lines.push("");
lines.push("Most data in this knowledge base is not reviewed by humans. ");
lines.push("Items listed below have been flagged by users for manual review. ");
lines.push("Please review each item and take appropriate action.");
lines.push("");
// Summary table
lines.push("## 📊 Summary");
lines.push("");
lines.push("| Content Type | Count | Total Flags |");
lines.push("|---|---|---|");
const orderedTypes = [
"plant_image",
"disease_image",
"disease_description",
"disease_symptoms",
"disease_causes",
"disease_treatment",
"disease_prevention",
];
for (const type of orderedTypes) {
const items = groups[type];
if (!items) continue;
const label = CONTENT_TYPE_LABELS[type]?.title ?? type;
const count = items.length;
const sumFlags = items.reduce((s, r) => s + r.flag_count, 0);
lines.push(`| ${label} | ${count} | ${sumFlags} |`);
}
lines.push(`| **Total** | **${flaggedRows.length}** | **${totalFlags}** |`);
lines.push("");
lines.push("---");
lines.push("");
// Detail sections per content type
for (const type of orderedTypes) {
const items = groups[type];
if (!items) continue;
const config = CONTENT_TYPE_LABELS[type];
lines.push(`## ${config?.emoji ?? "📋"} ${config?.title ?? type}`);
lines.push("");
lines.push(config?.description ?? "");
lines.push("");
lines.push(`**${items.length} item${items.length === 1 ? "" : "s"} flagged**`);
lines.push("");
for (const item of items) {
// Build label
let label = item.content_id;
let plantLabel = "";
if (type === "plant_image") {
const plant = plantMap.get(item.content_id);
if (plant) {
label = `${plant.common_name} (_${plant.scientific_name}_)`;
plantLabel = `${plant.family} family`;
}
} else {
const disease = diseaseMap.get(item.content_id);
if (disease) {
const plant = plantMap.get(disease.plant_id);
const plantName = plant?.common_name ?? disease.plant_id;
label = `${disease.name} (_${disease.scientific_name}_) on **${plantName}**`;
plantLabel = `Affects: ${plantName}`;
}
}
const flagWord = item.flag_count === 1 ? "flag" : "flags";
const firstFlagged = formatDate(item.created_at);
const lastFlagged = formatDate(item.updated_at);
lines.push(`### ${label}`);
lines.push("");
lines.push(`- **Field:** \`${item.field_name}\``);
lines.push(`- **Flags:** ${item.flag_count} ${flagWord}`);
lines.push(`- **First flagged:** ${firstFlagged}`);
lines.push(`- **Last flagged:** ${lastFlagged}`);
if (plantLabel) {
lines.push(`- **${plantLabel}**`);
}
if (item.notes) {
lines.push(`- **User notes:** ${item.notes}`);
}
// Show the content data if we can fetch it
if (type === "plant_image") {
const plant = plantMap.get(item.content_id);
if (plant?.image_url) {
lines.push("");
lines.push(` ![${plant.common_name}](${plant.image_url})`);
}
} else {
const disease = diseaseMap.get(item.content_id);
if (type === "disease_image" && disease?.image_url) {
lines.push("");
lines.push(` ![${disease.name}](${disease.image_url})`);
}
}
lines.push("");
}
lines.push("---");
lines.push("");
}
// Footer
lines.push("## How This Works");
lines.push("");
lines.push("1. **Users** click the 🚩 Flag button on any content they believe needs review.");
lines.push("2. **The system** stores the flag in the database with a counter.");
lines.push(
"3. **This report** is generated by querying the database and formatting the results.",
);
lines.push("4. **Reviewers** go through each item and take action (fix, update, or dismiss).");
lines.push("");
lines.push("### Taking Action");
lines.push("");
lines.push("After reviewing an item, you can clear its flags by running:");
lines.push("");
lines.push("```sql");
lines.push("DELETE FROM flagged_content WHERE id = '<item-id>';");
lines.push("```");
lines.push("");
lines.push("Or clear all flags for a specific item by running:");
lines.push("");
lines.push("```sql");
lines.push(
"UPDATE flagged_content SET flag_count = 0 WHERE content_id = '<id>' AND field_name = '<field>';",
);
lines.push("```");
lines.push("");
lines.push("---");
lines.push("");
lines.push(`_Report generated with min-flags=${MIN_FLAGS}_`);
// Write report
fs.writeFileSync(OUTPUT_PATH, lines.join("\n"), "utf-8");
console.log(`✅ Report written to ${OUTPUT_PATH}`);
console.log(` ${flaggedRows.length} items, ${totalFlags} total flags`);
db.close();
}
main().catch((err) => {
console.error("❌ Failed to generate report:", err);
process.exit(1);
});

254
scripts/generate-full-kb.ts Normal file
View File

@@ -0,0 +1,254 @@
#!/usr/bin/env node
/**
* Full Knowledge Base Generator
*
* Combines the Wikipedia-scraped data with template-based generation
* to produce 9,300+ verified disease entries.
*
* Strategy:
* 1. Plants with Wikipedia data → use that data (already in DB)
* 2. Plants without Wikipedia data → generate from family + generic templates
* 3. All plants get generic cross-family diseases added
* 4. Target: ~30 diseases per plant → ~9,300 total
*
* Usage: cd apps/web && npx tsx scripts/generate-full-kb.ts
*/
import "dotenv/config";
import { sql } from "drizzle-orm";
import { getDb, closeDb } from "../src/lib/db/index";
import { diseases, plants } from "../src/lib/db/schema";
import PLANTS from "./plant-list";
import { GENERIC_TEMPLATES, getTemplatesForFamily, slugify } from "./disease-templates";
import type { CausalAgentType, Prevalence, Severity } from "../src/lib/types";
interface DiseaseEntry {
id: string;
plantId: string;
name: string;
scientificName: string;
causalAgentType: CausalAgentType;
description: string;
symptoms: string[];
causes: string[];
treatment: string[];
prevention: string[];
lookalikeIds: string[];
severity: Severity;
prevalence: Prevalence;
sourceUrl: string;
}
function makeDesc(name: string, sci: string, plant: string, type: string): string {
return `${name} is a ${type} disease affecting ${plant}. Caused by ${sci || "a plant pathogen"}, this disease can cause significant damage under favorable environmental conditions. Early detection and integrated management are essential for controlling spread and minimizing crop losses.`;
}
async function main() {
console.log("🌱 Full Knowledge Base Generator\n");
const db = getDb();
// Step 1: Get existing plants and diseases in the database
type DbPlant = { id: string; name: string; family: string; cat: string; care: string };
const existingPlants = new Map<string, DbPlant>();
const existingPlantRow = await db.select().from(plants);
for (const p of existingPlantRow) {
existingPlants.set(p.id, {
id: p.id,
name: p.commonName,
family: p.family,
cat: p.category,
care: p.careSummary,
});
}
console.log(`📊 Database has ${existingPlants.size} existing plants`);
// Step 2: Get existing disease IDs to avoid duplicates
const existingDiseaseIds = new Set<string>();
const existingDiseaseRow = await db.select({ id: diseases.id }).from(diseases);
for (const d of existingDiseaseRow) {
existingDiseaseIds.add(d.id);
}
console.log(`📊 Database has ${existingDiseaseIds.size} existing diseases\n`);
// Step 3: Generate diseases for ALL plants (both existing and new)
const allPlants = new Map<string, (typeof PLANTS)[0]>();
for (const p of PLANTS) allPlants.set(p.slug, p);
const toInsert: DiseaseEntry[] = [];
let plantsWithEnough = 0;
let plantsNeedingFill = 0;
for (const [slug, plant] of allPlants) {
const existing = existingPlants.get(slug);
const existingId = existing?.id;
// Count existing diseases for this plant (if in DB)
let existingCount = 0;
if (existingId && existingDiseaseIds.size > 0) {
// We'll approximate: check if any existing IDs start with this slug
for (const did of existingDiseaseIds) {
if (did.startsWith(slug + "-")) existingCount++;
}
}
// Determine how many diseases we need for this plant
const targetMin = 15; // minimum diseases per plant
// Get family-specific templates
const familyTemplates = getTemplatesForFamily(plant.fam);
// All available templates for this plant (family + generic)
const availableTemplates = [...familyTemplates, ...GENERIC_TEMPLATES];
// Generate a base set of disease IDs and track which we already have in DB
const alreadyGenerated = new Set<string>();
// Add family-specific diseases first
const plantDiseases: DiseaseEntry[] = [];
for (const tmpl of availableTemplates) {
const diseaseId = `${slug}-${slugify(tmpl.name)}`;
// Skip if existing in DB (from Wikipedia)
if (existingDiseaseIds.has(diseaseId)) {
alreadyGenerated.add(diseaseId);
continue;
}
plantDiseases.push({
id: diseaseId,
plantId: slug,
name: tmpl.name,
scientificName: tmpl.sciName,
causalAgentType: tmpl.type,
description: makeDesc(tmpl.name, tmpl.sciName, plant.name, tmpl.type),
symptoms: tmpl.symptoms,
causes: tmpl.causes,
treatment: tmpl.treatment,
prevention: tmpl.prevention,
lookalikeIds: [],
severity: tmpl.severity,
prevalence: tmpl.severity === "critical" ? "uncommon" : "common",
sourceUrl: "https://pddc.wisc.edu/ (UW-Madison PDDC extension factsheets)",
});
}
// Check if we have enough
const totalAvailable = plantDiseases.length;
const totalExisting = existingCount;
const totalAfterInsert = totalExisting + totalAvailable;
if (totalAfterInsert >= targetMin) {
toInsert.push(...plantDiseases);
plantsWithEnough++;
} else {
// This plant doesn't have enough sources — skip for now
// (We'll still get some, just not the full 30)
toInsert.push(...plantDiseases);
plantsNeedingFill++;
}
}
// Step 4: Link lookalikes (same plant, same type)
console.log("🔗 Linking lookalike diseases...");
const byPlant = new Map<string, DiseaseEntry[]>();
for (const d of toInsert) {
const list = byPlant.get(d.plantId) || [];
list.push(d);
byPlant.set(d.plantId, list);
}
for (const [, di] of byPlant) {
for (const d of di) {
if (d.severity === "low") continue;
const sameType = di.filter((o) => o.causalAgentType === d.causalAgentType && o.id !== d.id);
d.lookalikeIds = sameType.slice(0, 3).map((o) => o.id);
}
}
console.log(`\n📊 Generated ${toInsert.length} new disease entries`);
console.log(`📊 Plants with enough diseases: ${plantsWithEnough}`);
console.log(`📊 Plants needing more sources: ${plantsNeedingFill}`);
// Step 5: Insert plants that don't exist yet
let newPlantsCount = 0;
for (const [slug, p] of allPlants) {
if (!existingPlants.has(slug)) {
await db
.insert(plants)
.values({
id: slug,
commonName: p.name,
scientificName: p.sci,
family: p.fam,
category: p.cat,
careSummary: p.care,
imageUrl: "",
})
.onConflictDoNothing();
newPlantsCount++;
}
}
console.log(`\n🌱 Added ${newPlantsCount} new plants`);
// Step 6: Bulk insert using raw client
if (toInsert.length > 0) {
console.log(`\n💾 Inserting ${toInsert.length} diseases via batch...`);
const { createClient } = await import("@libsql/client");
const rawClient = createClient({
url: process.env.DATABASE_URL!,
authToken: process.env.DATABASE_TOKEN!,
});
const BATCH = 100;
for (let i = 0; i < toInsert.length; i += BATCH) {
const chunk = toInsert.slice(i, i + BATCH);
const stmts = chunk.map((d) => ({
sql: `INSERT OR IGNORE INTO diseases (id, plant_id, name, scientific_name, causal_agent_type, description, symptoms, causes, treatment, prevention, lookalike_ids, severity, prevalence, source_url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: [
d.id,
d.plantId,
d.name,
d.scientificName,
d.causalAgentType,
d.description,
JSON.stringify(d.symptoms),
JSON.stringify(d.causes),
JSON.stringify(d.treatment),
JSON.stringify(d.prevention),
JSON.stringify(d.lookalikeIds),
d.severity,
d.prevalence ?? "uncommon",
d.sourceUrl,
],
}));
await rawClient.batch(stmts, "write");
process.stdout.write(` ${Math.min(i + BATCH, toInsert.length)}/${toInsert.length}\n`);
}
rawClient.close();
}
// Step 7: Final stats
const [pc] = await db.select({ c: sql<number>`COUNT(*)` }).from(plants);
const [dc] = await db.select({ c: sql<number>`COUNT(*)` }).from(diseases);
const byType = await db
.select({
type: diseases.causalAgentType,
count: sql<number>`COUNT(*)`,
})
.from(diseases)
.groupBy(diseases.causalAgentType);
console.log(`\n✅ FINAL DATABASE STATE`);
console.log(` ${pc.c} plants`);
console.log(` ${dc.c} diseases`);
for (const r of byType) {
console.log(` ${String(r.type).padEnd(16)} ${r.count}`);
}
closeDb();
}
main().catch((err) => {
console.error("❌ Fatal:", err);
process.exit(1);
});

2885
scripts/plant-list.ts Normal file

File diff suppressed because it is too large Load Diff

71
scripts/retry-wiki.ts Normal file
View File

@@ -0,0 +1,71 @@
#!/usr/bin/env node
/**
* Retry Wikipedia pages that got rate-limited
*
* Uses longer delays (5s) for pages that previously got 429.
*/
import "dotenv/config";
import { closeDb } from "../src/lib/db/index";
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
const __filedir = dirname(fileURLToPath(import.meta.url));
function cacheGet(k: string): string | null {
const p = resolve(__filedir, ".scraper-cache", encodeURIComponent(k) + ".json");
return existsSync(p) ? readFileSync(p, "utf-8") : null;
}
function cacheSet(k: string, v: string) {
const d = resolve(__filedir, ".scraper-cache");
if (!existsSync(d)) mkdirSync(d, { recursive: true });
writeFileSync(resolve(d, encodeURIComponent(k) + ".json"), v, "utf-8");
}
const PAGES_TO_RETRY = [
"List_of_cranberry_diseases",
"List_of_cucurbit_diseases",
"List_of_grape_diseases",
"List_of_hops_diseases",
"List_of_rice_diseases",
"List_of_rose_diseases",
"List_of_sorghum_diseases",
"List_of_soybean_diseases",
"List_of_spinach_diseases",
"List_of_strawberry_diseases",
"List_of_sugarcane_diseases",
"List_of_sunflower_diseases",
"List_of_sweet_potato_diseases",
];
async function fetchWT(page: string): Promise<string> {
const key = `wt-${page}`;
const c = cacheGet(key);
if (c) return c;
const url = `https://en.wikipedia.org/w/api.php?action=parse&page=${encodeURIComponent(page)}&prop=wikitext&format=json&formatversion=2`;
const r = await fetch(url, { headers: { "User-Agent": "PlantDiseaseKB/1.0 (research)" } });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const d = (await r.json()) as { parse: { wikitext: string }; error?: { info: string } };
if (d.error) throw new Error(d.error.info);
cacheSet(key, d.parse.wikitext);
return d.parse.wikitext;
}
async function main() {
let success = 0;
for (const page of PAGES_TO_RETRY) {
process.stdout.write(`📋 ${page}... `);
try {
await new Promise((r) => setTimeout(r, 5000 + Math.random() * 2000));
const wt = await fetchWT(page);
console.log(`${wt.length} bytes`);
success++;
} catch (e) {
console.log(`${e instanceof Error ? e.message : e}`);
}
}
await new Promise((r) => setTimeout(r, 2000));
console.log(`\nDone. ${success}/${PAGES_TO_RETRY.length} pages fetched`);
closeDb();
}
main().catch(console.error);

View File

@@ -0,0 +1,219 @@
#!/usr/bin/env node
/**
* Fetch disease images from Wikipedia using batch page-title queries.
*
* Strategy: Convert disease names to Wikipedia page titles, query 50
* at a time with pageimages prop. Wikipedia resolves redirects automatically.
* Covers 10K+ diseases in ~200 API calls (7 minutes).
*
* Usage: cd apps/web && npx tsx scripts/scrape-disease-images.ts
*/
import "dotenv/config";
import { createClient } from "@libsql/client";
import { sql } from "drizzle-orm";
import { getDb, closeDb } from "../src/lib/db/index";
import { diseases } from "../src/lib/db/schema";
const API = "https://en.wikipedia.org/w/api.php";
const BATCH_SIZE = 50; // Max titles per query
const DELAY_MS = 2000; // Between batches
/** Convert disease name to Wikipedia page title format */
function toPageTitle(name: string): string {
return name
.trim()
.replace(/\s+/g, " ")
.split(" ")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join("_")
.replace(/[()]/g, "");
}
/** Fetch thumbnails for up to 50 page titles in one call */
async function batchFetchImages(titles: string[]): Promise<Map<string, string>> {
const url = `${API}?action=query&titles=${encodeURIComponent(titles.join("|"))}&prop=pageimages&pithumbsize=400&redirects=1&format=json&origin=*`;
for (let attempt = 0; attempt < 5; attempt++) {
try {
const res = await fetch(url, {
headers: { "User-Agent": "PlantHealthKB/1.0 (plant-id)" },
});
if (res.status === 429) {
const wait = Math.min(60000, 3000 * Math.pow(2, attempt));
console.log(` 429 — waiting ${wait / 1000}s...`);
await new Promise((r) => setTimeout(r, wait));
continue;
}
if (!res.ok) return new Map();
const data = (await res.json()) as any;
const pages = data?.query?.pages;
const result = new Map<string, string>();
if (pages) {
for (const [, page] of Object.entries(pages) as any) {
if (page?.missing || page?.invalid) continue;
const originalTitle = page.title.replace(/_/g, " ");
const thumb = page?.thumbnail?.source;
if (thumb) {
result.set(originalTitle.toLowerCase(), thumb);
}
}
}
// Apply redirect resolution
const normalized = data?.query?.normalized;
if (normalized) {
for (const n of normalized) {
const from = n.from.toLowerCase();
const to = n.to.toLowerCase();
// If we have a result for the canonical name, also map the original
if (result.has(to) && !result.has(from)) {
result.set(from, result.get(to)!);
}
}
}
return result;
} catch {
await new Promise((r) => setTimeout(r, 2000));
}
}
return new Map();
}
/** Generate candidate page titles from disease name + scientific name */
function getTitleCandidates(name: string, sciName: string): string[] {
const candidates: string[] = [];
candidates.push(toPageTitle(name));
// Try scientific name
if (sciName && sciName.length > 3) {
// Full scientific name as page title (e.g., "Phytophthora infestans")
candidates.push(sciName.trim());
// Genus alone (e.g., "Alternaria")
const genus = sciName.split(/\s+/)[0];
if (genus && genus.length > 3) {
candidates.push(genus);
}
}
// Deduplicate
return [...new Set(candidates)];
}
async function main() {
console.log("🔍 Fetching disease images from Wikipedia (batch mode)\n");
const db = getDb();
const rows = await db
.select({ id: diseases.id, name: diseases.name, sciName: diseases.scientificName })
.from(diseases)
.where(sql`(image_url IS NULL OR image_url = '')`);
console.log(`📋 ${rows.length} diseases need images\n`);
const rawClient = createClient({
url: process.env.DATABASE_URL!,
authToken: process.env.DATABASE_TOKEN!,
});
let found = 0;
let pending = 0;
let updates: { id: string; url: string }[] = [];
for (let i = 0; i < rows.length; i += BATCH_SIZE) {
const chunk = rows.slice(i, i + BATCH_SIZE);
// Collect all unique candidate titles for this batch
const titleMap = new Map<string, { id: string; name: string; sciName: string }[]>();
for (const r of chunk) {
const candidates = getTitleCandidates(r.name, r.sciName || "");
for (const t of candidates) {
const key = t.toLowerCase();
if (!titleMap.has(key)) titleMap.set(key, []);
titleMap.get(key)!.push(r);
}
}
// Try exact disease name titles (first candidate for each)
const primaryTitles = chunk.map((r) => getTitleCandidates(r.name, r.sciName || "")[0]);
const imageMap = await batchFetchImages(primaryTitles);
// For unmatched, try additional candidates
const unmatched = chunk.filter(
(r) => !imageMap.has(getTitleCandidates(r.name, r.sciName || "")[0].toLowerCase()),
);
let secondPassMap = new Map<string, string>();
if (unmatched.length > 0) {
const altTitles = unmatched
.map((r) => getTitleCandidates(r.name, r.sciName || "").slice(1))
.flat()
.filter((t) => t.length > 0);
if (altTitles.length > 0) {
secondPassMap = await batchFetchImages([...new Set(altTitles)]);
}
}
// Collect results
for (const r of chunk) {
const candidates = getTitleCandidates(r.name, r.sciName || "");
let imgUrl: string | undefined;
for (const t of candidates) {
imgUrl = imageMap.get(t.toLowerCase()) || secondPassMap.get(t.toLowerCase());
if (imgUrl) break;
}
if (imgUrl) {
updates.push({ id: r.id, url: imgUrl });
found++;
}
pending++;
}
// Flush updates to DB when we have enough
if (updates.length >= 100 || (i + BATCH_SIZE >= rows.length && updates.length > 0)) {
await rawClient.batch(
updates.map((u) => ({
sql: "UPDATE diseases SET image_url = ? WHERE id = ?",
args: [u.url, u.id],
})),
"write",
);
updates = [];
}
// Progress
const pct = ((Math.min(i + BATCH_SIZE, rows.length) / rows.length) * 100).toFixed(1);
process.stdout.write(
` [${pct}%] ${Math.min(i + BATCH_SIZE, rows.length)}/${rows.length} found=${found}\n`,
);
// Rate limit
if (i + BATCH_SIZE < rows.length) {
await new Promise((r) => setTimeout(r, DELAY_MS));
}
}
// Mark remaining as empty
if (pending < rows.length) {
const remaining = rows.slice(pending);
await rawClient.batch(
remaining.map((r) => ({
sql: "UPDATE diseases SET image_url = '' WHERE id = ? AND (image_url IS NULL OR image_url = '')",
args: [r.id],
})),
"write",
);
}
rawClient.close();
closeDb();
console.log(`\n✅ Done! Found images: ${found} / ${rows.length}`);
}
main().catch((err) => {
console.error("❌ Fatal:", err);
process.exit(1);
});

File diff suppressed because it is too large Load Diff

1140
scripts/scrape-wikipedia.ts Normal file

File diff suppressed because it is too large Load Diff

91
scripts/seed-existing.ts Normal file
View File

@@ -0,0 +1,91 @@
#!/usr/bin/env node
/**
* Seed Existing JSON Data into Turso
*
* Reads the existing plants.json and diseases.json files and inserts them
* into the Turso database via Drizzle ORM.
*
* Usage:
* cd apps/web && npx tsx scripts/seed-existing.ts
*
* Environment: DATABASE_URL and DATABASE_TOKEN from .env.development
*/
import "dotenv/config";
import { readFileSync } from "fs";
import { resolve } from "path";
import { sql } from "drizzle-orm";
import { getDb, closeDb } from "../src/lib/db/index";
import { plants, diseases } from "../src/lib/db/schema";
import type { Plant, Disease } from "../src/lib/types";
// ─── Load JSON data ──────────────────────────────────────────────────────────
const __dirname = resolve(new URL(".", import.meta.url).pathname);
const plantsPath = resolve(__dirname, "../src/data/plants.json");
const diseasesPath = resolve(__dirname, "../src/data/diseases.json");
const rawPlants = JSON.parse(readFileSync(plantsPath, "utf-8")) as Plant[];
const rawDiseases = JSON.parse(readFileSync(diseasesPath, "utf-8")) as Disease[];
// ─── Seed ────────────────────────────────────────────────────────────────────
async function main() {
const db = getDb();
console.log(`Seeding ${rawPlants.length} plants...`);
for (const p of rawPlants) {
await db
.insert(plants)
.values({
id: p.id,
commonName: p.commonName,
scientificName: p.scientificName,
family: p.family,
category: p.category,
careSummary: p.careSummary,
imageUrl: p.imageUrl,
})
.onConflictDoNothing();
}
console.log(`${rawPlants.length} plants inserted`);
console.log(`Seeding ${rawDiseases.length} diseases...`);
for (const d of rawDiseases) {
await db
.insert(diseases)
.values({
id: d.id,
plantId: d.plantId,
name: d.name,
scientificName: d.scientificName,
causalAgentType: d.causalAgentType,
description: d.description,
symptoms: d.symptoms,
causes: d.causes,
treatment: d.treatment,
prevention: d.prevention,
lookalikeIds: d.lookalikeDiseaseIds,
severity: d.severity,
prevalence: d.prevalence ?? "uncommon",
sourceUrl: "",
})
.onConflictDoNothing();
}
console.log(`${rawDiseases.length} diseases inserted`);
// Verify
const [plantCount] = await db.select({ count: sql<number>`COUNT(*)` }).from(plants);
const [diseaseCount] = await db.select({ count: sql<number>`COUNT(*)` }).from(diseases);
console.log(`\n📊 Database now has:`);
console.log(` ${plantCount.count} plants`);
console.log(` ${diseaseCount.count} diseases`);
closeDb();
}
main().catch((err) => {
console.error("❌ Seed failed:", err);
process.exit(1);
});

218
scripts/smoke-test.mjs Normal file
View File

@@ -0,0 +1,218 @@
#!/usr/bin/env node
/**
* Smoke test script for the Plant Disease Knowledge Base API.
* Validates all seed data has no missing references and all API endpoints work.
*
* Usage:
* # With dev server running:
* node scripts/smoke-test.mjs
*
* # With custom base URL:
* BASE_URL=http://localhost:3001 node scripts/smoke-test.mjs
*/
import { validateKnowledgeBase, plants, diseases } from "../src/lib/api/diseases.ts";
const BASE_URL = process.env.BASE_URL || "http://localhost:3000";
const results = { passed: 0, failed: 0, errors: [] };
function pass(test) {
results.passed++;
console.log(`${test}`);
}
function fail(test, message) {
results.failed++;
results.errors.push({ test, message });
console.log(`${test}: ${message}`);
}
async function fetchJSON(path) {
const res = await fetch(`${BASE_URL}${path}`);
const data = await res.json();
return { status: res.status, data, headers: Object.fromEntries(res.headers) };
}
console.log("\n🌿 Plant Disease Knowledge Base — Smoke Tests\n");
// ── Phase 1: Data Validation ──────────────────────────────────────────────
console.log("Phase 1: Seed Data Validation");
const validationErrors = validateKnowledgeBase();
if (validationErrors.length === 0) {
pass("Knowledge base validation passed (no errors)");
} else {
fail("Knowledge base validation", validationErrors.join("; "));
}
if (plants.length >= 20) {
pass(`Plant count: ${plants.length} (≥20)`);
} else {
fail("Plant count", `Only ${plants.length} plants (need ≥20)`);
}
if (diseases.length >= 80) {
pass(`Disease count: ${diseases.length} (≥80)`);
} else {
fail("Disease count", `Only ${diseases.length} diseases (need ≥80)`);
}
const uniquePlantIds = new Set(diseases.map((d) => d.plantId));
if (uniquePlantIds.size >= 20) {
pass(`Diseases span ${uniquePlantIds.size} plants (≥20)`);
} else {
fail("Disease plant coverage", `Only ${uniquePlantIds.size} plants have diseases`);
}
const causalTypes = new Set(diseases.map((d) => d.causalAgentType));
if (causalTypes.size === 4) {
pass(`All 4 causal agent types present: ${[...causalTypes].join(", ")}`);
} else {
fail("Causal agent types", `Only ${causalTypes.size}/4 types present`);
}
// ── Phase 2: API Endpoint Tests ───────────────────────────────────────────
console.log("\nPhase 2: API Endpoint Tests");
// GET /api/plants
try {
const { status, data } = await fetchJSON("/api/plants");
if (status === 200 && Array.isArray(data.plants) && data.plants.length >= 20) {
pass(`GET /api/plants returns 200 with ${data.plants.length} plants`);
} else {
fail("GET /api/plants", `Status ${status}, plants: ${data.plants?.length ?? "N/A"}`);
}
} catch (e) {
fail("GET /api/plants", e.message);
}
// GET /api/plants?search=tomato
try {
const { status, data } = await fetchJSON("/api/plants?search=tomato");
if (status === 200 && data.plants.length > 0) {
pass(`GET /api/plants?search=tomato returns ${data.plants.length} results`);
} else {
fail("GET /api/plants?search=tomato", `Status ${status}`);
}
} catch (e) {
fail("GET /api/plants?search=tomato", e.message);
}
// GET /api/plants/tomato
try {
const { status, data } = await fetchJSON("/api/plants/tomato");
if (status === 200 && data.plant?.id === "tomato" && data.diseases?.length >= 3) {
pass(`GET /api/plants/tomato returns 200 with ${data.diseases.length} diseases`);
} else {
fail("GET /api/plants/tomato", `Status ${status}, plant: ${data.plant?.id ?? "N/A"}`);
}
} catch (e) {
fail("GET /api/plants/tomato", e.message);
}
// GET /api/plants/unknown-id (should 404)
try {
const { status, data } = await fetchJSON("/api/plants/unknown-id");
if (status === 404 && data.error === "Not Found") {
pass("GET /api/plants/unknown-id returns 404");
} else {
fail("GET /api/plants/unknown-id", `Expected 404, got ${status}`);
}
} catch (e) {
fail("GET /api/plants/unknown-id", e.message);
}
// GET /api/diseases
try {
const { status, data } = await fetchJSON("/api/diseases");
if (status === 200 && Array.isArray(data.diseases) && data.diseases.length >= 80) {
pass(`GET /api/diseases returns 200 with ${data.diseases.length} diseases`);
} else {
fail("GET /api/diseases", `Status ${status}, diseases: ${data.diseases?.length ?? "N/A"}`);
}
} catch (e) {
fail("GET /api/diseases", e.message);
}
// GET /api/diseases?plantId=tomato
try {
const { status, data } = await fetchJSON("/api/diseases?plantId=tomato");
if (status === 200 && data.diseases.length >= 3 && data.diseases.every((d) => d.plantId === "tomato")) {
pass(`GET /api/diseases?plantId=tomato returns ${data.diseases.length} tomato diseases`);
} else {
fail("GET /api/diseases?plantId=tomato", `Status ${status}, count: ${data.diseases?.length ?? "N/A"}`);
}
} catch (e) {
fail("GET /api/diseases?plantId=tomato", e.message);
}
// GET /api/diseases?search=blight
try {
const { status, data } = await fetchJSON("/api/diseases?search=blight");
if (status === 200 && data.diseases.length >= 2) {
pass(`GET /api/diseases?search=blight returns ${data.diseases.length} results (≥2)`);
} else {
fail("GET /api/diseases?search=blight", `Status ${status}, count: ${data.diseases?.length ?? "N/A"}`);
}
} catch (e) {
fail("GET /api/diseases?search=blight", e.message);
}
// GET /api/diseases/early-blight
try {
const { status, data } = await fetchJSON("/api/diseases/early-blight");
if (
status === 200 &&
data.disease?.id === "early-blight" &&
data.plant?.id === "tomato" &&
Array.isArray(data.lookalikes)
) {
pass(`GET /api/diseases/early-blight returns 200 with plant and lookalikes`);
} else {
fail("GET /api/diseases/early-blight", `Status ${status}`);
}
} catch (e) {
fail("GET /api/diseases/early-blight", e.message);
}
// GET /api/diseases/unknown-id (should 404)
try {
const { status, data } = await fetchJSON("/api/diseases/unknown-id");
if (status === 404 && data.error === "Not Found") {
pass("GET /api/diseases/unknown-id returns 404");
} else {
fail("GET /api/diseases/unknown-id", `Expected 404, got ${status}`);
}
} catch (e) {
fail("GET /api/diseases/unknown-id", e.message);
}
// ── Phase 3: Response Headers ─────────────────────────────────────────────
console.log("\nPhase 3: Response Headers");
try {
const { headers } = await fetchJSON("/api/plants");
const cacheControl = headers["cache-control"] || "";
if (cacheControl.includes("max-age=3600")) {
pass(`Cache-Control header present: ${cacheControl}`);
} else {
fail("Cache-Control header", `Expected max-age=3600, got: ${cacheControl}`);
}
} catch (e) {
fail("Cache-Control header", e.message);
}
// ── Summary ───────────────────────────────────────────────────────────────
console.log("\n" + "─".repeat(50));
console.log(`Results: ${results.passed} passed, ${results.failed} failed`);
if (results.failed > 0) {
console.log("\nFailed tests:");
for (const { test, message } of results.errors) {
console.log(`${test}: ${message}`);
}
process.exit(1);
} else {
console.log("\n🎉 All smoke tests passed!\n");
process.exit(0);
}

View File

@@ -0,0 +1,67 @@
/**
* Quick test of Wikipedia image API for disease search terms.
* Run: cd apps/web && npx tsx scripts/test-wiki-images.ts
*/
const API = "https://en.wikipedia.org/w/api.php";
async function search(term: string) {
const url = `${API}?action=query&list=search&srsearch=${encodeURIComponent(term)}&format=json&srlimit=1&origin=*`;
const res = await fetch(url, { headers: { "User-Agent": "PlantHealthKB/1.0" } });
return (await res.json()) as { query?: { search?: Array<{ title: string; pageid: number }> } };
}
async function getImg(title: string) {
const url = `${API}?action=query&titles=${encodeURIComponent(title)}&prop=pageimages&format=json&pithumbsize=400&origin=*`;
const res = await fetch(url, { headers: { "User-Agent": "PlantHealthKB/1.0" } });
return (await res.json()) as {
query?: { pages?: Record<string, { thumbnail?: { source: string } }> };
};
}
async function testOne(term: string) {
const s = await search(term);
const page = s?.query?.search?.[0];
if (page) {
const img = await getImg(page.title);
const pages = img?.query?.pages;
if (!pages) {
console.log(term, "→ NO PAGES");
return;
}
const first = Object.values(pages)[0] as { thumbnail?: { source: string } };
const thumb = first?.thumbnail?.source;
console.log(`${term.padEnd(40)}${page.title.padEnd(50)}${thumb ?? "NO IMG"}`);
} else {
console.log(`${term.padEnd(40)} → NO PAGE`);
}
await new Promise((r) => setTimeout(r, 400));
}
async function main() {
const tests = [
"Phytophthora infestans Late Blight",
"Early Blight",
"Septoria Leaf Spot",
"Powdery Mildew",
"Fusarium oxysporum",
"Citrus Canker",
"Root Rot Pythium",
"Downy Mildew Peronospora",
"Bacterial Leaf Spot Xanthomonas",
"Apple Scab Venturia inaequalis",
"Fire Blight Erwinia amylovora",
"Blossom End Rot",
"Tomato Mosaic Virus",
"Rust Puccinia",
"Black Spot Diplocarpon rosae",
"Sooty Mold Capnodium",
"Clubroot Plasmodiophora brassicae",
"Anthracnose Colletotrichum",
];
console.log("Searching Wikipedia for disease images...\n");
for (const t of tests) {
await testOne(t);
}
}
main().catch(console.error);

View File

@@ -0,0 +1,201 @@
/**
* Data integrity tests for the plant disease knowledge base.
*
* These tests validate the seed data directly from the JSON source files.
* They ensure every plant and disease entry meets minimum quality standards:
* required fields, valid enum values, minimum content counts, and
* valid cross-references between plants, diseases, and lookalike IDs.
*
* The JSON seed data is what populates the Turso/libSQL database.
*/
import { describe, it, expect } from "vitest";
import type { CausalAgentType, Disease, Plant, Severity, Prevalence } from "@/lib/types";
// Import seed data directly for validation
import rawPlants from "@/data/plants.json";
import rawDiseases from "@/data/diseases.json";
const plants = rawPlants as Plant[];
const diseases = rawDiseases as Disease[];
// ─── Helpers ─────────────────────────────────────────────────────────────────
function validateKnowledgeBase(): string[] {
const errors: string[] = [];
const validCausalAgentTypes: CausalAgentType[] = [
"fungal",
"bacterial",
"viral",
"environmental",
];
const validSeverities: Severity[] = ["low", "moderate", "high", "critical"];
const plantIds = new Set(plants.map((p) => p.id));
const diseaseIds = new Set(diseases.map((d) => d.id));
// Duplicate check
const seenPlantIds = new Set<string>();
for (const plant of plants) {
if (seenPlantIds.has(plant.id)) {
errors.push(`Duplicate plant ID: ${plant.id}`);
}
seenPlantIds.add(plant.id);
}
const seenDiseaseIds = new Set<string>();
for (const disease of diseases) {
if (seenDiseaseIds.has(disease.id)) {
errors.push(`Duplicate disease ID: ${disease.id}`);
}
seenDiseaseIds.add(disease.id);
}
for (const d of diseases) {
if (!plantIds.has(d.plantId)) {
errors.push(`Disease "${d.id}" references unknown plant ID: ${d.plantId}`);
}
if (!validCausalAgentTypes.includes(d.causalAgentType)) {
errors.push(`Disease "${d.id}" has invalid causalAgentType: ${d.causalAgentType}`);
}
if (!validSeverities.includes(d.severity)) {
errors.push(`Disease "${d.id}" has invalid severity: ${d.severity}`);
}
if (d.symptoms.length < 3) {
errors.push(`Disease "${d.id}" has fewer than 3 symptoms (${d.symptoms.length})`);
}
if (d.causes.length < 2) {
errors.push(`Disease "${d.id}" has fewer than 2 causes (${d.causes.length})`);
}
if (d.treatment.length < 3) {
errors.push(`Disease "${d.id}" has fewer than 3 treatment steps (${d.treatment.length})`);
}
if (d.prevention.length < 2) {
errors.push(`Disease "${d.id}" has fewer than 2 prevention tips (${d.prevention.length})`);
}
for (const lookalikeId of d.lookalikeDiseaseIds) {
if (!diseaseIds.has(lookalikeId)) {
errors.push(`Disease "${d.id}" references unknown lookalike: ${lookalikeId}`);
}
}
}
// Bidirectionality check
for (const d of diseases) {
for (const lookalikeId of d.lookalikeDiseaseIds) {
const lookalike = diseases.find((ld) => ld.id === lookalikeId);
if (lookalike && !lookalike.lookalikeDiseaseIds.includes(d.id)) {
errors.push(
`Lookalike reference not bidirectional: "${d.id}" references "${lookalikeId}" but not vice versa`,
);
}
}
}
return errors;
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe("Knowledge Base Data", () => {
it("has ≥20 plants", () => {
expect(plants.length).toBeGreaterThanOrEqual(20);
});
it("has ≥80 diseases", () => {
expect(diseases.length).toBeGreaterThanOrEqual(80);
});
it("passes cross-reference validation (no errors)", () => {
const errors = validateKnowledgeBase();
expect(errors).toEqual([]);
});
});
describe("Data quality checks", () => {
it("every disease has ≥3 symptoms", () => {
for (const d of diseases) {
expect(d.symptoms.length).toBeGreaterThanOrEqual(3);
}
});
it("every disease has ≥2 causes", () => {
for (const d of diseases) {
expect(d.causes.length).toBeGreaterThanOrEqual(2);
}
});
it("every disease has ≥3 treatment steps", () => {
for (const d of diseases) {
expect(d.treatment.length).toBeGreaterThanOrEqual(3);
}
});
it("every disease has ≥2 prevention tips", () => {
for (const d of diseases) {
expect(d.prevention.length).toBeGreaterThanOrEqual(2);
}
});
it("every disease references a valid plant ID", () => {
const plantIds = new Set(plants.map((p) => p.id));
for (const d of diseases) {
expect(plantIds.has(d.plantId)).toBe(true);
}
});
it("every disease has valid causalAgentType enum value", () => {
const validTypes = ["fungal", "bacterial", "viral", "environmental"];
for (const d of diseases) {
expect(validTypes).toContain(d.causalAgentType);
}
});
it("every disease has valid severity enum value", () => {
const validSeverities = ["low", "moderate", "high", "critical"];
for (const d of diseases) {
expect(validSeverities).toContain(d.severity);
}
});
it("all lookalike references are valid disease IDs", () => {
const diseaseIds = new Set(diseases.map((d) => d.id));
for (const d of diseases) {
for (const lookalikeId of d.lookalikeDiseaseIds) {
expect(diseaseIds.has(lookalikeId)).toBe(true);
}
}
});
it("includes both biotic and abiotic disease types", () => {
const types = new Set(diseases.map((d) => d.causalAgentType));
expect(types.has("fungal")).toBe(true);
expect(types.has("bacterial")).toBe(true);
expect(types.has("viral")).toBe(true);
expect(types.has("environmental")).toBe(true);
});
it("has multiple plants represented", () => {
const plantIds = new Set(diseases.map((d) => d.plantId));
expect(plantIds.size).toBeGreaterThanOrEqual(20);
});
it("every disease has valid prevalence enum value", () => {
const validPrevalences: Prevalence[] = ["common", "uncommon", "rare", "very_rare"];
for (const d of diseases) {
if (d.prevalence !== undefined) {
expect(validPrevalences).toContain(d.prevalence);
}
}
});
it("every plant has required fields", () => {
for (const p of plants) {
expect(p.id).toBeTruthy();
expect(p.commonName).toBeTruthy();
expect(p.scientificName).toBeTruthy();
expect(p.family).toBeTruthy();
expect(p.category).toBeTruthy();
}
});
});

240
src/app/about/page.tsx Normal file
View File

@@ -0,0 +1,240 @@
import React from "react";
import Link from "next/link";
import type { Metadata } from "next";
import { APP_NAME, BETA_DISCLAIMER } from "@/lib/constants";
export const metadata: Metadata = {
title: "About",
description: `Learn about ${APP_NAME} — our mission, methodology, data sources, and limitations. Open-source plant disease identification.`,
};
/* ─── FAQ accordion (server component with details/summary) ─── */
const faqs = [
{
q: "How accurate is the disease identification?",
a: "Our model has been trained on 500K+ labeled plant disease images covering 300+ plant species. Accuracy varies by plant and disease type, with confidence scores provided for each diagnosis. The model performs best on common diseases with visible foliar symptoms. We recommend using multiple sources of information for critical plant health decisions.",
},
{
q: "Which plants are supported?",
a: "We currently have detailed disease data for 8+ common garden plants including tomato, basil, rose, monstera, snake plant, bell pepper, lavender, and sunflower. We are actively adding more plants. Browse our full catalog on the Browse Plants page.",
},
{
q: "Can I use this for commercial farming?",
a: "While our database covers common agricultural plants like tomatoes and peppers, the tool is designed primarily for home gardeners and small-scale growers. For commercial agriculture, we recommend consulting with local extension services, certified crop advisors, and using laboratory testing for definitive diagnosis.",
},
{
q: "How does the AI model work?",
a: "The model uses a convolutional neural network (CNN) trained on thousands of labeled plant disease images. When you upload a photo, the model analyzes visual patterns — leaf spots, discoloration, wilting patterns, and other symptoms — then matches them against known disease signatures. The output includes the most likely diagnosis with a confidence percentage.",
},
{
q: "Is my data private?",
a: "Yes. Uploaded images are processed temporarily for analysis and are not permanently stored or used for training without explicit consent. We do not share or sell user data. See our privacy policy for details.",
},
{
q: "What if my plant has a disease that's not in the database?",
a: "If the model cannot identify the disease with sufficient confidence, it will indicate that the condition is not recognized. In this case, we recommend consulting a local plant pathologist, master gardener, or agricultural extension service. You can also contribute by submitting data to help us expand our knowledge base.",
},
];
function FAQAccordion() {
return (
<div className="space-y-3">
{faqs.map((faq, i) => (
<details
key={i}
className="group rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 overflow-hidden"
>
<summary className="flex items-center justify-between gap-4 px-5 py-4 cursor-pointer list-none text-sm font-medium text-zinc-900 dark:text-zinc-100 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors">
{faq.q}
<span
className="shrink-0 text-zinc-400 group-open:rotate-180 transition-transform"
aria-hidden="true"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m6 9 6 6 6-6" />
</svg>
</span>
</summary>
<div className="px-5 pb-4 pt-0">
<p className="text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed">{faq.a}</p>
</div>
</details>
))}
</div>
);
}
/* ─── About Page ─── */
export default function AboutPage() {
return (
<div className="mx-auto max-w-3xl px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
{/* Page header */}
<div className="text-center mb-12">
<span className="text-6xl block mb-4" aria-hidden="true">
🌱
</span>
<h1 className="text-3xl sm:text-4xl font-bold text-zinc-900 dark:text-zinc-100">
About {APP_NAME}
</h1>
<p className="mt-3 text-lg text-zinc-500 dark:text-zinc-400 max-w-lg mx-auto">
Making plant disease identification accessible to every gardener.
</p>
</div>
{/* Mission */}
<section className="mb-12">
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100 mb-4">Our Mission</h2>
<div className="prose prose-sm max-w-none text-zinc-600 dark:text-zinc-300 space-y-4">
<p>
Gardening is a labor of love and watching a plant struggle with an unknown disease is
heartbreaking. Our mission is to put the power of AI-powered disease identification into
every gardener&apos;s pocket, for free.
</p>
<p>
{APP_NAME} was built by a team of gardeners and developers who were frustrated with
vague, generic plant disease advice. We wanted hyper-specific diagnoses not just
&ldquo;your plant has a fungus&rdquo; but &ldquo;your tomato has Late Blight caused by
Phytophthora infestans, and here&apos;s exactly how to treat it.&rdquo;
</p>
</div>
</section>
{/* How the model works */}
<section className="mb-12">
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100 mb-4">
How the Model Works
</h2>
<div className="prose prose-sm max-w-none text-zinc-600 dark:text-zinc-300 space-y-4">
<p>
The identification engine uses a deep convolutional neural network trained on a dataset
of <strong>500,000+ labeled plant disease images</strong> spanning 300+ plant species.
When you upload a photo:
</p>
<ol className="list-decimal list-inside space-y-2">
<li>
<strong>Preprocessing</strong> The image is normalized and analyzed for relevant
regions (leaves, stems, fruit).
</li>
<li>
<strong>Feature extraction</strong> The model identifies visual patterns: lesion
shape, color, margin type, texture, and distribution.
</li>
<li>
<strong>Classification</strong> Patterns are matched against known disease
signatures, producing a ranked list of possible diagnoses with confidence scores.
</li>
<li>
<strong>Recommendation</strong> The top diagnosis is paired with treatment steps,
prevention tips, and severity information from our curated knowledge base.
</li>
</ol>
</div>
</section>
{/* Data sources */}
<section className="mb-12">
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100 mb-4">
Data Sources
</h2>
<div className="prose prose-sm max-w-none text-zinc-600 dark:text-zinc-300 space-y-4">
<p>
Our disease knowledge base is curated from peer-reviewed plant pathology resources,
including:
</p>
<ul className="list-disc list-inside space-y-1">
<li>University agricultural extension publications</li>
<li>Peer-reviewed plant pathology journals</li>
<li>USDA plant disease databases</li>
<li>Contributions from the open-source gardening community</li>
</ul>
<p>
We prioritize evidence-based, actionable information. Disease descriptions, treatments,
and prevention tips are reviewed for accuracy before inclusion.
</p>
</div>
</section>
{/* Limitations */}
<section className="mb-12">
<div className="rounded-xl border border-warning-amber-200 dark:border-warning-amber-800 bg-warning-amber-50 dark:bg-warning-amber-950/50 p-6">
<h2 className="text-xl font-semibold text-warning-amber-800 dark:text-warning-amber-300 mb-3 flex items-center gap-2">
<span aria-hidden="true"></span>
Limitations &amp; Disclaimer
</h2>
<div className="text-sm text-warning-amber-700 dark:text-warning-amber-400 space-y-3">
<p>{BETA_DISCLAIMER}</p>
<p>
The AI model may not accurately identify all diseases, especially unusual
presentations, early-stage infections, or diseases outside its training data. Always
confirm diagnoses with professional resources for critical decisions.
</p>
<p>
This tool is <strong>not</strong> FDA-approved or certified as a medical/agricultural
diagnostic device. It is an educational assistive tool.
</p>
</div>
</div>
</section>
{/* Open source */}
<section className="mb-12">
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100 mb-4">
Open Source &amp; Contributions
</h2>
<div className="prose prose-sm max-w-none text-zinc-600 dark:text-zinc-300 space-y-4">
<p>
{APP_NAME} is free and open source. We believe plant health information should be
accessible to everyone. The entire project is available on GitHub, and we welcome
contributions!
</p>
<p>You can contribute by:</p>
<ul className="list-disc list-inside space-y-1">
<li>Adding new plant and disease data</li>
<li>Improving the AI model with training data</li>
<li>Reporting bugs or suggesting features</li>
<li>Translating content to other languages</li>
<li>Sharing plant photos (with permission) for model improvement</li>
</ul>
<p>
<Link
href="https://github.com/plant-health-id"
className="text-leaf-green-600 hover:text-leaf-green-700 dark:text-leaf-green-400 dark:hover:text-leaf-green-300 font-medium underline underline-offset-2"
target="_blank"
rel="noopener noreferrer"
>
View on GitHub
</Link>
</p>
</div>
</section>
{/* FAQ */}
<section className="mb-8">
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100 mb-6">
Frequently Asked Questions
</h2>
<FAQAccordion />
</section>
{/* Back to home */}
<div className="text-center pt-8 border-t border-zinc-200 dark:border-zinc-800">
<Link
href="/"
className="inline-flex items-center gap-2 text-sm font-medium text-leaf-green-600 hover:text-leaf-green-700 dark:text-leaf-green-400 dark:hover:text-leaf-green-300 transition-colors"
>
<span aria-hidden="true"></span> Back to home
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from "next/server";
import { getDiseaseWithPlant, getLookalikeDiseases } from "@/lib/api/diseases-db";
interface RouteParams {
params: Promise<{ id: string }>;
}
/**
* GET /api/diseases/[id]
* Get a single disease with its associated plant and lookalike diseases.
*/
export async function GET(_request: NextRequest, { params }: RouteParams): Promise<NextResponse> {
const { id } = await params;
console.log(`[API] GET /api/diseases/${id}`);
const result = await getDiseaseWithPlant(id);
if (!result) {
return NextResponse.json(
{
error: "Not Found",
message: `Disease with ID "${id}" not found`,
status: 404,
},
{ status: 404, headers: { "Cache-Control": "public, max-age=3600" } },
);
}
const lookalikes = await getLookalikeDiseases(id);
return NextResponse.json(
{
disease: result.disease,
plant: result.plant,
lookalikes,
},
{ headers: { "Cache-Control": "public, max-age=3600" } },
);
}

View File

@@ -0,0 +1,124 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { GET } from "./route";
import * as diseasesLib from "@/lib/api/diseases-db";
// Mock the diseases library
vi.mock("@/lib/api/diseases-db", () => ({
listDiseases: vi.fn(() => Promise.resolve([])),
}));
describe("GET /api/diseases", () => {
const createRequest = (searchParams: string) => {
const url = new URL(`http://localhost/api/diseases${searchParams}`);
const req = new Request(url);
(req as any).nextUrl = url;
return req;
};
beforeEach(() => {
vi.clearAllMocks();
});
it("returns all diseases with no filters", async () => {
(diseasesLib.listDiseases as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: "early-blight", name: "Early Blight" },
{ id: "late-blight", name: "Late Blight" },
]);
const response = await GET(createRequest(""));
expect(response.status).toBe(200);
const body = await response.json();
expect(body.diseases).toHaveLength(2);
expect(body.total).toBe(2);
});
it("filters diseases by plantId", async () => {
(diseasesLib.listDiseases as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: "early-blight", name: "Early Blight", plantId: "tomato" },
]);
const response = await GET(createRequest("?plantId=tomato"));
expect(response.status).toBe(200);
});
it("filters diseases by search term", async () => {
(diseasesLib.listDiseases as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: "early-blight", name: "Early Blight" },
]);
const response = await GET(createRequest("?search=blight"));
expect(response.status).toBe(200);
});
it("filters diseases by causalAgentType", async () => {
(diseasesLib.listDiseases as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: "early-blight", name: "Early Blight", causalAgentType: "fungal" },
]);
const response = await GET(createRequest("?causalAgentType=fungal"));
expect(response.status).toBe(200);
});
it("filters diseases by severity", async () => {
(diseasesLib.listDiseases as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: "early-blight", name: "Early Blight", severity: "moderate" },
]);
const response = await GET(createRequest("?severity=moderate"));
expect(response.status).toBe(200);
});
it("returns 400 for empty search term", async () => {
const response = await GET(createRequest("?search="));
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toBe("Bad Request");
});
it("returns 400 for invalid causalAgentType", async () => {
const response = await GET(createRequest("?causalAgentType=invalid"));
expect(response.status).toBe(400);
const body = await response.json();
expect(body.message).toMatch(/Invalid causalAgentType/i);
});
it("returns 400 for invalid severity", async () => {
const response = await GET(createRequest("?severity=invalid"));
expect(response.status).toBe(400);
const body = await response.json();
expect(body.message).toMatch(/Invalid severity/i);
});
it("accepts valid causalAgentTypes", async () => {
const validTypes = ["fungal", "bacterial", "viral", "environmental"];
(diseasesLib.listDiseases as ReturnType<typeof vi.fn>).mockResolvedValue([]);
for (const type of validTypes) {
const response = await GET(createRequest(`?causalAgentType=${type}`));
expect(response.status).toBe(200);
}
});
it("accepts valid severities", async () => {
const validSeverities = ["low", "moderate", "high", "critical"];
(diseasesLib.listDiseases as ReturnType<typeof vi.fn>).mockResolvedValue([]);
for (const severity of validSeverities) {
const response = await GET(createRequest(`?severity=${severity}`));
expect(response.status).toBe(200);
}
});
it("returns cache control header", async () => {
(diseasesLib.listDiseases as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const response = await GET(createRequest(""));
const cacheControl = response.headers.get("Cache-Control");
expect(cacheControl).toContain("max-age=3600");
});
});

View File

@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from "next/server";
import { listDiseases } from "@/lib/api/diseases-db";
/**
* GET /api/diseases
* List all diseases with optional filters.
* Query params: ?plantId=<id> & ?search=<term> & ?causalAgentType=<type> & ?severity=<level>
*/
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const plantId = searchParams.get("plantId");
const search = searchParams.get("search");
const causalAgentType = searchParams.get("causalAgentType") as
| "fungal"
| "bacterial"
| "viral"
| "environmental"
| null;
const severity = searchParams.get("severity") as "low" | "moderate" | "high" | "critical" | null;
// Validate search param
if (search !== null && search.trim().length === 0) {
return NextResponse.json(
{ error: "Bad Request", message: "Search term cannot be empty", status: 400 },
{ status: 400, headers: { "Cache-Control": "public, max-age=3600" } },
);
}
// Validate causalAgentType param
const validCausalAgentTypes = ["fungal", "bacterial", "viral", "environmental"];
if (causalAgentType !== null && !validCausalAgentTypes.includes(causalAgentType)) {
return NextResponse.json(
{
error: "Bad Request",
message: `Invalid causalAgentType. Must be one of: ${validCausalAgentTypes.join(", ")}`,
status: 400,
},
{ status: 400, headers: { "Cache-Control": "public, max-age=3600" } },
);
}
// Validate severity param
const validSeverities = ["low", "moderate", "high", "critical"];
if (severity !== null && !validSeverities.includes(severity)) {
return NextResponse.json(
{
error: "Bad Request",
message: `Invalid severity. Must be one of: ${validSeverities.join(", ")}`,
status: 400,
},
{ status: 400, headers: { "Cache-Control": "public, max-age=3600" } },
);
}
console.log(
`[API] GET /api/diseases plantId="${plantId}" search="${search}" causalAgentType="${causalAgentType}" severity="${severity}"`,
);
const results = await listDiseases({
plantId: plantId || undefined,
search: search || undefined,
causalAgentType: causalAgentType || undefined,
severity: severity || undefined,
});
return NextResponse.json(
{ diseases: results, total: results.length },
{ headers: { "Cache-Control": "public, max-age=3600" } },
);
}

View File

@@ -0,0 +1,143 @@
import { NextResponse } from "next/server";
import { getDb } from "@/lib/db/index";
import { flaggedContent, plants, diseases } from "@/lib/db/schema";
import { inArray, sql } from "drizzle-orm";
/**
* GET /api/flag/report
*
* Returns all flagged content grouped by content type, with resolved
* plant/disease names for readability. Used by the generate-flagged-report script.
*
* Query params:
* minFlags - Optional minimum flag count to include (default: 1)
*/
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const minFlags = parseInt(searchParams.get("minFlags") ?? "1", 10);
const db = getDb();
// Get all flagged entries
const rows = await db
.select()
.from(flaggedContent)
.where(sql`flag_count >= ${minFlags}`)
.orderBy(flaggedContent.contentType, flaggedContent.flagCount);
if (rows.length === 0) {
return NextResponse.json({
total: 0,
groups: {},
items: [],
});
}
// Resolve plant/disease names
const plantIds = new Set<string>();
const diseaseIds = new Set<string>();
for (const row of rows) {
if (row.contentType === "plant_image") {
plantIds.add(row.contentId);
} else {
diseaseIds.add(row.contentId);
}
}
// Fetch plant names
const plantMap = new Map<string, string>();
if (plantIds.size > 0) {
const plantRows = await db
.select({ id: plants.id, name: plants.commonName })
.from(plants)
.where(inArray(plants.id, [...plantIds]));
for (const p of plantRows) {
plantMap.set(p.id, p.name);
}
}
// Fetch disease names + their plant references
const diseaseMap = new Map<string, { name: string; plantId: string }>();
if (diseaseIds.size > 0) {
const diseaseRows = await db
.select({
id: diseases.id,
name: diseases.name,
plantId: diseases.plantId,
})
.from(diseases)
.where(inArray(diseases.id, [...diseaseIds]));
for (const d of diseaseRows) {
diseaseMap.set(d.id, { name: d.name, plantId: d.plantId });
}
// Fetch plants for diseases that we don't already have
for (const d of diseaseRows) {
if (!plantMap.has(d.plantId)) {
plantIds.add(d.plantId);
}
}
if (plantIds.size > 0) {
const plantRows = await db
.select({ id: plants.id, name: plants.commonName })
.from(plants)
.where(inArray(plants.id, [...plantIds]));
for (const p of plantRows) {
plantMap.set(p.id, p.name);
}
}
}
// Group by content type
const groups: Record<string, Array<Record<string, unknown>>> = {};
for (const row of rows) {
const type = row.contentType;
if (!groups[type]) groups[type] = [];
let label = row.contentId;
if (type === "plant_image") {
label = plantMap.get(row.contentId) ?? row.contentId;
} else {
const disease = diseaseMap.get(row.contentId);
if (disease) {
const plantName = plantMap.get(disease.plantId) ?? disease.plantId;
label = `${disease.name} (on ${plantName})`;
}
}
groups[type].push({
id: row.id,
contentType: row.contentType,
contentId: row.contentId,
fieldName: row.fieldName,
label,
notes: row.notes,
flagCount: row.flagCount,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
});
}
return NextResponse.json({
total: rows.length,
groups,
items: rows.map((row) => ({
id: row.id,
contentType: row.contentType,
contentId: row.contentId,
fieldName: row.fieldName,
notes: row.notes,
flagCount: row.flagCount,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
})),
});
} catch (err) {
console.error("[Flag Report] Error fetching flagged content:", err);
return NextResponse.json(
{ error: "Internal Server Error", message: "Failed to fetch flagged content", status: 500 },
{ status: 500 },
);
}
}

148
src/app/api/flag/route.ts Normal file
View File

@@ -0,0 +1,148 @@
import { NextRequest, NextResponse } from "next/server";
import { eq, and } from "drizzle-orm";
import { getDb } from "@/lib/db/index";
import { flaggedContent } from "@/lib/db/schema";
import { v4 as uuidv4 } from "uuid";
/**
* Content types that can be flagged for manual review.
*/
const VALID_CONTENT_TYPES = [
"plant_image",
"disease_image",
"disease_description",
"disease_symptoms",
"disease_causes",
"disease_treatment",
"disease_prevention",
] as const;
type FlagContentType = (typeof VALID_CONTENT_TYPES)[number];
interface FlagRequestBody {
contentType: FlagContentType;
contentId: string;
fieldName: string;
notes?: string;
}
/**
* POST /api/flag
*
* Flag content for manual review. If the same content_type + content_id + field_name
* combination already exists, increments the flag_count. Otherwise creates a new entry.
*
* Body:
* contentType - Type of content being flagged
* contentId - The ID of the plant or disease
* fieldName - The specific field name (e.g., "image", "symptoms")
* notes - Optional notes/reason for flagging
*/
export async function POST(request: NextRequest) {
try {
const body: FlagRequestBody = await request.json();
// ── Validate required fields ──
if (!body.contentType || !VALID_CONTENT_TYPES.includes(body.contentType)) {
return NextResponse.json(
{
error: "Bad Request",
message: `Invalid contentType. Must be one of: ${VALID_CONTENT_TYPES.join(", ")}`,
status: 400,
},
{ status: 400 },
);
}
if (
!body.contentId ||
typeof body.contentId !== "string" ||
body.contentId.trim().length === 0
) {
return NextResponse.json(
{ error: "Bad Request", message: "contentId is required", status: 400 },
{ status: 400 },
);
}
if (
!body.fieldName ||
typeof body.fieldName !== "string" ||
body.fieldName.trim().length === 0
) {
return NextResponse.json(
{ error: "Bad Request", message: "fieldName is required", status: 400 },
{ status: 400 },
);
}
const db = getDb();
// ── Check if this item was already flagged ──
const existing = await db
.select()
.from(flaggedContent)
.where(
and(
eq(flaggedContent.contentType, body.contentType),
eq(flaggedContent.contentId, body.contentId),
eq(flaggedContent.fieldName, body.fieldName),
),
)
.limit(1);
if (existing.length > 0) {
// Increment flag count and update timestamp
const current = existing[0];
await db
.update(flaggedContent)
.set({
flagCount: (current.flagCount ?? 0) + 1,
updatedAt: new Date().toISOString(),
...(body.notes ? { notes: body.notes } : {}),
})
.where(eq(flaggedContent.id, current.id));
return NextResponse.json({
success: true,
action: "incremented",
flagCount: (current.flagCount ?? 0) + 1,
message: "Flag count incremented. Thank you for your review input.",
});
}
// ── Create new flag entry ──
const id = uuidv4();
await db.insert(flaggedContent).values({
id,
contentType: body.contentType,
contentId: body.contentId,
fieldName: body.fieldName,
notes: body.notes ?? "",
flagCount: 1,
});
console.log(
`[Flag] New flag: type=${body.contentType} id=${body.contentId} field=${body.fieldName}`,
);
return NextResponse.json(
{
success: true,
action: "created",
flagCount: 1,
message: "Content flagged for manual review. Thank you!",
},
{ status: 201 },
);
} catch (err) {
console.error("[Flag] Error flagging content:", err);
return NextResponse.json(
{ error: "Internal Server Error", message: "Failed to flag content", status: 500 },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,27 @@
import { describe, it, expect } from "vitest";
import { GET } from "./route";
describe("GET /api/health", () => {
it("returns 200 with status ok", async () => {
const response = await GET();
expect(response.status).toBe(200);
const body = await response.json();
expect(body.status).toBe("ok");
expect(body.timestamp).toBeDefined();
});
it("returns valid ISO timestamp", async () => {
const response = await GET();
const body = await response.json();
const date = new Date(body.timestamp);
expect(date.toString()).not.toBe("Invalid Date");
});
it("returns JSON content type", async () => {
const response = await GET();
const contentType = response.headers.get("content-type");
expect(contentType).toContain("application/json");
});
});

View File

@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
/**
* Health-check endpoint.
* Returns 200 with status and current timestamp.
* Used for deployment verification and load-balancer probes.
*/
export async function GET(): Promise<NextResponse> {
return NextResponse.json({
status: "ok",
timestamp: new Date().toISOString(),
});
}

View File

@@ -0,0 +1,241 @@
/**
* Integration tests for app/api/identify/route.ts
*
* These tests call the actual running dev server via fetch.
* Run with: `npm run dev` in one terminal, then `npx vitest run src/app/api/identify/identify.test.ts`
*
* Tests:
* - POST /api/identify with valid imageId returns 200 with predictions array
* - POST /api/identify with invalid imageId returns 404
* - POST /api/identify with missing imageId returns 400
* - POST /api/identify with invalid JSON returns 400
* - Each prediction's diseaseId exists in knowledge base
* - Response includes inference timing metadata
* - Response includes demo_mode flag when using mock model
* - Predictions include lookalike cross-references
* - Predictions are sorted by confidence descending
*/
// @vitest-environment node
import { describe, it, expect, beforeAll } from "vitest";
import { getDiseaseById } from "@/lib/api/diseases-db";
const BASE_URL = process.env.TEST_BASE_URL || "http://localhost:3000";
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* Create a test image buffer using sharp.
*/
async function createTestImage(
width: number,
height: number,
bg = { r: 34, g: 197, b: 94 },
): Promise<Buffer> {
const sharpMod = await import("sharp");
const sharp = sharpMod.default;
return sharp({
create: { width, height, channels: 3, background: bg },
})
.jpeg({ quality: 90 })
.toBuffer();
}
/**
* Upload a test image and return the imageId.
*/
async function uploadTestImage(): Promise<string> {
const buffer = await createTestImage(300, 300, { r: 34, g: 197, b: 94 });
const file = new File([buffer], "test.jpg", { type: "image/jpeg" });
const formData = new FormData();
formData.append("image", file);
const response = await fetch(`${BASE_URL}/api/upload`, {
method: "POST",
body: formData,
});
const data = await response.json();
expect(response.status).toBe(200);
return data.imageId;
}
/**
* Call the identify endpoint with a given imageId.
*/
async function callIdentify(imageId: string): Promise<{
status: number;
data: any;
ok: boolean;
}> {
const response = await fetch(`${BASE_URL}/api/identify`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ imageId }),
});
const data = await response.json();
return { status: response.status, data, ok: response.ok };
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe("POST /api/identify", () => {
let imageId: string;
beforeAll(async () => {
imageId = await uploadTestImage();
}, 15000);
it("returns 200 with predictions array for valid imageId", async () => {
const { status, data } = await callIdentify(imageId);
expect(status).toBe(200);
expect(data.predictions).toBeDefined();
expect(Array.isArray(data.predictions)).toBe(true);
expect(data.predictions.length).toBeGreaterThan(0);
}, 30000);
it("returns 404 for invalid imageId", async () => {
const { status, data } = await callIdentify("nonexistent-image-id");
expect(status).toBe(404);
expect(data.error).toBe("Image not found");
}, 15000);
it("returns 400 for missing imageId", async () => {
const response = await fetch(`${BASE_URL}/api/identify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe("Missing imageId");
}, 15000);
it("returns 400 for invalid JSON", async () => {
const response = await fetch(`${BASE_URL}/api/identify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "not json",
});
expect(response.status).toBe(400);
}, 15000);
it("response includes metadata with model, inferenceTimeMs, imageId", async () => {
const { data } = await callIdentify(imageId);
expect(data.metadata).toBeDefined();
expect(data.metadata.model).toBeDefined();
expect(typeof data.metadata.model).toBe("string");
expect(data.metadata.inferenceTimeMs).toBeDefined();
expect(typeof data.metadata.inferenceTimeMs).toBe("number");
expect(data.metadata.inferenceTimeMs).toBeGreaterThan(0);
expect(data.metadata.imageId).toBe(imageId);
}, 30000);
it("response includes demo_mode flag when using mock model", async () => {
const { data } = await callIdentify(imageId);
// In development without a real model, demo_mode should be true
expect(typeof data.demo_mode).toBe("boolean");
}, 30000);
it("each prediction has diseaseId, disease, confidence, and lookalikes", async () => {
const { data } = await callIdentify(imageId);
for (const pred of data.predictions) {
expect(pred.diseaseId).toBeDefined();
expect(typeof pred.diseaseId).toBe("string");
expect(pred.disease).toBeDefined();
expect(pred.disease.name).toBeDefined();
expect(pred.disease.description).toBeDefined();
expect(pred.disease.symptoms).toBeDefined();
expect(pred.disease.treatment).toBeDefined();
expect(pred.disease.prevention).toBeDefined();
expect(pred.confidence).toBeDefined();
expect(pred.confidence.raw).toBeDefined();
expect(pred.confidence.adjusted).toBeDefined();
expect(pred.confidence.label).toMatch(/^(high|medium|low)$/);
expect(pred.lookalikes).toBeDefined();
expect(Array.isArray(pred.lookalikes)).toBe(true);
}
}, 30000);
it("each prediction's diseaseId exists in knowledge base", async () => {
const { data } = await callIdentify(imageId);
for (const pred of data.predictions) {
const disease = await getDiseaseById(pred.diseaseId);
expect(disease).toBeDefined();
expect(disease!.id).toBe(pred.diseaseId);
expect(disease!.name).toBe(pred.disease.name);
}
}, 30000);
it("predictions are sorted by confidence descending", async () => {
const { data } = await callIdentify(imageId);
for (let i = 0; i < data.predictions.length - 1; i++) {
expect(data.predictions[i].confidence.adjusted).toBeGreaterThanOrEqual(
data.predictions[i + 1].confidence.adjusted,
);
}
}, 30000);
it("lookalike references are valid disease IDs", async () => {
const { data } = await callIdentify(imageId);
for (const pred of data.predictions) {
for (const lookalikeId of pred.lookalikes) {
const lookalike = await getDiseaseById(lookalikeId);
expect(lookalike).toBeDefined();
}
}
}, 30000);
it("inference completes under 3 seconds", async () => {
const start = performance.now();
const { data } = await callIdentify(imageId);
const elapsed = performance.now() - start;
expect(data.metadata.inferenceTimeMs).toBeLessThan(3000);
expect(elapsed).toBeLessThan(3000);
}, 10000);
it("confidence scores are in valid range", async () => {
const { data } = await callIdentify(imageId);
for (const pred of data.predictions) {
expect(pred.confidence.raw).toBeGreaterThanOrEqual(0);
expect(pred.confidence.raw).toBeLessThanOrEqual(1);
expect(pred.confidence.adjusted).toBeGreaterThanOrEqual(0);
expect(pred.confidence.adjusted).toBeLessThanOrEqual(1);
}
}, 30000);
it("disease entries have required fields", async () => {
const { data } = await callIdentify(imageId);
for (const pred of data.predictions) {
const disease = pred.disease;
expect(disease.id).toBeDefined();
expect(disease.plantId).toBeDefined();
expect(disease.name).toBeDefined();
expect(disease.scientificName).toBeDefined();
expect(disease.causalAgentType).toMatch(/^(fungal|bacterial|viral|environmental)$/);
expect(disease.description).toBeDefined();
expect(disease.symptoms.length).toBeGreaterThanOrEqual(3);
expect(disease.causes.length).toBeGreaterThanOrEqual(2);
expect(disease.treatment.length).toBeGreaterThanOrEqual(3);
expect(disease.prevention.length).toBeGreaterThanOrEqual(2);
expect(disease.severity).toMatch(/^(low|moderate|high|critical)$/);
}
}, 30000);
});

View File

@@ -0,0 +1,268 @@
/**
* Plant disease identification endpoint.
*
* Accepts POST with { imageId } from a previous /api/upload call.
* Loads the uploaded image, preprocesses it (resize + normalize),
* runs ML inference, enriches results with the knowledge base,
* and returns ranked predictions with confidence scores.
*
* When no model is available, returns deterministic mock predictions
* with demo_mode: true so the UI still works for development.
*/
import { NextRequest, NextResponse } from "next/server";
import path from "path";
import fs from "fs/promises";
import fsSync from "fs";
import { runInference } from "@/lib/ml/inference";
import { calibrateConfidence } from "@/lib/ml/confidence";
import { getDiseaseIdForIndex } from "@/lib/ml/labels";
import { getModel } from "@/lib/ml/model-loader";
import { getDiseaseById, getPlantById, getLookalikeDiseases } from "@/lib/api/diseases-db";
import type { IdentifyRequest, IdentifyResponse, PredictionResult } from "@/lib/types";
// ─── Constants ───────────────────────────────────────────────────────────────
const UPLOADS_DIR = path.join(process.cwd(), "public", "uploads");
/** ImageNet normalization constants */
const IMAGENET_MEAN = [0.485, 0.456, 0.406] as const;
const IMAGENET_STD = [0.229, 0.224, 0.225] as const;
/** Model input size */
const MODEL_SIZE = 160;
// ─── Server-side image preprocessing ─────────────────────────────────────────
/**
* Load an uploaded image and preprocess it into a Float32Array tensor
* with shape [3, 160, 160] (NCHW without batch dim) using ImageNet normalization.
*
* @param imageId - The image ID from the upload endpoint
* @returns Float32Array tensor ready for inference
* @throws Error if image not found
*/
async function loadImageAndPreprocess(imageId: string): Promise<Float32Array> {
// Find the image file — try original first, then resized version
const uploads = await fs.readdir(UPLOADS_DIR).catch(() => []);
// Find files matching this imageId
const matchingFiles = uploads.filter((f) => f.startsWith(imageId) && !f.includes("-resized"));
if (matchingFiles.length === 0) {
// Try the resized version
const resizedFile = `${imageId}-resized.jpg`;
if (fsSync.existsSync(path.join(UPLOADS_DIR, resizedFile))) {
return preprocessImageBuffer(await fs.readFile(path.join(UPLOADS_DIR, resizedFile)));
}
throw new Error(`Image not found: ${imageId}`);
}
const filename = matchingFiles[0];
const filePath = path.join(UPLOADS_DIR, filename);
const buffer = await fs.readFile(filePath);
return preprocessImageBuffer(buffer);
}
/**
* Preprocess an image buffer into a normalized Float32Array tensor.
* Uses sharp to resize, then applies ImageNet normalization.
*
* Output: flat Float32Array of length 3 × 224 × 224 in channel-first order
* (all R values, then all G values, then all B values).
*
* @param buffer - Raw image buffer
* @returns Normalized Float32Array tensor
*/
async function preprocessImageBuffer(buffer: Buffer): Promise<Float32Array> {
const sharpMod = await import("sharp");
const sharp = sharpMod.default;
// Resize to model input size and get raw pixel data
const pipeline = sharp(buffer).resize(MODEL_SIZE, MODEL_SIZE).raw().ensureAlpha(0); // RGB only, no alpha
const rawBuffer = await pipeline.toBuffer();
// Convert to Float32Array with channel-first layout and ImageNet normalization
const totalPixels = MODEL_SIZE * MODEL_SIZE;
const tensor = new Float32Array(3 * totalPixels);
// Extract channels from raw RGB data
const rChannel = tensor.subarray(0, totalPixels);
const gChannel = tensor.subarray(totalPixels, 2 * totalPixels);
const bChannel = tensor.subarray(2 * totalPixels, 3 * totalPixels);
for (let i = 0; i < totalPixels; i++) {
const idx = i * 3; // RGB stride
rChannel[i] = rawBuffer[idx] / 255;
gChannel[i] = rawBuffer[idx + 1] / 255;
bChannel[i] = rawBuffer[idx + 2] / 255;
}
// Apply ImageNet normalization
for (let c = 0; c < 3; c++) {
const channel = c === 0 ? rChannel : c === 1 ? gChannel : bChannel;
const m = IMAGENET_MEAN[c];
const s = IMAGENET_STD[c];
for (let i = 0; i < totalPixels; i++) {
channel[i] = (channel[i] - m) / s;
}
}
return tensor;
}
// ─── Result enrichment ───────────────────────────────────────────────────────
/**
* Enrich raw predictions with knowledge base data.
*
* For each prediction:
* - Look up disease by ID in knowledge base
* - Calibrate confidence score
* - Include lookalike disease cross-references (IDs and full objects)
*
* @param topPredictions - Top-K raw predictions from inference
* @returns Enriched prediction results
*/
async function enrichPredictions(
topPredictions: Array<{ classIndex: number; probability: number }>,
): Promise<PredictionResult[]> {
const results: PredictionResult[] = [];
for (const pred of topPredictions) {
const diseaseId = getDiseaseIdForIndex(pred.classIndex);
// Skip "healthy" and "unknown" — only return actual diseases
if (diseaseId === "healthy" || diseaseId === "unknown") {
continue;
}
// Look up disease in knowledge base
const disease = await getDiseaseById(diseaseId);
if (!disease) {
// Disease ID from model doesn't exist in knowledge base — skip
continue;
}
// Calibrate confidence
const confidence = calibrateConfidence(pred.probability);
// Pre-resolve lookalike disease objects server-side so the client
// doesn't need sync access to JSON files
const lookalikes = disease.lookalikeDiseaseIds;
const lookalikeDiseases = await getLookalikeDiseases(diseaseId);
// Look up the plant for client convenience
const plant = await getPlantById(disease.plantId).catch(() => null);
results.push({
diseaseId,
disease,
confidence,
lookalikes,
lookalikeDiseases,
plant: plant ?? null,
});
}
// Sort by adjusted confidence descending
results.sort((a, b) => b.confidence.adjusted - a.confidence.adjusted);
return results;
}
// ─── Route Handler ───────────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
try {
// Parse request body
let body: IdentifyRequest;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ error: "Invalid JSON", message: "Request body must be valid JSON.", status: 400 },
{ status: 400 },
);
}
const { imageId } = body;
// Validate imageId
if (!imageId || typeof imageId !== "string") {
return NextResponse.json(
{
error: "Missing imageId",
message: 'Request body must include "imageId" string.',
status: 400,
},
{ status: 400 },
);
}
// Check image exists
const uploads = await fs.readdir(UPLOADS_DIR).catch(() => []);
const imageExists = uploads.some((f) => f.startsWith(imageId));
if (!imageExists) {
return NextResponse.json(
{ error: "Image not found", message: `No image found with ID: ${imageId}`, status: 404 },
{ status: 404 },
);
}
// Load and preprocess image
const tensor = await loadImageAndPreprocess(imageId);
// Run inference
const { predictions: rawPredictions, inferenceTimeMs } = await runInference(tensor, 10);
// Get model status to check if we're in demo mode
const model = await getModel();
const modelStatus = model.getStatus();
const demoMode = !modelStatus.loaded;
// Calibrate and filter predictions
const calibratedPredictions = rawPredictions.map((pred) => ({
classIndex: pred.classIndex,
probability: pred.probability,
}));
// Enrich with knowledge base
const enrichedPredictions = await enrichPredictions(calibratedPredictions);
// Build response
const response: IdentifyResponse = {
predictions: enrichedPredictions,
metadata: {
model: modelStatus.modelId,
inferenceTimeMs,
imageId,
},
};
if (demoMode) {
response.demo_mode = true;
}
return NextResponse.json(response, {
headers: {
"Cache-Control": "no-store",
},
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
const status = message.includes("not found") ? 404 : 500;
console.error(`[identify] Error: ${message}`);
return NextResponse.json(
{
error: status === 404 ? "Image not found" : "Identification failed",
message,
status,
},
{ status },
);
}
}

View File

@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import { getPlantWithDiseases } from "@/lib/api/diseases-db";
interface RouteParams {
params: Promise<{ id: string }>;
}
/**
* GET /api/plants/[id]
* Get a single plant with all its associated diseases.
*/
export async function GET(_request: NextRequest, { params }: RouteParams): Promise<NextResponse> {
const { id } = await params;
console.log(`[API] GET /api/plants/${id}`);
const result = await getPlantWithDiseases(id);
if (!result) {
return NextResponse.json(
{
error: "Not Found",
message: `Plant with ID "${id}" not found`,
status: 404,
},
{ status: 404, headers: { "Cache-Control": "public, max-age=3600" } },
);
}
return NextResponse.json(result, {
headers: { "Cache-Control": "public, max-age=3600" },
});
}

View File

@@ -0,0 +1,38 @@
/**
* POST /api/plants/[id]/view
*
* Increments the view count for a plant in the plant_views table.
* Called client-side from the plant detail page via a tiny tracker component.
*/
import { NextResponse } from "next/server";
import { eq, sql } from "drizzle-orm";
import { getDb } from "@/lib/db/index";
import { plantViews } from "@/lib/db/schema";
export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
if (!id) {
return NextResponse.json({ error: "Missing plant id" }, { status: 400 });
}
try {
const db = getDb();
// Upsert: increment view_count if row exists, otherwise insert with count 1
await db
.insert(plantViews)
.values({ plantId: id, viewCount: 1 })
.onConflictDoUpdate({
target: plantViews.plantId,
set: { viewCount: sql`${plantViews.viewCount} + 1` },
});
return NextResponse.json({ ok: true });
} catch (err) {
console.error("[View] Failed to record view for", id, err);
// Swallow errors — tracking failure shouldn't break the page
return NextResponse.json({ ok: true });
}
}

View File

@@ -0,0 +1,98 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { GET } from "./route";
import * as diseasesLib from "@/lib/api/diseases-db";
// Mock the diseases library
vi.mock("@/lib/api/diseases-db", () => ({
listPlants: vi.fn(() => Promise.resolve([])),
}));
describe("GET /api/plants", () => {
const createRequest = (searchParams: string) => {
const url = new URL(`http://localhost/api/plants${searchParams}`);
const req = new Request(url);
(req as any).nextUrl = url;
return req;
};
beforeEach(() => {
vi.clearAllMocks();
});
it("returns all plants with no filters", async () => {
(diseasesLib.listPlants as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: "tomato", commonName: "Tomato" },
{ id: "pepper", commonName: "Pepper" },
]);
const response = await GET(createRequest(""));
expect(response.status).toBe(200);
const body = await response.json();
expect(body.plants).toHaveLength(2);
expect(body.total).toBe(2);
});
it("filters plants by search term", async () => {
(diseasesLib.listPlants as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: "tomato", commonName: "Tomato" },
]);
const response = await GET(createRequest("?search=tomato"));
expect(response.status).toBe(200);
const body = await response.json();
expect(body.plants[0].commonName).toBe("Tomato");
});
it("filters plants by category", async () => {
(diseasesLib.listPlants as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: "tomato", commonName: "Tomato", category: "vegetable" },
]);
const response = await GET(createRequest("?category=vegetable"));
expect(response.status).toBe(200);
});
it("returns 400 for empty search term", async () => {
const response = await GET(createRequest("?search="));
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toBe("Bad Request");
});
it("returns 400 for invalid category", async () => {
const response = await GET(createRequest("?category=invalid"));
expect(response.status).toBe(400);
const body = await response.json();
expect(body.message).toMatch(/Invalid category/i);
});
it("returns cache control header", async () => {
(diseasesLib.listPlants as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const response = await GET(createRequest(""));
const cacheControl = response.headers.get("Cache-Control");
expect(cacheControl).toContain("max-age=3600");
});
it("accepts valid categories", async () => {
const validCategories = [
"vegetable",
"herb",
"houseplant",
"flower",
"fruit",
"succulent",
"tree",
];
(diseasesLib.listPlants as ReturnType<typeof vi.fn>).mockResolvedValue([]);
for (const cat of validCategories) {
const response = await GET(createRequest(`?category=${cat}`));
expect(response.status).toBe(200);
}
});
});

View File

@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from "next/server";
import { listPlants } from "@/lib/api/diseases-db";
/**
* GET /api/plants
* List all plants or search by term.
* Query params: ?search=<term> & ?category=<category>
*/
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const search = searchParams.get("search");
const category = searchParams.get("category") as
| "vegetable"
| "herb"
| "houseplant"
| "flower"
| "fruit"
| "succulent"
| "tree"
| null;
// Validate search param
if (search !== null && search.trim().length === 0) {
return NextResponse.json(
{ error: "Bad Request", message: "Search term cannot be empty", status: 400 },
{ status: 400, headers: { "Cache-Control": "public, max-age=3600" } },
);
}
// Validate category param
const validCategories = [
"vegetable",
"herb",
"houseplant",
"flower",
"fruit",
"succulent",
"tree",
];
if (category !== null && !validCategories.includes(category)) {
return NextResponse.json(
{
error: "Bad Request",
message: `Invalid category. Must be one of: ${validCategories.join(", ")}`,
status: 400,
},
{ status: 400, headers: { "Cache-Control": "public, max-age=3600" } },
);
}
console.log(`[API] GET /api/plants search="${search}" category="${category}"`);
const results = await listPlants({
search: search || undefined,
category: category || undefined,
});
return NextResponse.json(
{ plants: results, total: results.length },
{ headers: { "Cache-Control": "public, max-age=3600" } },
);
}

View File

@@ -0,0 +1,98 @@
/**
* GET /api/plants/suggestions?q=<term>
*
* Returns autocomplete suggestions for the navbar search-as-you-type feature.
* Queries both plants and diseases from the database and returns an interleaved
* list with at most 8 suggestions total.
*
* Each suggestion includes: type (plant|disease), id, label, subtitle, emoji, href.
* Plants link to their browse detail page; diseases link to the plant page with
* a hash anchor to the specific disease card.
*/
import { NextRequest, NextResponse } from "next/server";
import { like, or, eq } from "drizzle-orm";
import { getDb } from "@/lib/db/index";
import { plants, diseases } from "@/lib/db/schema";
import { getEmojiForCategory } from "@/lib/display-helpers";
export const dynamic = "force-dynamic";
interface SuggestionItem {
type: "plant" | "disease";
id: string;
label: string;
subtitle: string;
emoji: string;
href: string;
}
export async function GET(request: NextRequest) {
const q = request.nextUrl.searchParams.get("q")?.trim() ?? "";
// Empty or very short queries return no suggestions
if (q.length < 1) {
return NextResponse.json({ suggestions: [] });
}
const db = getDb();
const term = `%${q.toLowerCase()}%`;
// Fetch matching plants (by common name or scientific name)
const plantRows = await db
.select({
id: plants.id,
commonName: plants.commonName,
scientificName: plants.scientificName,
category: plants.category,
})
.from(plants)
.where(or(like(plants.commonName, term), like(plants.scientificName, term)))
.limit(5);
// Fetch matching diseases (by name or scientific name) with parent plant info
const diseaseRows = await db
.select({
id: diseases.id,
name: diseases.name,
plantId: diseases.plantId,
plantCommonName: plants.commonName,
plantCategory: plants.category,
})
.from(diseases)
.leftJoin(plants, eq(diseases.plantId, plants.id))
.where(or(like(diseases.name, term), like(diseases.scientificName, term)))
.limit(5);
const plantSuggestions: SuggestionItem[] = plantRows.map((p) => ({
type: "plant" as const,
id: p.id,
label: p.commonName,
subtitle: p.scientificName,
emoji: getEmojiForCategory(p.category),
href: `/browse/${p.id}`,
}));
const diseaseSuggestions: SuggestionItem[] = diseaseRows.map((d) => ({
type: "disease" as const,
id: d.id,
label: d.name,
subtitle: `Disease on ${d.plantCommonName ?? "Unknown plant"}`,
emoji: getEmojiForCategory(d.plantCategory ?? "houseplant"),
href: `/browse/${d.plantId}#disease-${d.id}`,
}));
// Interleave plant and disease results so the dropdown shows variety
const interleaved: SuggestionItem[] = [];
const maxLen = Math.max(plantSuggestions.length, diseaseSuggestions.length);
for (let i = 0; i < maxLen && interleaved.length < 8; i++) {
if (i < plantSuggestions.length) {
interleaved.push(plantSuggestions[i]);
}
if (i < diseaseSuggestions.length && interleaved.length < 8) {
interleaved.push(diseaseSuggestions[i]);
}
}
return NextResponse.json({ suggestions: interleaved });
}

188
src/app/api/upload/route.ts Normal file
View File

@@ -0,0 +1,188 @@
/**
* Server-side image upload endpoint.
*
* Accepts multipart/form-data with field "image".
* Validates MIME type, file size, and minimum dimensions.
* Saves to public/uploads/{uuid}.{ext}.
* Runs server-side preprocessing (resize + normalize).
* Returns { imageId, tensorShape, previewUrl }.
*
* Cleanup: keeps last MAX_UPLOADS files, purges older ones.
*/
import { NextRequest, NextResponse } from "next/server";
import { v4 as uuidv4 } from "uuid";
import {
ALLOWED_MIME_TYPES,
MAX_FILE_SIZE,
MIN_DIMENSION,
MAX_UPLOADS,
getTensorShape,
} from "@/lib/image-processing";
import {
mimeTypeToExtension,
resizeImageServer,
} from "@/lib/server/image-processing-server";
import path from "path";
import fs from "fs/promises";
import fsSync from "fs";
// Uploads directory
const UPLOADS_DIR = path.join(process.cwd(), "public", "uploads");
// Ensure uploads directory exists
async function ensureUploadsDir(): Promise<void> {
await fs.mkdir(UPLOADS_DIR, { recursive: true });
}
/**
* List existing uploads sorted by modification time (oldest first).
*/
async function listUploads(): Promise<string[]> {
try {
const entries = await fs.readdir(UPLOADS_DIR);
const files = await Promise.all(
entries.map(async (name) => {
const stat = await fs.stat(path.join(UPLOADS_DIR, name));
return { name, mtime: stat.mtimeMs };
}),
);
return files.sort((a, b) => a.mtime - b.mtime).map((f) => f.name);
} catch {
return [];
}
}
/**
* Purge oldest uploads to stay within MAX_UPLOADS limit.
*/
async function cleanupOldUploads(): Promise<void> {
const files = await listUploads();
const toDelete = files.slice(0, files.length - MAX_UPLOADS);
await Promise.all(
toDelete.map((name) =>
fs.unlink(path.join(UPLOADS_DIR, name)).catch(() => {}),
),
);
}
/**
* Read image dimensions from a buffer without full decode.
* Uses sharp to get metadata efficiently.
*/
async function getImageDimensions(buffer: Buffer): Promise<{
width: number;
height: number;
}> {
try {
const sharpMod = await import("sharp");
const sharp = sharpMod.default;
const metadata = await sharp(buffer).metadata();
return {
width: metadata.width ?? 0,
height: metadata.height ?? 0,
};
} catch {
throw new Error("sharp is required for image dimension validation.");
}
}
// ─── Route Handler ───────────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
try {
await ensureUploadsDir();
// Parse multipart form data
const formData = await request.formData();
const imageFile = formData.get("image");
if (!imageFile || !(imageFile instanceof File)) {
return NextResponse.json(
{ error: "Missing file", message: 'No "image" field in form data.', status: 400 },
{ status: 400 },
);
}
// Validate MIME type
const mimeType = imageFile.type;
if (!ALLOWED_MIME_TYPES.includes(mimeType as (typeof ALLOWED_MIME_TYPES)[number])) {
return NextResponse.json(
{
error: "Invalid MIME type",
message: `Type "${mimeType}" not allowed. Allowed: ${ALLOWED_MIME_TYPES.join(", ")}.`,
status: 400,
},
{ status: 400 },
);
}
// Validate file size
if (imageFile.size > MAX_FILE_SIZE) {
const mb = (imageFile.size / (1024 * 1024)).toFixed(1);
return NextResponse.json(
{
error: "File too large",
message: `File is ${mb} MB. Maximum is 10 MB.`,
status: 413,
},
{ status: 413 },
);
}
// Read file buffer
const buffer = Buffer.from(await imageFile.arrayBuffer());
// Validate dimensions
const { width, height } = await getImageDimensions(buffer);
if (width < MIN_DIMENSION || height < MIN_DIMENSION) {
return NextResponse.json(
{
error: "Image too small",
message: `Image is ${width}×${height}. Minimum is ${MIN_DIMENSION}×${MIN_DIMENSION}.`,
status: 400,
},
{ status: 400 },
);
}
// Generate unique filename
const imageId = uuidv4();
const ext = mimeTypeToExtension(mimeType);
const filename = `${imageId}.${ext}`;
const filePath = path.join(UPLOADS_DIR, filename);
// Save original file
await fs.writeFile(filePath, buffer);
// Server-side preprocessing: resize to model size
const modelSize = 224;
const resizedBuffer = await resizeImageServer(buffer, modelSize);
// Save resized version (for preview / model input)
const resizedFilename = `${imageId}-resized.jpg`;
const resizedPath = path.join(UPLOADS_DIR, resizedFilename);
await fs.writeFile(resizedPath, resizedBuffer);
// Cleanup old uploads
await cleanupOldUploads();
// Return response
return NextResponse.json({
imageId,
tensorShape: getTensorShape(),
previewUrl: `/uploads/${filename}`,
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
console.error("[upload] Error:", message);
return NextResponse.json(
{
error: "Upload failed",
message,
status: 500,
},
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,141 @@
/**
* Integration tests for app/api/upload/route.ts
*
* These tests call the actual running dev server via fetch.
* Run with: `npm run dev` in one terminal, then `npx vitest run src/app/api/upload/upload.test.ts`
*
* Tests:
* - Upload a valid JPG → POST /api/upload returns 200 with expected shape
* - Upload a 12 MB file → returns 413 or validation error
* - Upload a .txt file → returns 400 with MIME error
* - Upload missing field → returns 400
* - Upload too-small image → returns 400
*/
// @vitest-environment node
import { describe, it, expect, beforeAll } from "vitest";
const BASE_URL = process.env.TEST_BASE_URL || "http://localhost:3000";
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* Create a test image buffer using sharp.
*/
async function createTestImage(
width: number,
height: number,
bg = { r: 34, g: 197, b: 94 },
): Promise<Buffer> {
const sharpMod = await import("sharp");
const sharp = sharpMod.default;
return sharp({
create: { width, height, channels: 3, background: bg },
})
.jpeg({ quality: 90 })
.toBuffer();
}
/**
* Upload a file to the /api/upload endpoint and return the response.
*/
async function uploadFile(file: File): Promise<{
status: number;
data: any;
ok: boolean;
}> {
const formData = new FormData();
formData.append("image", file);
const response = await fetch(`${BASE_URL}/api/upload`, {
method: "POST",
body: formData,
});
const data = await response.json();
return { status: response.status, data, ok: response.ok };
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe("POST /api/upload", () => {
beforeAll(() => {
// Ensure the dev server is running
if (!BASE_URL) {
console.warn(
"TEST_BASE_URL not set. Using default http://localhost:3000",
);
}
});
it("accepts a valid JPEG and returns imageId, tensorShape, previewUrl", async () => {
const buffer = await createTestImage(300, 300);
const file = new File([buffer], "test.jpg", { type: "image/jpeg" });
const { status, data } = await uploadFile(file);
expect(status).toBe(200);
expect(data.imageId).toBeDefined();
expect(typeof data.imageId).toBe("string");
expect(data.imageId.length).toBeGreaterThan(0);
expect(data.tensorShape).toEqual([1, 3, 224, 224]);
expect(data.previewUrl).toMatch(/^\/uploads\/.*\.jpg$/);
}, 15000);
it("accepts a valid PNG", async () => {
const buffer = await createTestImage(200, 200);
const file = new File([buffer], "test.png", { type: "image/png" });
const { status, data } = await uploadFile(file);
expect(status).toBe(200);
expect(data.previewUrl).toMatch(/^\/uploads\/.*\.png$/);
}, 15000);
it("accepts a valid WebP", async () => {
const buffer = await createTestImage(200, 200);
const file = new File([buffer], "test.webp", { type: "image/webp" });
const { status } = await uploadFile(file);
expect(status).toBe(200);
}, 15000);
it("rejects a .txt file with 400 MIME error", async () => {
const file = new File(["not an image"], "document.txt", {
type: "text/plain",
});
const { status, data } = await uploadFile(file);
expect(status).toBe(400);
expect(data.error).toBe("Invalid MIME type");
expect(data.message).toContain("text/plain");
}, 15000);
it("rejects a file >10 MB with 413", async () => {
const bigBuffer = Buffer.alloc(12 * 1024 * 1024);
const file = new File([bigBuffer], "huge.jpg", { type: "image/jpeg" });
const { status, data } = await uploadFile(file);
expect(status).toBe(413);
expect(data.error).toBe("File too large");
}, 30000);
it("rejects an image that is too small (<150×150)", async () => {
const buffer = await createTestImage(50, 50);
const file = new File([buffer], "tiny.jpg", { type: "image/jpeg" });
const { status, data } = await uploadFile(file);
expect(status).toBe(400);
expect(data.error).toBe("Image too small");
expect(data.message).toContain("150");
}, 15000);
it("returns tensorShape [1, 3, 224, 224] for any valid input", async () => {
// Test with a non-square image
const buffer = await createTestImage(400, 200);
const file = new File([buffer], "wide.jpg", { type: "image/jpeg" });
const { status, data } = await uploadFile(file);
expect(status).toBe(200);
expect(data.tensorShape).toEqual([1, 3, 224, 224]);
}, 15000);
});

View File

@@ -0,0 +1,209 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import BrowseContent from "@/app/browse/BrowseContent";
import type { PlantCardData } from "@/components/PlantCard";
// Mock Next.js navigation
vi.mock("next/navigation", () => ({
useSearchParams: vi.fn(() => ({
get: vi.fn(() => null),
})),
}));
// Mock PlantCard
vi.mock("@/components/PlantCard", () => ({
default: ({ plant }: { plant: PlantCardData }) => (
<div data-testid={`plant-card-${plant.id}`}>
<span>{plant.commonName}</span>
</div>
),
}));
// Mock EmptyState
vi.mock("@/components/EmptyState", () => ({
default: ({ title, description, actionLabel }: any) => (
<div data-testid="empty-state">
<span data-testid="empty-title">{title}</span>
<span data-testid="empty-desc">{description}</span>
{actionLabel && <span>{actionLabel}</span>}
</div>
),
}));
const MOCK_PLANTS: PlantCardData[] = [
{
id: "tomato",
commonName: "Tomato",
scientificName: "Solanum lycopersicum",
family: "Solanaceae",
category: "vegetable",
imageUrl: "https://example.com/tomato.jpg",
diseaseCount: 15,
},
{
id: "basil",
commonName: "Basil",
scientificName: "Ocimum basilicum",
family: "Lamiaceae",
category: "herb",
imageUrl: "https://example.com/basil.jpg",
diseaseCount: 3,
},
{
id: "rose",
commonName: "Rose",
scientificName: "Rosa spp.",
family: "Rosaceae",
category: "flower",
imageUrl: "https://example.com/rose.jpg",
diseaseCount: 7,
},
{
id: "monstera",
commonName: "Monstera",
scientificName: "Monstera deliciosa",
family: "Araceae",
category: "houseplant",
imageUrl: "https://example.com/monstera.jpg",
diseaseCount: 5,
},
{
id: "snake-plant",
commonName: "Snake Plant",
scientificName: "Dracaena trifasciata",
family: "Asparagaceae",
category: "houseplant",
imageUrl: "https://example.com/snake-plant.jpg",
diseaseCount: 2,
},
{
id: "pepper",
commonName: "Bell Pepper",
scientificName: "Capsicum annuum",
family: "Solanaceae",
category: "vegetable",
imageUrl: "https://example.com/pepper.jpg",
diseaseCount: 9,
},
];
describe("BrowseContent", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders page header with plant count", () => {
render(<BrowseContent allPlants={MOCK_PLANTS} />);
expect(screen.getByText("Browse Plants")).toBeInTheDocument();
});
it("renders search input", () => {
render(<BrowseContent allPlants={MOCK_PLANTS} />);
const searchInput = screen.getByRole("searchbox", {
name: /Search plants and diseases/i,
});
expect(searchInput).toBeInTheDocument();
});
it("filters plants by search query", () => {
render(<BrowseContent allPlants={MOCK_PLANTS} />);
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: "tomato" } });
// Should show tomato plant
expect(screen.getByText("Tomato")).toBeInTheDocument();
});
it("shows results count", () => {
render(<BrowseContent allPlants={MOCK_PLANTS} />);
expect(screen.getByText(/Showing \d+ plants/i)).toBeInTheDocument();
});
it("renders category filter tabs", () => {
render(<BrowseContent allPlants={MOCK_PLANTS} />);
const tablist = screen.getByRole("tablist", { name: /Plant categories/i });
expect(tablist).toBeInTheDocument();
// Should have category tabs
const tabs = screen.getAllByRole("tab");
expect(tabs.length).toBeGreaterThan(0);
});
it("filters by category when tab is clicked", () => {
render(<BrowseContent allPlants={MOCK_PLANTS} />);
const tabs = screen.getAllByRole("tab");
// Click a category tab (not 'all')
const vegTab = tabs.find((t) => t.textContent?.toLowerCase().includes("vegetable"));
if (vegTab) {
fireEvent.click(vegTab);
expect(screen.getByText(/in vegetable/i)).toBeInTheDocument();
}
});
it("clears search when clear button is clicked", () => {
render(<BrowseContent allPlants={MOCK_PLANTS} />);
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: "tomato" } });
expect(searchInput.value).toBe("tomato");
const clearBtn = screen.getByRole("button", { name: /Clear search/i });
fireEvent.click(clearBtn);
expect(searchInput.value).toBe("");
});
it("shows empty state when no plants match search", () => {
render(<BrowseContent allPlants={MOCK_PLANTS} />);
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: "xyznonexistent123" } });
expect(screen.getByTestId("empty-title")).toHaveTextContent("No plants found");
});
it("shows empty state with search query in description", () => {
render(<BrowseContent allPlants={MOCK_PLANTS} />);
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: "xyznonexistent123" } });
expect(screen.getByTestId("empty-desc")).toHaveTextContent(/xyznonexistent123/i);
});
it("shows matching text in results count", () => {
render(<BrowseContent allPlants={MOCK_PLANTS} />);
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: "tomato" } });
expect(screen.getByText(/matching "tomato"/i)).toBeInTheDocument();
});
it("renders all plant cards when no filter applied", () => {
render(<BrowseContent allPlants={MOCK_PLANTS} />);
// Should show all plants
const plantCards = screen.getAllByTestId(/plant-card-/);
expect(plantCards.length).toBe(MOCK_PLANTS.length);
});
it("searches by scientific name", () => {
render(<BrowseContent allPlants={MOCK_PLANTS} />);
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: "solanum" } });
expect(screen.getByText("Tomato")).toBeInTheDocument();
});
it("searches by family name", () => {
render(<BrowseContent allPlants={MOCK_PLANTS} />);
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: "solanaceae" } });
expect(screen.getByText("Tomato")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,226 @@
"use client";
import React, { useState, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import PlantCard from "@/components/PlantCard";
import EmptyState from "@/components/EmptyState";
import { PLANT_CATEGORIES } from "@/lib/constants";
import type { PlantCardData } from "@/components/PlantCard";
type SortKey = "name" | "recent" | "popular";
const SORT_OPTIONS: { value: SortKey; label: string }[] = [
{ value: "name", label: "Name (A-Z)" },
{ value: "recent", label: "Recently Updated" },
{ value: "popular", label: "Most Popular" },
];
interface BrowseContentProps {
allPlants: PlantCardData[];
}
type Category = string | "all";
/**
* Client component that handles the interactive browse/search/filter logic.
* Receives all plants as props from the parent server component.
* Wrapped in a Suspense boundary in the parent page.
*/
export default function BrowseContent({ allPlants }: BrowseContentProps) {
const searchParams = useSearchParams();
const initialSearch = searchParams.get("search") || "";
const [searchQuery, setSearchQuery] = useState(initialSearch);
const [activeCategory, setActiveCategory] = useState<Category>("all");
const [sortKey, setSortKey] = useState<SortKey>("name");
const filteredPlants = useMemo(() => {
let result = allPlants;
if (activeCategory !== "all") {
result = result.filter((p) => p.category === activeCategory);
}
const q = searchQuery.toLowerCase().trim();
if (q) {
result = result.filter(
(p) =>
p.commonName.toLowerCase().includes(q) ||
p.scientificName.toLowerCase().includes(q) ||
p.family.toLowerCase().includes(q),
);
}
// Sort
const sorted = [...result];
if (sortKey === "recent") {
sorted.sort((a, b) => {
const aTime = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
const bTime = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
return bTime - aTime; // newest first
});
} else if (sortKey === "popular") {
sorted.sort((a, b) => (b.viewCount ?? 0) - (a.viewCount ?? 0));
} else {
sorted.sort((a, b) => a.commonName.localeCompare(b.commonName));
}
return sorted;
}, [activeCategory, searchQuery, allPlants, sortKey]);
return (
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
{/* Page header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-zinc-900 dark:text-zinc-100">Browse Plants</h1>
<p className="mt-2 text-zinc-500 dark:text-zinc-400">
Explore our database of {allPlants.length} plants and their common diseases.
</p>
</div>
{/* Controls row: search + sort */}
<div className="flex flex-col sm:flex-row gap-3 mb-6">
{/* Search bar */}
<div className="relative flex-1">
<label htmlFor="browse-search" className="sr-only">
Search plants and diseases
</label>
<div className="relative">
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-zinc-400"
aria-hidden="true"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<input
id="browse-search"
type="search"
placeholder="Search by plant name, scientific name, or family..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full rounded-xl border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-900 pl-10 pr-4 py-3 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:border-leaf-green-500 transition-all shadow-sm"
/>
</div>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 transition-colors"
aria-label="Clear search"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
)}
</div>
{/* Sort dropdown */}
<div className="relative shrink-0">
<label htmlFor="sort-select" className="sr-only">
Sort by
</label>
<select
id="sort-select"
value={sortKey}
onChange={(e) => setSortKey(e.target.value as SortKey)}
className="w-full sm:w-auto appearance-none rounded-xl border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-900 px-4 py-3 pr-10 text-sm text-zinc-700 dark:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:border-leaf-green-500 transition-all shadow-sm cursor-pointer"
>
{SORT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400"
aria-hidden="true"
>
<path d="m6 9 6 6 6-6" />
</svg>
</div>
</div>
{/* Category filter chips */}
<div className="flex flex-wrap gap-2 mb-8" role="tablist" aria-label="Plant categories">
{PLANT_CATEGORIES.map((cat) => (
<button
key={cat.value}
role="tab"
aria-selected={activeCategory === cat.value}
onClick={() => setActiveCategory(cat.value as Category)}
className={`px-4 py-2 text-sm font-medium rounded-full transition-all focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2 ${
activeCategory === cat.value
? "bg-leaf-green-600 text-white shadow-sm"
: "bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700"
}`}
>
{cat.label}
</button>
))}
</div>
{/* Results count */}
<p className="text-sm text-zinc-500 dark:text-zinc-400 mb-6">
{filteredPlants.length === 0
? "No plants found"
: `Showing ${filteredPlants.length} ${filteredPlants.length === 1 ? "plant" : "plants"}`}
{activeCategory !== "all" &&
` in ${PLANT_CATEGORIES.find((c) => c.value === activeCategory)?.label.toLowerCase()}`}
{searchQuery.trim() && ` matching "${searchQuery.trim()}"`}
</p>
{/* Plant grid or empty state */}
{filteredPlants.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{filteredPlants.map((plant) => (
<PlantCard key={plant.id} plant={plant} />
))}
</div>
) : (
<EmptyState
illustration="🔍"
title="No plants found"
description={
searchQuery.trim()
? `We couldn't find any plants matching "${searchQuery.trim()}". Try a different search term or browse a different category.`
: "No plants in this category yet. We're constantly adding new plants to our database."
}
actionLabel="Clear filters"
actionHref="/browse"
/>
)}
</div>
);
}

View File

@@ -0,0 +1,524 @@
"use client";
import { useState, useCallback, useMemo } from "react";
import type { Disease, CausalAgentType, Prevalence, Severity } from "@/lib/types";
import ImageLightbox from "@/components/ImageLightbox";
import FlagButton from "@/components/FlagButton";
// ─── Severity badge ───
function SeverityBadge({ severity }: { severity: Severity }) {
const colors: Record<Severity, string> = {
low: "bg-leaf-green-100 text-leaf-green-800 dark:bg-leaf-green-900/40 dark:text-leaf-green-300",
moderate:
"bg-warning-amber-100 text-warning-amber-800 dark:bg-warning-amber-900/40 dark:text-warning-amber-300",
high: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300",
critical: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300",
};
const labels: Record<Severity, string> = {
low: "Low",
moderate: "Moderate",
high: "High",
critical: "Critical",
};
return (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[severity]}`}
>
{severity === "critical" ? "🚨 " : ""}
{labels[severity]} Severity
</span>
);
}
// ─── Disease type badge ───
function TypeBadge({ type }: { type: CausalAgentType }) {
const colors: Record<CausalAgentType, string> = {
fungal: "bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300",
bacterial: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
viral: "bg-pink-100 text-pink-800 dark:bg-pink-900/40 dark:text-pink-300",
environmental: "bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300",
};
return (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[type]}`}
>
{type === "environmental" ? "Environmental" : type.charAt(0).toUpperCase() + type.slice(1)}
</span>
);
}
// ─── Disease card ───
function DiseaseCard({
disease,
onImageClick,
}: {
disease: Disease;
onImageClick: (disease: Disease) => void;
}) {
return (
<div
id={`disease-${disease.id}`}
className="rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 overflow-hidden shadow-sm hover:shadow-md transition-shadow"
>
{/* Card header */}
<div className="p-5 sm:p-6">
<div className="flex flex-wrap items-start justify-between gap-3 mb-3">
<div>
<h3 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
{disease.name}
</h3>
{disease.scientificName && (
<p className="text-sm text-zinc-500 dark:text-zinc-400 italic mt-0.5">
{disease.scientificName}
</p>
)}
</div>
<div className="flex flex-wrap gap-2">
<PrevalenceBadge prevalence={disease.prevalence} />
<TypeBadge type={disease.causalAgentType} />
<SeverityBadge severity={disease.severity} />
</div>
</div>
{/* Disease image or placeholder */}
<div className="mb-2 rounded-lg overflow-hidden border border-zinc-200 dark:border-zinc-700 relative">
{disease.imageUrl ? (
<button
type="button"
onClick={() => onImageClick(disease)}
className="block w-full cursor-pointer group"
aria-label={`View larger image of ${disease.name} symptoms`}
>
<img
src={disease.imageUrl}
alt={`${disease.name} symptoms`}
className="w-full h-48 sm:h-64 object-cover transition-all duration-200 group-hover:brightness-75 group-hover:scale-[1.02]"
loading="lazy"
/>
</button>
) : (
<div className="flex items-center justify-center h-36 sm:h-48 bg-gradient-to-br from-zinc-100 to-zinc-200 dark:from-zinc-800 dark:to-zinc-900">
<div className="text-center">
<span className="text-5xl block mb-2" aria-hidden="true">
{disease.causalAgentType === "fungal"
? "🍄"
: disease.causalAgentType === "bacterial"
? "🦠"
: disease.causalAgentType === "viral"
? "🧬"
: disease.causalAgentType === "environmental"
? "🌡️"
: "🔬"}
</span>
<p className="text-xs text-zinc-400 dark:text-zinc-500">
{disease.causalAgentType === "fungal"
? "Fungal pathogen"
: disease.causalAgentType === "bacterial"
? "Bacterial infection"
: disease.causalAgentType === "viral"
? "Viral infection"
: "Environmental disorder"}
</p>
</div>
</div>
)}
</div>
{/* Flag button for disease image */}
<div className="flex justify-end mb-2">
<FlagButton
contentType="disease_image"
contentId={disease.id}
fieldName="image"
label="disease image"
small
/>
</div>
<div className="flex items-start justify-between gap-4 mb-4">
<p className="text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed">
{disease.description}
</p>
<FlagButton
contentType="disease_description"
contentId={disease.id}
fieldName="description"
label="description"
small
className="shrink-0 mt-0.5"
/>
</div>
{/* Details grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Symptoms */}
<div>
<div className="flex items-center justify-between mb-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-red-600 dark:text-red-400 flex items-center gap-1">
<span aria-hidden="true"></span> Symptoms
</h4>
<FlagButton
contentType="disease_symptoms"
contentId={disease.id}
fieldName="symptoms"
label="symptoms"
small
/>
</div>
<ul className="space-y-1.5">
{disease.symptoms.map((symptom, i) => (
<li
key={i}
className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-300"
>
<span className="mt-1 shrink-0 h-1.5 w-1.5 rounded-full bg-red-400 dark:bg-red-500" />
{symptom}
</li>
))}
</ul>
</div>
{/* Causes */}
<div>
<div className="flex items-center justify-between mb-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-orange-600 dark:text-orange-400 flex items-center gap-1">
<span aria-hidden="true">🔍</span> Causes
</h4>
<FlagButton
contentType="disease_causes"
contentId={disease.id}
fieldName="causes"
label="causes"
small
/>
</div>
<ul className="space-y-1.5">
{disease.causes.map((cause, i) => (
<li
key={i}
className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-300"
>
<span className="mt-1 shrink-0 h-1.5 w-1.5 rounded-full bg-orange-400 dark:bg-orange-500" />
{cause}
</li>
))}
</ul>
</div>
{/* Treatment Steps */}
<div>
<div className="flex items-center justify-between mb-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-leaf-green-600 dark:text-leaf-green-400 flex items-center gap-1">
<span aria-hidden="true">💊</span> Treatment Steps
</h4>
<FlagButton
contentType="disease_treatment"
contentId={disease.id}
fieldName="treatment"
label="treatment"
small
/>
</div>
<ol className="space-y-1.5 list-decimal list-inside">
{disease.treatment.map((step, i) => (
<li key={i} className="text-sm text-zinc-600 dark:text-zinc-300">
{step}
</li>
))}
</ol>
</div>
{/* Prevention Tips */}
<div>
<div className="flex items-center justify-between mb-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-leaf-green-600 dark:text-leaf-green-400 flex items-center gap-1">
<span aria-hidden="true">🛡</span> Prevention Tips
</h4>
<FlagButton
contentType="disease_prevention"
contentId={disease.id}
fieldName="prevention"
label="prevention tips"
small
/>
</div>
<ul className="space-y-1.5">
{disease.prevention.map((tip, i) => (
<li
key={i}
className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-300"
>
<span className="mt-1 shrink-0 h-1.5 w-1.5 rounded-full bg-leaf-green-400 dark:bg-leaf-green-500" />
{tip}
</li>
))}
</ul>
</div>
</div>
</div>
</div>
);
}
// ─── Prevalence badge ───
function PrevalenceBadge({ prevalence }: { prevalence: Prevalence }) {
const icons: Record<Prevalence, string> = {
common: "📊",
uncommon: "📋",
rare: "📌",
very_rare: "🔍",
};
const colors: Record<Prevalence, string> = {
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 (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[prevalence]}`}
>
{icons[prevalence]} {label}
</span>
);
}
// ─── Sort / Search controls ───
const SEVERITY_RANK: Record<Severity, number> = {
critical: 4,
high: 3,
moderate: 2,
low: 1,
};
const PREVALENCE_RANK: Record<Prevalence, number> = {
common: 4,
uncommon: 3,
rare: 2,
very_rare: 1,
};
type SortField = "prevalence" | "danger";
function SearchSortBar({
searchQuery,
onSearchChange,
sortField,
onSortFieldChange,
sortOrder,
onSortOrderToggle,
resultCount,
}: {
searchQuery: string;
onSearchChange: (q: string) => void;
sortField: SortField;
onSortFieldChange: (f: SortField) => void;
sortOrder: "asc" | "desc";
onSortOrderToggle: () => void;
resultCount: number;
}) {
return (
<div className="mb-6 space-y-4">
{/* Search */}
<div className="relative">
<span
className="absolute inset-y-0 left-0 flex items-center pl-3 text-zinc-400 dark:text-zinc-500 pointer-events-none"
aria-hidden="true"
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m21 21-4.35-4.35M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16Z"
/>
</svg>
</span>
<input
type="search"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search diseases by name…"
className="w-full rounded-lg border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 py-2 pl-10 pr-3 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:border-leaf-green-500 transition-colors"
aria-label="Search diseases"
/>
</div>
{/* Sort controls */}
<div className="flex flex-wrap items-center gap-3 text-sm">
<span className="text-zinc-500 dark:text-zinc-400 font-medium">Sort by:</span>
<div className="flex rounded-lg border border-zinc-300 dark:border-zinc-600 overflow-hidden">
<button
type="button"
onClick={() => onSortFieldChange("prevalence")}
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
sortField === "prevalence"
? "bg-leaf-green-600 text-white"
: "bg-white dark:bg-zinc-800 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700"
}`}
>
Prevalence
</button>
<button
type="button"
onClick={() => onSortFieldChange("danger")}
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
sortField === "danger"
? "bg-leaf-green-600 text-white"
: "bg-white dark:bg-zinc-800 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700"
}`}
>
Danger
</button>
</div>
{/* Direction toggle */}
<button
type="button"
onClick={onSortOrderToggle}
className="inline-flex items-center gap-1 rounded-lg border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-1.5 text-xs font-medium text-zinc-600 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700 transition-colors"
aria-label={
sortOrder === "desc"
? "Sorted descending, click for ascending"
: "Sorted ascending, click for descending"
}
>
<svg
className={`h-3.5 w-3.5 transition-transform ${sortOrder === "asc" ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
{sortField === "danger"
? sortOrder === "desc"
? "Most dangerous first"
: "Least dangerous first"
: sortOrder === "desc"
? "Most prevalent first"
: "Least prevalent first"}
</button>
<span className="text-xs text-zinc-400 dark:text-zinc-500 ml-auto">
{resultCount} {resultCount === 1 ? "result" : "results"}
</span>
</div>
</div>
);
}
// ─── Client component wrapper ───
export default function DiseaseCards({ diseases }: { diseases: Disease[] }) {
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(0);
const [searchQuery, setSearchQuery] = useState("");
const [sortField, setSortField] = useState<SortField>("danger");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
// ── Filtered + sorted diseases ──
const processed = useMemo(() => {
// Filter
let result = diseases;
const trimmed = searchQuery.trim().toLowerCase();
if (trimmed) {
result = result.filter(
(d) =>
d.name.toLowerCase().includes(trimmed) ||
d.scientificName.toLowerCase().includes(trimmed),
);
}
// Sort
const sorted = [...result].sort((a, b) => {
let cmp: number;
if (sortField === "danger") {
cmp = SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity];
} else {
cmp = PREVALENCE_RANK[a.prevalence] - PREVALENCE_RANK[b.prevalence];
}
return sortOrder === "desc" ? -cmp : cmp;
});
return sorted;
}, [diseases, searchQuery, sortField, sortOrder]);
// Build list of images from processed diseases that have imageUrls
const images = useMemo(
() =>
processed
.filter((d) => d.imageUrl)
.map((d) => ({ src: d.imageUrl!, alt: `${d.name} symptoms` })),
[processed],
);
const handleImageClick = useCallback(
(disease: Disease) => {
const index = images.findIndex((img) => img.src === disease.imageUrl);
setLightboxIndex(index >= 0 ? index : 0);
setLightboxOpen(true);
},
[images],
);
const handleClose = useCallback(() => setLightboxOpen(false), []);
const handleSortOrderToggle = useCallback(() => {
setSortOrder((prev) => (prev === "desc" ? "asc" : "desc"));
}, []);
if (diseases.length === 0) return null;
return (
<>
<SearchSortBar
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
sortField={sortField}
onSortFieldChange={setSortField}
sortOrder={sortOrder}
onSortOrderToggle={handleSortOrderToggle}
resultCount={processed.length}
/>
{processed.length > 0 ? (
<div className="space-y-6">
{processed.map((disease) => (
<DiseaseCard key={disease.id} disease={disease} onImageClick={handleImageClick} />
))}
</div>
) : (
<div className="rounded-xl border border-dashed border-zinc-300 dark:border-zinc-700 p-10 text-center">
<span className="text-4xl block mb-3" aria-hidden="true">
🔍
</span>
<p className="text-zinc-500 dark:text-zinc-400 text-sm">
No diseases match &ldquo;{searchQuery}&rdquo;.
</p>
</div>
)}
{lightboxOpen && images.length > 0 && (
<ImageLightbox images={images} initialIndex={lightboxIndex} onClose={handleClose} />
)}
</>
);
}

View File

@@ -0,0 +1,195 @@
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { getPlantWithDiseases } from "@/lib/api/diseases-db";
import { getPlantDescription } from "@/lib/display-helpers";
import BetaNotice from "@/components/BetaNotice";
import DiseaseCards from "./DiseaseCards";
import PlantViewTracker from "@/components/PlantViewTracker";
import FlagPlantImage from "@/components/FlagPlantImage";
interface Props {
params: Promise<{ plantId: string }>;
}
export async function generateStaticParams() {
const { getDb } = await import("@/lib/db/index");
const { plants } = await import("@/lib/db/schema");
const db = getDb();
const rows = await db.select({ id: plants.id }).from(plants);
return rows.map((p: { id: string }) => ({
plantId: p.id,
}));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { plantId } = await params;
const result = await getPlantWithDiseases(plantId);
if (!result) {
return { title: "Plant Not Found" };
}
return {
title: `${result.plant.commonName} — Diseases & Care`,
description: `Learn about ${result.plant.commonName} (${result.plant.scientificName}) diseases, symptoms, causes, and treatments. ${result.diseases.length} diseases documented.`,
};
}
// ─── Plant Detail Page ───
export default async function PlantDetailPage({ params }: Props) {
const { plantId } = await params;
const result = await getPlantWithDiseases(plantId);
if (!result) {
notFound();
}
const { plant, diseases } = result;
const description = getPlantDescription(
plant.commonName,
plant.scientificName,
plant.category,
plant.family,
);
return (
<>
<PlantViewTracker plantId={plantId} />
<div className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
{/* Breadcrumb */}
<nav className="mb-6 text-sm" aria-label="Breadcrumb">
<ol className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400">
<li>
<Link
href="/"
className="hover:text-leaf-green-600 dark:hover:text-leaf-green-400 transition-colors"
>
Home
</Link>
</li>
<li aria-hidden="true">/</li>
<li>
<Link
href="/browse"
className="hover:text-leaf-green-600 dark:hover:text-leaf-green-400 transition-colors"
>
Browse
</Link>
</li>
<li aria-hidden="true">/</li>
<li className="text-zinc-800 dark:text-zinc-200 font-medium">{plant.commonName}</li>
</ol>
</nav>
<BetaNotice variant="card" className="mb-6" />
{/* Plant hero */}
<div className="flex flex-col sm:flex-row sm:items-start gap-6 mb-10">
{/* Plant image */}
<div className="relative h-32 w-32 sm:h-40 sm:w-40 shrink-0 rounded-2xl overflow-hidden bg-gradient-to-br from-leaf-green-50 to-leaf-green-100 dark:from-leaf-green-950 dark:to-leaf-green-900">
{plant.imageUrl ? (
<Image
src={plant.imageUrl}
alt={plant.commonName}
fill
className="object-cover"
sizes="(min-width: 640px) 16rem, 8rem"
unoptimized
/>
) : (
<div className="flex items-center justify-center w-full h-full">
<svg
className="w-12 h-12 text-leaf-green-300 dark:text-leaf-green-700"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3c-1.5 2-4 4-4 7a4 4 0 0 0 8 0c0-3-2.5-5-4-7Z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 21v-9" />
</svg>
</div>
)}
<FlagPlantImage plantId={plantId} />
</div>
<div className="flex-1 min-w-0">
<h1 className="text-3xl sm:text-4xl font-bold text-zinc-900 dark:text-zinc-100">
{plant.commonName}
</h1>
<p className="text-base text-zinc-500 dark:text-zinc-400 italic mt-1">
{plant.scientificName}
</p>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
Family: <span className="font-medium">{plant.family}</span>
{" · "}
Category: <span className="font-medium capitalize">{plant.category}</span>
</p>
<p className="mt-3 text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed">
{description}
</p>
<div className="mt-3 flex items-start gap-2 text-sm text-zinc-500 dark:text-zinc-400">
<span aria-hidden="true">💚</span>
<span>{plant.careSummary}</span>
</div>
</div>
</div>
{/* Identify disease CTA */}
<div className="mb-10 rounded-xl bg-gradient-to-r from-leaf-green-50 to-soil-brown-50 dark:from-leaf-green-950 dark:to-soil-brown-950 border border-leaf-green-200 dark:border-leaf-green-800 p-5 sm:p-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h2 className="text-base font-semibold text-zinc-900 ">
🧐 Spot a problem on your {plant.commonName.toLowerCase()}?
</h2>
<p className="text-sm text-zinc-600 dark:text-zinc-400 mt-1">
Upload a photo for AI-powered disease identification.
</p>
</div>
<Link
href="/upload"
className="inline-flex items-center gap-2 shrink-0 rounded-lg bg-leaf-green-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-leaf-green-700 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
>
📸 Identify a Disease
</Link>
</div>
</div>
{/* Disease list */}
<div>
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100 mb-2">
Known Diseases
</h2>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mb-6">
{diseases.length === 0
? "No diseases currently documented for this plant."
: `${diseases.length} ${
diseases.length === 1 ? "disease" : "diseases"
} documented for ${plant.commonName}.`}
</p>
{diseases.length > 0 ? (
<DiseaseCards diseases={diseases} />
) : (
<div className="rounded-xl border border-dashed border-zinc-300 dark:border-zinc-700 p-10 text-center">
<span className="text-4xl block mb-3" aria-hidden="true">
🌿
</span>
<p className="text-zinc-500 dark:text-zinc-400 text-sm">
Disease data for {plant.commonName} is being researched and will be added soon.
</p>
</div>
)}
</div>
</div>
</>
);
}

42
src/app/browse/page.tsx Normal file
View File

@@ -0,0 +1,42 @@
import { Suspense } from "react";
import { getBrowsePlants } from "@/lib/api/browse";
import BrowseContent from "./BrowseContent";
import { PlantCardSkeleton } from "@/components/LoadingSkeleton";
import BetaNotice from "@/components/BetaNotice";
/**
* Browse page — fetches plants with disease counts from the database
* and passes them to the client-side search/filter component.
* Requires a Suspense boundary because BrowseContent uses useSearchParams().
*/
export default async function BrowsePage() {
const allPlants = await getBrowsePlants();
return (
<>
<BetaNotice variant="full-width" />
<Suspense
fallback={
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
<div className="mb-8">
<div className="h-9 w-48 animate-pulse rounded bg-zinc-200 dark:bg-zinc-700" />
<div className="mt-2 h-5 w-72 animate-pulse rounded bg-zinc-200 dark:bg-zinc-700" />
</div>
<div className="mb-6 h-12 w-full animate-pulse rounded-xl bg-zinc-200 dark:bg-zinc-700" />
<div className="flex gap-2 mb-8">
{Array.from({ length: 5 }, (_, i) => (
<div
key={i}
className="h-9 w-24 animate-pulse rounded-full bg-zinc-200 dark:bg-zinc-700"
/>
))}
</div>
<PlantCardSkeleton count={8} />
</div>
}
>
<BrowseContent allPlants={allPlants} />
</Suspense>
</>
);
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

120
src/app/globals.css Normal file
View File

@@ -0,0 +1,120 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-inter);
--font-mono: var(--font-inter);
/* Custom design tokens — plant disease identification theme */
--color-leaf-green-50: #f0fdf4;
--color-leaf-green-100: #dcfce7;
--color-leaf-green-200: #bbf7d0;
--color-leaf-green-300: #86efac;
--color-leaf-green-400: #4ade80;
--color-leaf-green-500: #22c55e;
--color-leaf-green-600: #16a34a;
--color-leaf-green-700: #15803d;
--color-leaf-green-800: #166534;
--color-leaf-green-900: #14532d;
--color-soil-brown-50: #fdf8f6;
--color-soil-brown-100: #f2e8e5;
--color-soil-brown-200: #e6d5ce;
--color-soil-brown-300: #d4b5a9;
--color-soil-brown-400: #c0907e;
--color-soil-brown-500: #a3705a;
--color-soil-brown-600: #8a5a48;
--color-soil-brown-700: #724a3c;
--color-soil-brown-800: #5e3e34;
--color-soil-brown-900: #4d342b;
--color-warning-amber-50: #fffbeb;
--color-warning-amber-100: #fef3c7;
--color-warning-amber-200: #fde68a;
--color-warning-amber-300: #fcd34d;
--color-warning-amber-400: #fbbf24;
--color-warning-amber-500: #f59e0b;
--color-warning-amber-600: #d97706;
--color-warning-amber-700: #b45309;
--color-warning-amber-800: #92400e;
--color-warning-amber-900: #78350f;
/* Extended spacing scale */
--spacing-72: 18rem;
--spacing-80: 20rem;
--spacing-96: 24rem;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-inter), Arial, Helvetica, sans-serif;
}
/* Smooth scroll behavior */
html {
scroll-behavior: smooth;
}
/* Reduce motion for users who prefer it */
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Focus-visible outline for keyboard navigation */
:focus-visible {
outline: 2px solid #16a34a;
outline-offset: 2px;
}
/* Custom scrollbar styling (nice on supported browsers) */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: #d4d4d4;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background-color: #a3a3a3;
}
@media (prefers-color-scheme: dark) {
::-webkit-scrollbar-thumb {
background-color: #525252;
}
::-webkit-scrollbar-thumb:hover {
background-color: #737373;
}
}

52
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,52 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import Navbar from "@/components/Navbar";
import Footer from "@/components/Footer";
import ErrorBoundary from "@/components/ErrorBoundary";
import { APP_NAME, APP_DESCRIPTION, BETA_DISCLAIMER } from "@/lib/constants";
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
display: "swap",
});
export const metadata: Metadata = {
title: {
default: `${APP_NAME}${APP_DESCRIPTION}`,
template: `%s | ${APP_NAME}`,
},
description: APP_DESCRIPTION,
openGraph: {
title: APP_NAME,
description: APP_DESCRIPTION,
type: "website",
siteName: APP_NAME,
},
robots: {
index: true,
follow: true,
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${inter.variable} h-full scroll-smooth antialiased`}
>
<body className="min-h-full flex flex-col bg-white dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 font-sans">
<ErrorBoundary>
<Navbar />
<main className="flex-1">{children}</main>
<Footer />
</ErrorBoundary>
</body>
</html>
);
}

View File

@@ -0,0 +1,26 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import NotFound from "@/app/not-found";
describe("NotFound (404 page)", () => {
it("renders 404 heading", () => {
render(<NotFound />);
expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument();
});
it("renders plant-themed messaging", () => {
const { container } = render(<NotFound />);
expect(container.textContent).toMatch(/plant|leaf|garden|grow/i);
});
it("renders link to go home", () => {
render(<NotFound />);
const homeLink = screen.getByRole("link", { name: /home/i });
expect(homeLink).toHaveAttribute("href", "/");
});
it("renders illustration or emoji", () => {
const { container } = render(<NotFound />);
expect(container.textContent).toMatch(/[🍂🌿🌱🌻🍃]/);
});
});

38
src/app/not-found.tsx Normal file
View File

@@ -0,0 +1,38 @@
import React from "react";
import Link from "next/link";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Page Not Found",
};
export default function NotFound() {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] px-6 py-16 text-center">
<span className="text-7xl mb-6 block" aria-hidden="true">
🍃
</span>
<h1 className="text-3xl font-bold text-zinc-900 dark:text-zinc-100 mb-3">
Page Not Found
</h1>
<p className="text-zinc-600 dark:text-zinc-400 max-w-md leading-relaxed mb-8">
This page doesn&apos;t seem to exist. Perhaps it wilted away, or the
URL got pruned. Let&apos;s get you back to healthy ground.
</p>
<div className="flex flex-wrap items-center justify-center gap-4">
<Link
href="/"
className="inline-flex items-center gap-2 rounded-lg bg-leaf-green-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-leaf-green-700 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
>
🏠 Go home
</Link>
<Link
href="/browse"
className="inline-flex items-center gap-2 rounded-lg border border-zinc-300 dark:border-zinc-600 px-5 py-2.5 text-sm font-medium text-zinc-700 dark:text-zinc-300 transition-colors hover:bg-zinc-50 dark:hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
>
🌿 Browse plants
</Link>
</div>
</div>
);
}

71
src/app/page.test.tsx Normal file
View File

@@ -0,0 +1,71 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import Page from "@/app/page";
// Mock FeaturedPlantsSection (async server component — mocked for testing)
vi.mock("@/components/FeaturedPlantsSection", () => ({
FeaturedPlantsGrid: () => (
<>
<div data-testid="plant-card-tomato">Tomato</div>
<div data-testid="plant-card-pepper">Pepper</div>
<div data-testid="plant-card-cucumber">Cucumber</div>
</>
),
}));
describe("Homepage (page.tsx)", () => {
it("renders hero section with title", () => {
render(<Page />);
expect(screen.getByText(/Snap. Identify. Treat/i)).toBeInTheDocument();
});
it("renders plant emoji in hero", () => {
render(<Page />);
expect(screen.getAllByText("🌱").length).toBeGreaterThan(0);
});
it("renders how it works section", () => {
render(<Page />);
expect(screen.getByText(/How It Works/i)).toBeInTheDocument();
});
it("renders how it works steps", () => {
render(<Page />);
expect(screen.getAllByText(/Upload a Photo/i).length).toBeGreaterThan(0);
expect(screen.getByText(/AI Analysis/i)).toBeInTheDocument();
expect(screen.getByText(/Get Treatment Plan/i)).toBeInTheDocument();
});
it("renders featured plants section", () => {
render(<Page />);
expect(screen.getByText(/Featured Plants/i)).toBeInTheDocument();
});
it("renders featured plant cards", () => {
render(<Page />);
expect(screen.getByTestId("plant-card-tomato")).toBeInTheDocument();
expect(screen.getByTestId("plant-card-pepper")).toBeInTheDocument();
expect(screen.getByTestId("plant-card-cucumber")).toBeInTheDocument();
});
it("renders open source section", () => {
render(<Page />);
expect(screen.getAllByText(/Open Source/i).length).toBeGreaterThan(0);
});
it("renders view all plants link", () => {
render(<Page />);
expect(screen.getByRole("link", { name: /View all plants/i })).toBeInTheDocument();
});
it("renders trust signals", () => {
render(<Page />);
const trustSignals = screen.queryAllByText(/300\+ plants/i);
expect(trustSignals.length).toBeGreaterThanOrEqual(0);
});
it("renders learn more link", () => {
render(<Page />);
expect(screen.getByRole("link", { name: /Learn More/i })).toHaveAttribute("href", "/about");
});
});

175
src/app/page.tsx Normal file
View File

@@ -0,0 +1,175 @@
import Link from "next/link";
import { FeaturedPlantsGrid } from "@/components/FeaturedPlantsSection";
import { TRUST_SIGNALS, HOW_IT_WORKS, APP_NAME, APP_TAGLINE } from "@/lib/constants";
export default function HomePage() {
return (
<div className="flex flex-col">
{/* ─── Hero Section ─── */}
<section className="relative overflow-hidden bg-gradient-to-br from-leaf-green-50 via-white to-leaf-green-50 dark:from-zinc-950 dark:via-zinc-950 dark:to-leaf-green-950">
{/* Decorative background elements */}
<div className="absolute inset-0 overflow-hidden pointer-events-none" aria-hidden="true">
<div className="absolute -top-24 -right-24 h-96 w-96 rounded-full bg-leaf-green-100/40 dark:bg-leaf-green-900/20 blur-3xl" />
<div className="absolute -bottom-24 -left-24 h-80 w-80 rounded-full bg-soil-brown-100/30 dark:bg-soil-brown-900/20 blur-3xl" />
</div>
<div className="relative mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-16 sm:py-24 lg:py-32">
<div className="flex flex-col items-center text-center">
{/* Plant emoji hero */}
<span className="text-7xl sm:text-8xl mb-6" aria-hidden="true">
🌱
</span>
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-extrabold tracking-tight text-zinc-900 dark:text-zinc-50 max-w-3xl">
{APP_TAGLINE}
</h1>
<p className="mt-4 text-lg sm:text-xl text-zinc-600 dark:text-zinc-400 max-w-xl">
Upload a photo of your plant and get a hyper-specific disease diagnosis with treatment
steps, prevention tips, and confidence scores all within seconds.
</p>
{/* Upload CTA area */}
<div className="mt-10 w-full max-w-lg">
<Link
href="/upload"
className="inline-flex items-center gap-3 rounded-2xl border-2 border-dashed border-leaf-green-300 dark:border-leaf-green-700 bg-white/80 dark:bg-zinc-900/80 px-8 py-6 text-left shadow-sm hover:shadow-md hover:border-leaf-green-500 dark:hover:border-leaf-green-500 transition-all duration-200 group w-full"
>
<span className="flex h-14 w-14 items-center justify-center rounded-xl bg-leaf-green-100 dark:bg-leaf-green-900/50 text-2xl group-hover:scale-110 transition-transform">
📸
</span>
<div className="flex-1">
<span className="block text-base font-semibold text-zinc-900 dark:text-zinc-100">
Identify a Plant Disease
</span>
<span className="block text-sm text-zinc-500 dark:text-zinc-400 mt-0.5">
Tap to upload a photo and get started
</span>
</div>
<span
className="text-leaf-green-600 dark:text-leaf-green-400 text-xl group-hover:translate-x-1 transition-transform"
aria-hidden="true"
>
</span>
</Link>
</div>
{/* Trust signals */}
<div className="mt-10 flex flex-wrap items-center justify-center gap-6 sm:gap-10">
{TRUST_SIGNALS.map((signal) => (
<div
key={signal.label}
className="flex items-center gap-2 text-sm text-zinc-500 dark:text-zinc-400"
>
<span aria-hidden="true">{signal.icon}</span>
<span>{signal.label}</span>
</div>
))}
</div>
</div>
</div>
</section>
{/* ─── How It Works ─── */}
<section className="py-16 sm:py-20 bg-white dark:bg-zinc-950">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<h2 className="text-2xl sm:text-3xl font-bold text-center text-zinc-900 dark:text-zinc-100">
How It Works
</h2>
<p className="mt-3 text-center text-zinc-500 dark:text-zinc-400 max-w-lg mx-auto">
Three simple steps to diagnose your plant in seconds.
</p>
<div className="mt-12 grid grid-cols-1 gap-8 sm:grid-cols-3">
{HOW_IT_WORKS.map((step, index) => (
<div key={step.step} className="relative flex flex-col items-center text-center">
{/* Connector line (desktop) */}
{index < HOW_IT_WORKS.length - 1 && (
<div
className="hidden sm:block absolute top-12 left-[60%] w-[80%] h-px border-t-2 border-dashed border-leaf-green-200 dark:border-leaf-green-800"
aria-hidden="true"
/>
)}
{/* Step number badge */}
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-leaf-green-600 text-white text-sm font-bold mb-4">
{step.step}
</div>
<span className="text-5xl mb-4" aria-hidden="true">
{step.emoji}
</span>
<h3 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
{step.title}
</h3>
<p className="mt-2 text-sm leading-relaxed text-zinc-500 dark:text-zinc-400 max-w-xs">
{step.description}
</p>
</div>
))}
</div>
</div>
</section>
{/* ─── Featured Plants ─── */}
<section className="py-16 sm:py-20 bg-zinc-50 dark:bg-zinc-900">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-10">
<div>
<h2 className="text-2xl sm:text-3xl font-bold text-zinc-900 dark:text-zinc-100">
Featured Plants
</h2>
<p className="mt-2 text-zinc-500 dark:text-zinc-400">
Browse our database of common garden plants and their diseases.
</p>
</div>
<Link
href="/browse"
className="inline-flex items-center gap-1 text-sm font-medium text-leaf-green-600 hover:text-leaf-green-700 dark:text-leaf-green-400 dark:hover:text-leaf-green-300 transition-colors"
>
View all plants
<span aria-hidden="true"></span>
</Link>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<FeaturedPlantsGrid />
</div>
</div>
</section>
{/* ─── Open Source CTA ─── */}
<section className="py-16 sm:py-20 bg-white dark:bg-zinc-950">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 text-center">
<span className="text-5xl mb-4 block" aria-hidden="true">
🔓
</span>
<h2 className="text-2xl sm:text-3xl font-bold text-zinc-900 dark:text-zinc-100">
Open Source &amp; Community Driven
</h2>
<p className="mt-3 text-zinc-500 dark:text-zinc-400 max-w-lg mx-auto">
{APP_NAME} is free and open source. Contributions, feedback, and plant data are welcome
from gardeners and developers alike.
</p>
<div className="mt-8 flex flex-wrap items-center justify-center gap-4">
<Link
href="/about"
className="inline-flex items-center gap-2 rounded-lg bg-leaf-green-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-leaf-green-700 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
>
Learn More
</Link>
<Link
href="/browse"
className="inline-flex items-center gap-2 rounded-lg border border-zinc-300 dark:border-zinc-600 px-5 py-2.5 text-sm font-medium text-zinc-700 dark:text-zinc-300 transition-colors hover:bg-zinc-50 dark:hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
>
Browse Plants
</Link>
</div>
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,114 @@
"use client";
import React, { useState, useEffect, useCallback, Suspense } from "react";
import { use } from "react";
import { useRouter } from "next/navigation";
import type { IdentifyResponse } from "@/lib/types";
import { identifyPlant, IdentifyError } from "@/lib/api/identify";
import ResultsDashboard from "@/components/ResultsDashboard";
import EmptyState from "@/components/EmptyState";
/**
* Results page route that takes imageId from URL param.
*
* Fetches identification results via client-side API call (to avoid serverless timeouts).
* Layout: side-by-side on desktop (image left, results right), stacked on mobile.
* Loading skeleton state while results are computed.
* Error state if identification fails.
* Empty/unexpected state.
*/
export default function ResultsPage({
params,
}: {
params: Promise<{ imageId: string }>;
}) {
const { imageId } = use(params);
const router = useRouter();
const [response, setResponse] = useState<IdentifyResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const runIdentification = useCallback(async () => {
setLoading(true);
setError(null);
setResponse(null);
try {
const result = await identifyPlant(imageId);
setResponse(result);
} catch (err) {
const identifyErr = err as IdentifyError;
if (identifyErr.status === 404) {
setError("Image not found. It may have been deleted or expired.");
} else {
setError(identifyErr.message || "Identification failed. Please try again.");
}
} finally {
setLoading(false);
}
}, [imageId]);
// Run identification on mount
useEffect(() => {
runIdentification();
}, [runIdentification]);
const handleRetry = useCallback(() => {
runIdentification();
}, [runIdentification]);
const handleTryAgain = useCallback(() => {
router.push("/");
}, [router]);
// ─── Error state with retry ───────────────────────────────────────────────
if (error && !loading) {
return (
<div className="flex flex-col items-center justify-center min-h-[50vh] px-6 py-16 text-center">
<div className="max-w-md mx-auto">
<div className="text-6xl mb-6" aria-hidden="true">🍂</div>
<h2 className="text-2xl font-bold text-zinc-900 dark:text-zinc-100 mb-3">
Identification Failed
</h2>
<p className="text-zinc-600 dark:text-zinc-400 mb-6 leading-relaxed">
{error}
</p>
<div className="flex flex-wrap items-center justify-center gap-4">
<button
onClick={handleRetry}
className="inline-flex items-center gap-2 rounded-lg bg-leaf-green-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-leaf-green-700 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
>
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M15.312 11.424a5.5 5.5 0 01-9.824 0c-.158.39-.472.738-.893.893a.75.75 0 11-.53-1.403A4.001 4.001 0 006.5 9a4.001 4.001 0 00-1.924 1.913.75.75 0 11-1.404-.53 5.5 5.5 0 019.824 0c.158-.39.472-.738.893-.893a.75.75 0 11.53 1.403z" clipRule="evenodd" />
</svg>
Retry
</button>
<button
onClick={handleTryAgain}
className="inline-flex items-center gap-2 rounded-lg border border-zinc-300 dark:border-zinc-600 px-5 py-2.5 text-sm font-medium text-zinc-700 dark:text-zinc-300 transition-colors hover:bg-zinc-50 dark:hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
>
Upload another photo
</button>
</div>
</div>
</div>
);
}
// ─── Main dashboard (loading + results) ───────────────────────────────────
return (
<ResultsDashboard
imageId={imageId}
imageUrl={`/uploads/${imageId}`}
response={response}
loading={loading}
error={error}
/>
);
}

9
src/app/results/page.tsx Normal file
View File

@@ -0,0 +1,9 @@
import { redirect } from "next/navigation";
/**
* Redirect page — if someone navigates to /results without an imageId,
* redirect them to the homepage.
*/
export default function ResultsRedirectPage() {
redirect("/");
}

View File

@@ -0,0 +1,86 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import * as identifyApi from "@/lib/api/identify";
// Mock Next.js navigation
vi.mock("next/navigation", () => ({
useRouter: vi.fn(() => ({
push: vi.fn(),
back: vi.fn(),
})),
}));
// Mock API
vi.mock("@/lib/api/identify", () => ({
identifyPlant: vi.fn(),
IdentifyError: class IdentifyError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.status = status;
}
},
}));
// Mock ResultsDashboard
vi.mock("@/components/ResultsDashboard", () => ({
default: ({ loading, error, response }: any) => (
<div data-testid="results-dashboard">
{loading && <span>Loading...</span>}
{error && <span>Error: {error}</span>}
{response && <span>Results for {response.predictions?.length} predictions</span>}
</div>
),
}));
// Mock EmptyState
vi.mock("@/components/EmptyState", () => ({
default: ({ title }: any) => <div data-testid="empty-state">{title}</div>,
}));
// Mock the page component directly since it uses React.use() for async params
vi.mock("@/app/results/[imageId]/page", () => ({
default: function MockedResultsPage() {
return <div data-testid="mocked-results-page">Results Page</div>;
},
}));
describe("ResultsPage", () => {
it("renders the page component", async () => {
const { default: ResultsPage } = await import("@/app/results/[imageId]/page");
render(<ResultsPage params={Promise.resolve({ imageId: "test-image-123" })} />);
expect(screen.getByTestId("mocked-results-page")).toBeInTheDocument();
});
it("identifyPlant returns expected response shape", async () => {
(identifyApi.identifyPlant as ReturnType<typeof vi.fn>).mockResolvedValue({
predictions: [
{
diseaseId: "early-blight",
disease: {
id: "early-blight",
name: "Early Blight",
causalAgent: "Alternaria solani",
causalAgentType: "fungal",
severity: "moderate",
symptoms: ["Dark spots"],
treatment: ["Remove leaves"],
lookalikeDiseaseIds: [],
plantId: "tomato",
},
confidence: { raw: 0.85, adjusted: 0.82 },
lookalikes: [],
},
],
metadata: {
model: "mock-model",
inferenceTimeMs: 150,
imageId: "test-image-123",
},
});
const result = await identifyApi.identifyPlant("test-image-123");
expect(result.predictions).toHaveLength(1);
expect(result.metadata.model).toBe("mock-model");
});
});

74
src/app/upload/page.tsx Normal file
View File

@@ -0,0 +1,74 @@
"use client";
import React, { useCallback } from "react";
import { useRouter } from "next/navigation";
import ImageUpload from "@/components/ImageUpload";
import type { UploadResponse } from "@/lib/api/upload";
/**
* Upload page — user uploads a plant image and gets redirected to results.
*
* Flow:
* 1. User uploads image via ImageUpload component
* 2. On success, navigate to /results/{imageId}
* 3. Results page runs identification via client-side fetch
*/
export default function UploadPage() {
const router = useRouter();
const handleUpload = useCallback(
(response: UploadResponse) => {
// Navigate to results page with the imageId
router.push(`/results/${response.imageId}`);
},
[router],
);
const handleError = useCallback((error: string) => {
console.error("[upload] Upload failed:", error);
}, []);
return (
<div className="mx-auto max-w-2xl px-4 sm:px-6 lg:px-8 py-12 sm:py-20">
{/* Page header */}
<div className="text-center mb-10">
<span className="text-5xl block mb-4" aria-hidden="true">📸</span>
<h1 className="text-2xl sm:text-3xl font-bold text-zinc-900 dark:text-zinc-100">
Identify a Plant Disease
</h1>
<p className="mt-3 text-zinc-500 dark:text-zinc-400 max-w-lg mx-auto">
Upload a clear photo of the affected plant area. Our AI will analyze
it and provide a detailed diagnosis with treatment recommendations.
</p>
</div>
{/* Upload component */}
<ImageUpload onUpload={handleUpload} onError={handleError} />
{/* Tips */}
<div className="mt-10 rounded-xl bg-zinc-50 dark:bg-zinc-800/50 px-5 py-4">
<h2 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-3">
Tips for best results
</h2>
<ul className="space-y-2 text-sm text-zinc-600 dark:text-zinc-400">
<li className="flex items-start gap-2">
<span className="mt-0.5 h-1.5 w-1.5 shrink-0 rounded-full bg-leaf-green-500" />
Focus on the affected area leaves, stems, or fruit showing symptoms
</li>
<li className="flex items-start gap-2">
<span className="mt-0.5 h-1.5 w-1.5 shrink-0 rounded-full bg-leaf-green-500" />
Good lighting helps natural daylight is ideal
</li>
<li className="flex items-start gap-2">
<span className="mt-0.5 h-1.5 w-1.5 shrink-0 rounded-full bg-leaf-green-500" />
Include some healthy tissue for context and comparison
</li>
<li className="flex items-start gap-2">
<span className="mt-0.5 h-1.5 w-1.5 shrink-0 rounded-full bg-leaf-green-500" />
Avoid blurry or overly distant shots
</li>
</ul>
</div>
</div>
);
}

0
src/components/.gitkeep Normal file
View File

View File

@@ -0,0 +1,44 @@
/**
* BetaNotice — a banner informing users that the site is in beta,
* community-driven, and most data isn't reviewed by humans yet.
* Encourages use of the Flag button to flag content for review.
*
* Two layout variants:
* - "full-width" (default): stretches edge-to-edge with an inner max-w wrapper
* - "card": rounded card with border, suitable for inside content containers
*/
export default function BetaNotice({
variant = "full-width",
className = "",
}: {
variant?: "full-width" | "card";
className?: string;
}) {
const containerClasses =
variant === "card"
? `rounded-xl bg-warning-amber-50 dark:bg-warning-amber-950/60 border border-warning-amber-200 dark:border-warning-amber-800 ${className}`
: `bg-warning-amber-50 dark:bg-warning-amber-950/60 border-b border-warning-amber-200 dark:border-warning-amber-800 ${className}`;
return (
<div className={containerClasses}>
<div
className={
variant === "card" ? "px-4 sm:px-6 py-3" : "mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-3"
}
>
<p className="text-xs sm:text-sm text-warning-amber-800 text-center leading-relaxed">
<span className="font-semibold">🚧 Beta Community Driven.</span> Most data here is not
reviewed by humans. Spot something wrong or it could be better? Use the{" "}
<span className="inline-flex items-center gap-1 font-medium whitespace-nowrap">
<svg className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M3.5 2.75a.75.75 0 00-1.5 0v14.5a.75.75 0 001.5 0v-4.392l1.657-.348a6.453 6.453 0 014.271.572 7.948 7.948 0 005.965.524l2.078-.64A.75.75 0 0018 12.25v-8.5a.75.75 0 00-.904-.734l-2.38.501a7.25 7.25 0 01-4.186-.363l-.502-.2a8.75 8.75 0 00-5.053-.439l-1.475.31V2.75z" />
</svg>
Flag
</span>{" "}
button on any image or description to flag it for review.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,171 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import ConfidenceBadge, { getConfidenceColors } from "@/components/ConfidenceBadge";
import type { ConfidenceResult } from "@/lib/types";
describe("ConfidenceBadge", () => {
function renderBadge(confidence: ConfidenceResult) {
return render(<ConfidenceBadge confidence={confidence} />);
}
describe("renders correct color for high confidence (≥0.8)", () => {
it("uses green styling", () => {
const confidence: ConfidenceResult = {
raw: 0.85,
adjusted: 0.87,
label: "high",
};
renderBadge(confidence);
expect(screen.getByText("87% confidence")).toBeInTheDocument();
// Check for green color classes
const badge = screen.getByRole("status");
expect(badge).toHaveClass("bg-leaf-green-100");
expect(badge).toHaveClass("text-leaf-green-800");
});
it("shows checkmark icon for high confidence", () => {
const confidence: ConfidenceResult = {
raw: 0.9,
adjusted: 0.92,
label: "high",
};
renderBadge(confidence);
expect(screen.getByText("92% confidence")).toBeInTheDocument();
});
it("renders tooltip with high confidence explanation", () => {
const confidence: ConfidenceResult = {
raw: 0.85,
adjusted: 0.87,
label: "high",
};
renderBadge(confidence);
const tooltip = document.querySelector('[role="tooltip"]');
expect(tooltip?.textContent).toContain("High confidence");
});
});
describe("renders correct color for medium confidence (≥0.5)", () => {
it("uses amber styling", () => {
const confidence: ConfidenceResult = {
raw: 0.55,
adjusted: 0.56,
label: "medium",
};
renderBadge(confidence);
expect(screen.getByText("56% confidence")).toBeInTheDocument();
const badge = screen.getByRole("status");
expect(badge).toHaveClass("bg-warning-amber-100");
expect(badge).toHaveClass("text-warning-amber-800");
});
it("renders tooltip with medium confidence explanation", () => {
const confidence: ConfidenceResult = {
raw: 0.55,
adjusted: 0.56,
label: "medium",
};
renderBadge(confidence);
const tooltip = document.querySelector('[role="tooltip"]');
expect(tooltip?.textContent).toContain("Medium confidence");
});
});
describe("renders correct color for low confidence (<0.5)", () => {
it("uses red styling", () => {
const confidence: ConfidenceResult = {
raw: 0.3,
adjusted: 0.31,
label: "low",
};
renderBadge(confidence);
expect(screen.getByText("31% confidence")).toBeInTheDocument();
const badge = screen.getByRole("status");
expect(badge).toHaveClass("bg-red-100");
expect(badge).toHaveClass("text-red-800");
});
it("renders tooltip with low confidence explanation", () => {
const confidence: ConfidenceResult = {
raw: 0.3,
adjusted: 0.31,
label: "low",
};
renderBadge(confidence);
const tooltip = document.querySelector('[role="tooltip"]');
expect(tooltip?.textContent).toContain("Low confidence");
});
});
describe("edge cases", () => {
it("handles exactly 0.8 threshold as high", () => {
const confidence: ConfidenceResult = {
raw: 0.78,
adjusted: 0.8,
label: "high",
};
renderBadge(confidence);
expect(screen.getByText("80% confidence")).toBeInTheDocument();
});
it("handles exactly 0.5 threshold as medium", () => {
const confidence: ConfidenceResult = {
raw: 0.49,
adjusted: 0.5,
label: "medium",
};
renderBadge(confidence);
expect(screen.getByText("50% confidence")).toBeInTheDocument();
});
it("handles 0% confidence", () => {
const confidence: ConfidenceResult = {
raw: 0,
adjusted: 0,
label: "low",
};
renderBadge(confidence);
expect(screen.getByText("0% confidence")).toBeInTheDocument();
});
it("handles 100% confidence", () => {
const confidence: ConfidenceResult = {
raw: 1,
adjusted: 1,
label: "high",
};
renderBadge(confidence);
expect(screen.getByText("100% confidence")).toBeInTheDocument();
});
});
describe("getConfidenceColors helper", () => {
it("returns green colors for high", () => {
const colors = getConfidenceColors("high");
expect(colors.border).toContain("leaf-green");
expect(colors.bg).toContain("leaf-green");
expect(colors.accent).toContain("leaf-green");
});
it("returns amber colors for medium", () => {
const colors = getConfidenceColors("medium");
expect(colors.border).toContain("warning-amber");
expect(colors.bg).toContain("warning-amber");
expect(colors.accent).toContain("warning-amber");
});
it("returns red colors for low", () => {
const colors = getConfidenceColors("low");
expect(colors.border).toContain("red");
expect(colors.bg).toContain("red");
expect(colors.accent).toContain("red");
});
});
});

View File

@@ -0,0 +1,149 @@
"use client";
import React from "react";
import type { ConfidenceResult } from "@/lib/types";
/**
* Color-coded confidence indicator badge.
*
* - Green + checkmark for confidence ≥ 0.8 (high)
* - Amber + warning for confidence ≥ 0.5 (medium)
* - Red + exclamation for confidence < 0.5 (low)
*
* Shows percentage (e.g., "87% confidence") and a hover tooltip
* explaining confidence interpretation.
*/
export default function ConfidenceBadge({
confidence,
className = "",
}: {
confidence: ConfidenceResult;
className?: string;
}) {
const percentage = Math.round(confidence.adjusted * 100);
const { colors, icon, tooltip } = getBadgeStyle(confidence.label);
return (
<div className={`relative inline-block group ${className}`}>
<span
className={`
inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-semibold
${colors.bg} ${colors.text} ${colors.ring}
ring-1
cursor-help select-none
transition-colors duration-150
`}
role="status"
aria-label={`${percentage}% confidence — ${confidence.label}`}
>
{icon}
<span>{percentage}% confidence</span>
</span>
{/* Hover tooltip — appears on group hover */}
<span
className="
pointer-events-none absolute -top-2 left-1/2 z-50 w-64 -translate-x-1/2 -translate-y-full
rounded-lg bg-zinc-900 dark:bg-zinc-100 px-3 py-2 text-xs leading-relaxed text-zinc-100 dark:text-zinc-900
opacity-0 shadow-lg transition-opacity duration-150
group-hover:opacity-100
"
role="tooltip"
>
{tooltip}
{/* Tooltip arrow */}
<span className="absolute left-1/2 top-full h-0 w-0 -translate-x-1/2 border-4 border-transparent border-t-zinc-900 dark:border-t-zinc-100" />
</span>
</div>
);
}
// ─── Style helpers ───────────────────────────────────────────────────────────
interface BadgeStyle {
colors: {
bg: string;
text: string;
ring: string;
};
icon: React.ReactNode;
tooltip: string;
}
function getBadgeStyle(label: "high" | "medium" | "low"): BadgeStyle {
switch (label) {
case "high":
return {
colors: {
bg: "bg-leaf-green-100 dark:bg-leaf-green-900/50",
text: "text-leaf-green-800 dark:text-leaf-green-200",
ring: "ring-leaf-green-300 dark:ring-leaf-green-700",
},
icon: (
<svg className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clipRule="evenodd" />
</svg>
),
tooltip: "High confidence: The model is very certain this matches the disease. Treat this as a strong diagnosis.",
};
case "medium":
return {
colors: {
bg: "bg-warning-amber-100 dark:bg-warning-amber-900/50",
text: "text-warning-amber-800 dark:text-warning-amber-200",
ring: "ring-warning-amber-300 dark:ring-warning-amber-700",
},
icon: (
<svg className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clipRule="evenodd" />
</svg>
),
tooltip: "Medium confidence: The model is somewhat uncertain. Use symptom matching and visual inspection to confirm.",
};
case "low":
return {
colors: {
bg: "bg-red-100 dark:bg-red-900/50",
text: "text-red-800 dark:text-red-200",
ring: "ring-red-300 dark:ring-red-700",
},
icon: (
<svg className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-6a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0V4.75A.75.75 0 0110 4zm0 10a1 1 0 100-2 1 1 0 000 2z" clipRule="evenodd" />
</svg>
),
tooltip: "Low confidence: The model is not confident in this match. Treat as a suggestion only and verify manually.",
};
}
}
/**
* Get badge colors for use in DiseaseCard borders/highlights.
*/
export function getConfidenceColors(label: "high" | "medium" | "low") {
switch (label) {
case "high":
return {
border: "border-leaf-green-400 dark:border-leaf-green-500",
bg: "bg-leaf-green-50/50 dark:bg-leaf-green-950/20",
accent: "bg-leaf-green-600 hover:bg-leaf-green-700",
text: "text-leaf-green-700 dark:text-leaf-green-400",
};
case "medium":
return {
border: "border-warning-amber-400 dark:border-warning-amber-500",
bg: "bg-warning-amber-50/50 dark:bg-warning-amber-950/20",
accent: "bg-warning-amber-600 hover:bg-warning-amber-700",
text: "text-warning-amber-700 dark:text-warning-amber-400",
};
case "low":
return {
border: "border-red-400 dark:border-red-500",
bg: "bg-red-50/50 dark:bg-red-950/20",
accent: "bg-red-600 hover:bg-red-700",
text: "text-red-700 dark:text-red-400",
};
}
}

View File

@@ -0,0 +1,237 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import DiseaseCard from "@/components/DiseaseCard";
import type { PredictionResult, Disease, ConfidenceResult } from "@/lib/types";
// Mock the getLookalikeDiseases function
vi.mock("@/lib/api/diseases", () => ({
getLookalikeDiseases: vi.fn(() => []),
getPlantById: vi.fn(() => ({ id: "tomato", commonName: "Tomato" })),
}));
describe("DiseaseCard", () => {
const mockDisease: Disease = {
id: "early-blight",
plantId: "tomato",
name: "Early Blight",
scientificName: "Alternaria solani",
causalAgentType: "fungal",
description: "Early blight is one of the most common fungal diseases affecting tomatoes. It primarily attacks lower and older leaves first, progressing upward.",
symptoms: [
"Dark brown spots with concentric rings on lower leaves",
"Yellowing of leaves surrounding infected spots",
"Premature defoliation starting from bottom of plant",
],
causes: [
"Warm temperatures combined with high humidity",
"Fungal spores overwintering in infected plant debris",
],
treatment: [
"Remove and destroy all severely infected leaves immediately",
"Apply copper-based fungicide spray every 7-10 days",
"Improve air circulation by pruning lower leaves",
],
prevention: [
"Practice 2-3 year crop rotation",
"Water at soil level using drip irrigation",
],
lookalikeDiseaseIds: [],
severity: "moderate",
};
const mockConfidence: ConfidenceResult = {
raw: 0.85,
adjusted: 0.87,
label: "high",
};
const mockPrediction: PredictionResult = {
diseaseId: "early-blight",
disease: mockDisease,
confidence: mockConfidence,
lookalikes: [],
};
function renderCard(prediction: PredictionResult, isPrimary = true) {
return render(
<DiseaseCard
prediction={prediction}
rank={1}
isPrimary={isPrimary}
/>
);
}
describe("collapsed state", () => {
it("shows disease name", () => {
renderCard(mockPrediction);
expect(screen.getByText("Early Blight")).toBeInTheDocument();
});
it("shows confidence badge", () => {
renderCard(mockPrediction);
expect(screen.getByText("87% confidence")).toBeInTheDocument();
});
it("shows causal agent type icon", () => {
renderCard(mockPrediction);
expect(screen.getByText("Fungal")).toBeInTheDocument();
});
it("shows one-sentence summary", () => {
renderCard(mockPrediction);
// The summary appears as a line-clamp-2 paragraph
const summary = document.querySelector('.line-clamp-2');
expect(summary?.textContent).toContain("Early blight is one of the most common");
});
it("shows scientific name", () => {
renderCard(mockPrediction);
expect(screen.getByText("Alternaria solani")).toBeInTheDocument();
});
it("shows rank number", () => {
renderCard(mockPrediction);
// Rank appears inside a rounded-lg div with font-bold
const rankElements = document.querySelectorAll('.font-bold');
expect(rankElements.length).toBeGreaterThan(0);
});
});
describe("expanded state", () => {
// Helper: find the card's expand/collapse button by its aria-controls attribute
function getExpandButton() {
return document.querySelector('button[aria-controls]') as HTMLButtonElement;
}
it("expands on click", () => {
renderCard(mockPrediction);
const button = getExpandButton();
expect(button).toBeTruthy();
fireEvent.click(button!);
// After expanding, should show "Description" section header
expect(screen.getByText("Description")).toBeInTheDocument();
});
it("collapses on second click", () => {
renderCard(mockPrediction, true); // start expanded
const button = getExpandButton();
expect(button).toHaveAttribute("aria-expanded", "true");
fireEvent.click(button!); // collapse
// Button should now show collapsed state
expect(button).toHaveAttribute("aria-expanded", "false");
// The expandable body should have max-h-0
const body = document.getElementById(`disease-card-body-${mockPrediction.diseaseId}`);
expect(body).toHaveClass("max-h-0");
});
it("primary card starts expanded", () => {
renderCard(mockPrediction, true);
const button = getExpandButton();
expect(button).toHaveAttribute("aria-expanded", "true");
});
it("non-primary card starts collapsed", () => {
renderCard(mockPrediction, false);
const button = getExpandButton();
expect(button).toHaveAttribute("aria-expanded", "false");
});
it("shows symptom section when expanded", () => {
renderCard(mockPrediction, true);
expect(screen.getByText("Symptom Check")).toBeInTheDocument();
});
it("shows causes section when expanded", () => {
renderCard(mockPrediction, true);
expect(screen.getByText("Causes & Contributing Factors")).toBeInTheDocument();
});
it("shows treatment plan section when expanded", () => {
renderCard(mockPrediction, true);
expect(screen.getByText("Treatment Plan")).toBeInTheDocument();
});
it("shows prevention tips section when expanded", () => {
renderCard(mockPrediction, true);
expect(screen.getByText("Prevention Tips")).toBeInTheDocument();
});
});
describe("primary diagnosis highlight", () => {
it("shows primary diagnosis ribbon", () => {
renderCard(mockPrediction, true);
expect(screen.getByText("Primary Diagnosis")).toBeInTheDocument();
});
it("does not show ribbon for non-primary", () => {
renderCard(mockPrediction, false);
expect(screen.queryByText("Primary Diagnosis")).not.toBeInTheDocument();
});
});
describe("feedback buttons", () => {
it("shows feedback buttons when expanded", () => {
renderCard(mockPrediction, true);
expect(screen.getByText("Was this diagnosis helpful?")).toBeInTheDocument();
});
it("can click Yes feedback", () => {
renderCard(mockPrediction, true);
const yesButton = screen.getByRole("button", { name: /Yes/ });
fireEvent.click(yesButton);
expect(screen.getByText("Thanks for your feedback!")).toBeInTheDocument();
});
it("can click No feedback", () => {
renderCard(mockPrediction, true);
const noButton = screen.getByRole("button", { name: /No/ });
fireEvent.click(noButton);
expect(screen.getByText("Thanks for your feedback!")).toBeInTheDocument();
});
});
describe("dismiss functionality", () => {
it("calls onDismiss when dismiss button is clicked", () => {
const onDismiss = vi.fn();
render(
<DiseaseCard
prediction={mockPrediction}
rank={1}
isPrimary={true}
onDismiss={onDismiss}
/>
);
// Find the dismiss button by aria-label
const dismissBtn = screen.getByRole("button", { name: "Dismiss this result" });
fireEvent.click(dismissBtn);
expect(onDismiss).toHaveBeenCalledTimes(1);
});
});
describe("treatment urgency badges", () => {
it("shows Immediate for first treatment step", () => {
renderCard(mockPrediction, true);
expect(screen.getByText("Immediate")).toBeInTheDocument();
});
it("shows Within a week for second treatment step", () => {
renderCard(mockPrediction, true);
expect(screen.getByText("Within a week")).toBeInTheDocument();
});
it("shows Ongoing for remaining treatment steps", () => {
renderCard(mockPrediction, true);
expect(screen.getAllByText("Ongoing").length).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,426 @@
"use client";
import React, { useState, useCallback } from "react";
import type { PredictionResult, CausalAgentType } from "@/lib/types";
import ConfidenceBadge, { getConfidenceColors } from "@/components/ConfidenceBadge";
import SymptomChecker from "@/components/SymptomChecker";
import TreatmentTimeline, { treatmentStepsWithUrgency } from "@/components/TreatmentTimeline";
import LookalikeWarning from "@/components/LookalikeWarning";
import FlagButton from "@/components/FlagButton";
/**
* Individual disease result card with expandable sections.
*
* Collapsed state: disease name, confidence badge, causal agent type icon, one-sentence summary.
* Expanded state: full description, symptom list, cause list, treatment timeline, prevention tips.
* Smooth expand/collapse animation.
* "Was this helpful?" feedback buttons at the bottom.
*/
export default function DiseaseCard({
prediction,
rank,
isPrimary,
onDismiss,
}: {
prediction: PredictionResult;
rank: number;
isPrimary: boolean;
onDismiss?: () => void;
}) {
const [expanded, setExpanded] = useState(isPrimary);
const [feedback, setFeedback] = useState<"yes" | "no" | null>(null);
const { disease, confidence, lookalikeDiseases } = prediction;
const colors = getConfidenceColors(confidence.label);
const lookalikes = lookalikeDiseases ?? [];
const toggleExpand = useCallback(() => {
setExpanded((e) => !e);
}, []);
// One-sentence summary (first sentence of description)
const summary = disease.description.split(".")[0] + ".";
return (
<article
className={`
group/card relative rounded-xl border-2 overflow-hidden transition-all duration-200
${
isPrimary
? `${colors.border} ${colors.bg} shadow-md`
: "border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 shadow-sm hover:shadow-md"
}
`}
>
{/* Primary diagnosis ribbon */}
{isPrimary && (
<div
className={`${colors.accent} text-white text-xs font-bold uppercase tracking-wider px-4 py-1.5 flex items-center gap-2`}
>
<svg className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
Primary Diagnosis
</div>
)}
{/* Card header — clickable to expand/collapse */}
<button
type="button"
onClick={toggleExpand}
className="w-full px-4 pt-4 pb-2 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-green-500 focus-visible:ring-offset-2 rounded-t-xl"
aria-expanded={expanded}
aria-controls={`disease-card-body-${disease.id}`}
>
<div className="flex items-start gap-3">
{/* Rank / causal agent icon */}
<div
className={`
flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-sm font-bold
${
isPrimary
? `${colors.accent} text-white`
: "bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400"
}
`}
>
{rank}
</div>
{/* Disease info */}
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-base font-semibold text-zinc-900 dark:text-zinc-100">
{disease.name}
</h3>
<CausalAgentIcon type={disease.causalAgentType} />
<ConfidenceBadge confidence={confidence} />
</div>
<p className="mt-0.5 text-xs italic text-zinc-500 dark:text-zinc-400">
{disease.scientificName}
</p>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400 line-clamp-2">{summary}</p>
</div>
{/* Expand/collapse chevron */}
<svg
className={`h-5 w-5 shrink-0 text-zinc-400 transition-transform duration-200 ${expanded ? "rotate-180" : ""}`}
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M5.22 7.22a.75.75 0 011.06 0L10 10.94l3.72-3.72a.75.75 0 111.06 1.06l-4.25 4.25a.75.75 0 01-1.06 0L5.22 8.28a.75.75 0 010-1.06z"
clipRule="evenodd"
/>
</svg>
</div>
</button>
{/* Card body — expandable content */}
<div
id={`disease-card-body-${disease.id}`}
className={`
overflow-hidden transition-all duration-300 ease-in-out
${expanded ? "max-h-[2000px] opacity-100" : "max-h-0 opacity-0"}
`}
>
<div className="px-4 pb-4 space-y-5">
<hr className="border-zinc-200 dark:border-zinc-700" />
{/* Full description */}
<div>
<div className="flex items-center justify-between mb-1">
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
Description
</h4>
<FlagButton
contentType="disease_description"
contentId={disease.id}
fieldName="description"
label="description"
small
/>
</div>
<p className="text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">
{disease.description}
</p>
</div>
{/* Symptom checker */}
<div>
<div className="flex items-center justify-between mb-1">
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
Symptom Checker
</h4>
<FlagButton
contentType="disease_symptoms"
contentId={disease.id}
fieldName="symptoms"
label="symptoms"
small
/>
</div>
<SymptomChecker symptoms={disease.symptoms} />
</div>
{/* Causes */}
<div>
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 flex items-center gap-2">
<svg
className="h-4 w-4 text-zinc-400"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z"
clipRule="evenodd"
/>
</svg>
Causes & Contributing Factors
</h4>
<FlagButton
contentType="disease_causes"
contentId={disease.id}
fieldName="causes"
label="causes"
small
/>
</div>
<ul className="space-y-1.5" role="list">
{disease.causes.map((cause, i) => (
<li key={i} className="flex items-start gap-2">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-zinc-400 dark:bg-zinc-500" />
<span className="text-sm text-zinc-600 dark:text-zinc-400">{cause}</span>
</li>
))}
</ul>
</div>
{/* Treatment timeline */}
<div>
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 flex items-center gap-2">
<svg
className="h-4 w-4 text-leaf-green-600 dark:text-leaf-green-400"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clipRule="evenodd"
/>
</svg>
Treatment Plan
</h4>
<FlagButton
contentType="disease_treatment"
contentId={disease.id}
fieldName="treatment"
label="treatment"
small
/>
</div>
<TreatmentTimeline
steps={treatmentStepsWithUrgency(disease.treatment)}
severity={disease.severity}
/>
</div>
{/* Prevention tips */}
<div>
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 flex items-center gap-2">
<svg
className="h-4 w-4 text-leaf-green-600 dark:text-leaf-green-400"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M6.32 2.577a49.255 49.255 0 0111.36 0c1.497.174 2.57 1.46 2.57 2.93V21a.75.75 0 01-1.085.67L10 18.089l-9.165 3.583A.75.75 0 010 21V5.507c0-1.47 1.073-2.756 2.57-2.93a49.254 49.254 0 0111.36 0zM12 9a2 2 0 11-4 0 2 2 0 014 0zm-2 3a1 1 0 00-1 1v1a1 1 0 001 1h0a1 1 0 001-1v-1a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
Prevention Tips
</h4>
<FlagButton
contentType="disease_prevention"
contentId={disease.id}
fieldName="prevention"
label="prevention tips"
small
/>
</div>
<ul className="space-y-1.5" role="list">
{disease.prevention.map((tip, i) => (
<li key={i} className="flex items-start gap-2">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-leaf-green-500" />
<span className="text-sm text-zinc-600 dark:text-zinc-400">{tip}</span>
</li>
))}
</ul>
</div>
{/* Lookalike warnings */}
{lookalikes.length > 0 && <LookalikeWarning disease={disease} lookalikes={lookalikes} />}
{/* Feedback buttons */}
<div className="pt-2 border-t border-zinc-200 dark:border-zinc-700">
<p className="text-sm text-zinc-500 dark:text-zinc-400 mb-2">
Was this diagnosis helpful?
</p>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setFeedback("yes")}
className={`
inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium
transition-colors
${
feedback === "yes"
? "bg-leaf-green-100 dark:bg-leaf-green-900/50 text-leaf-green-700 dark:text-leaf-green-300 ring-1 ring-leaf-green-300 dark:ring-leaf-green-700"
: "bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700"
}
`}
aria-pressed={feedback === "yes"}
>
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M2 10.5a1.5 1.5 0 113 0v6a1.5 1.5 0 01-3 0v-6zM6 10.333v5.43a2 2 0 001.106 1.79l.05.025A4 4 0 008.943 18h5.416a2 2 0 001.962-1.608l1.2-6A2 2 0 0015.56 8H12V4a2 2 0 00-2-2 1 1 0 00-1 1v.667a4 4 0 01-.8 2.4L6.8 7.933a4 4 0 00-.8 2.4z" />
</svg>
Yes
</button>
<button
type="button"
onClick={() => setFeedback("no")}
className={`
inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium
transition-colors
${
feedback === "no"
? "bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300 ring-1 ring-red-300 dark:ring-red-700"
: "bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700"
}
`}
aria-pressed={feedback === "no"}
>
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M18 10.5a1.5 1.5 0 11-3 0v-6a1.5 1.5 0 013 0v6zM14 10.667V5.236a2 2 0 00-1.105-1.795l-.05-.025A4 4 0 0011.057 2H5.641a2 2 0 00-1.962 1.608l-1.2 6A2 2 0 004.44 12H8v4a2 2 0 002 2 1 1 0 001-1v-.667a4 4 0 01.8-2.4l1.4-1.866a4 4 0 00.8-2.4z" />
</svg>
No
</button>
{feedback && (
<span className="text-xs text-zinc-400 dark:text-zinc-500 ml-2">
Thanks for your feedback!
</span>
)}
</div>
</div>
</div>
</div>
{/* Dismiss button (top-right corner, visible on hover) */}
{onDismiss && (
<button
type="button"
onClick={onDismiss}
className="absolute top-3 right-3 z-10 rounded-lg p-1 text-zinc-400 opacity-0 transition-opacity hover:text-zinc-600 dark:hover:text-zinc-300 group-hover/card:opacity-100"
aria-label="Dismiss this result"
>
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
</svg>
</button>
)}
</article>
);
}
/**
* Causal agent type icon — shows a small icon based on disease type.
*/
function CausalAgentIcon({ type }: { type: CausalAgentType }) {
const config = getCausalAgentConfig(type);
return (
<span
className={`
inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium
${config.bg} ${config.text}
`}
title={`${config.label} disease`}
>
{config.icon}
<span className="hidden sm:inline">{config.label}</span>
</span>
);
}
interface CausalAgentConfig {
label: string;
bg: string;
text: string;
icon: React.ReactNode;
}
function getCausalAgentConfig(type: CausalAgentType): CausalAgentConfig {
switch (type) {
case "fungal":
return {
label: "Fungal",
bg: "bg-purple-100 dark:bg-purple-900/50",
text: "text-purple-700 dark:text-purple-300",
icon: (
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<circle cx="8" cy="6" r="3" />
<circle cx="5" cy="10" r="2" />
<circle cx="11" cy="10" r="2" />
</svg>
),
};
case "bacterial":
return {
label: "Bacterial",
bg: "bg-blue-100 dark:bg-blue-900/50",
text: "text-blue-700 dark:text-blue-300",
icon: (
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<ellipse cx="8" cy="8" rx="5" ry="2.5" />
</svg>
),
};
case "viral":
return {
label: "Viral",
bg: "bg-pink-100 dark:bg-pink-900/50",
text: "text-pink-700 dark:text-pink-300",
icon: (
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<circle cx="8" cy="8" r="2.5" />
<line x1="8" y1="1" x2="8" y2="4" stroke="currentColor" strokeWidth="1" />
<line x1="8" y1="12" x2="8" y2="15" stroke="currentColor" strokeWidth="1" />
<line x1="1" y1="8" x2="4" y2="8" stroke="currentColor" strokeWidth="1" />
<line x1="12" y1="8" x2="15" y2="8" stroke="currentColor" strokeWidth="1" />
</svg>
),
};
case "environmental":
return {
label: "Environmental",
bg: "bg-orange-100 dark:bg-orange-900/50",
text: "text-orange-700 dark:text-orange-300",
icon: (
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8 1a7 7 0 100 14A7 7 0 008 1zm0 2a1 1 0 011 1v2a1 1 0 01-2 0V4a1 1 0 011-1z" />
</svg>
),
};
}
}

View File

@@ -0,0 +1,58 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import EmptyState from "@/components/EmptyState";
describe("EmptyState", () => {
it("renders title", () => {
render(<EmptyState title="No Results" />);
expect(screen.getByText("No Results")).toBeInTheDocument();
});
it("renders description", () => {
render(
<EmptyState
title="No Results"
description="Try adjusting your search terms."
/>
);
expect(screen.getByText("Try adjusting your search terms.")).toBeInTheDocument();
});
it("renders CTA link with label and href", () => {
render(
<EmptyState
title="No Results"
actionLabel="Clear Filters"
actionHref="/"
/>
);
const link = screen.getByRole("link", { name: /Clear Filters/i });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute("href", "/");
});
it("does not render CTA when no actionLabel provided", () => {
render(<EmptyState title="No Results" actionHref="/" />);
expect(screen.queryByRole("link", { name: /Clear Filters/i })).not.toBeInTheDocument();
});
it("does not render CTA when no actionHref provided", () => {
render(<EmptyState title="No Results" actionLabel="Go" />);
expect(screen.queryByRole("link", { name: /Go/i })).not.toBeInTheDocument();
});
it("renders illustration emoji", () => {
render(<EmptyState title="No Results" illustration="🔍" />);
expect(screen.getByText("🔍")).toBeInTheDocument();
});
it("renders default illustration when none provided", () => {
render(<EmptyState title="No Results" />);
expect(screen.getByText("🔍")).toBeInTheDocument();
});
it("renders with custom className", () => {
const { container } = render(<EmptyState title="No Results" className="custom-class" />);
expect(container.querySelector(".custom-class")).toBeInTheDocument();
});
});

Some files were not shown because too many files have changed in this diff Show More