flagging
This commit is contained in:
1
apps/web/data/.gitignore
vendored
Normal file
1
apps/web/data/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
dataset
|
||||
14
apps/web/drizzle/0004_add-flagged-content.sql
Normal file
14
apps/web/drizzle/0004_add-flagged-content.sql
Normal 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`);
|
||||
469
apps/web/drizzle/meta/0004_snapshot.json
Normal file
469
apps/web/drizzle/meta/0004_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,13 @@
|
||||
"when": 1749268800000,
|
||||
"tag": "0003_giant_toad",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1751846400000,
|
||||
"tag": "0004_add-flagged-content",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"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",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
11
apps/web/scripts/.flagged-content-review-needed.md
Normal file
11
apps/web/scripts/.flagged-content-review-needed.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# 🚩 Flagged Content Review — Nothing to Review
|
||||
|
||||
Generated: 2026-06-06T21:02:03.301Z
|
||||
|
||||
**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=1_
|
||||
53
apps/web/scripts/apply-flag-migration.ts
Normal file
53
apps/web/scripts/apply-flag-migration.ts
Normal 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);
|
||||
});
|
||||
19
apps/web/scripts/check-progress.mjs
Normal file
19
apps/web/scripts/check-progress.mjs
Normal 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();
|
||||
379
apps/web/scripts/generate-flagged-report.ts
Normal file
379
apps/web/scripts/generate-flagged-report.ts
Normal file
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* 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_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_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(` `);
|
||||
}
|
||||
} else {
|
||||
const disease = diseaseMap.get(item.content_id);
|
||||
if (type === "disease_image" && disease?.image_url) {
|
||||
lines.push("");
|
||||
lines.push(` `);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -2,59 +2,113 @@
|
||||
/**
|
||||
* scrape-training-dataset.ts
|
||||
*
|
||||
* Collects a training dataset for fine-tuning by scraping DuckDuckGo image search.
|
||||
* Collects a training dataset from DuckDuckGo, iNaturalist, and Wikimedia Commons.
|
||||
*
|
||||
* Targets:
|
||||
* - 200 images per disease class (93 diseases)
|
||||
* - 400 images for the "healthy" class
|
||||
* - Full resolution images stored in data/dataset/{class_id}/
|
||||
* Targets (tiered by plant type):
|
||||
* - Core plants (houseplants + common garden): 100 images per disease
|
||||
* - Full set (all 11,498 DB diseases): 10 images per disease
|
||||
* - Healthy: 400 images
|
||||
*
|
||||
* DuckDuckGo approach (no API key needed):
|
||||
* 1. Fetch the main search page to extract a vqd (query) token
|
||||
* 2. Use the vqd token to paginate through image results
|
||||
* 3. Download each image to the dataset directory
|
||||
* Sources (all free, no API keys):
|
||||
* 1. DB image_url — existing images already found
|
||||
* 2. DuckDuckGo — general web image search
|
||||
* 3. iNaturalist — real-world plant observation photos
|
||||
* 4. Wikimedia Commons — curated scientific/educational images
|
||||
*
|
||||
* Usage: cd apps/web && npx tsx scripts/scrape-training-dataset.ts
|
||||
*
|
||||
* Progress is tracked in data/dataset/.progress.json — interrupt and resume safely.
|
||||
* Progress: data/dataset/.progress.json — interrupt and resume safely.
|
||||
*/
|
||||
|
||||
import "dotenv/config";
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from "fs";
|
||||
import { resolve, extname, join } from "path";
|
||||
import { readFileSync, 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 DISEASES_JSON = resolve(__dirname, "../src/data/diseases.json");
|
||||
const PLANTS_JSON = resolve(__dirname, "../src/data/plants.json");
|
||||
|
||||
const DATASET_DIR = resolve(__dirname, "../data/dataset");
|
||||
const PROGRESS_FILE = resolve(DATASET_DIR, ".progress.json");
|
||||
|
||||
/** Target images per disease class */
|
||||
const TARGET_PER_DISEASE = 200;
|
||||
/** Target images per disease for CORE plants */
|
||||
const TARGET_CORE = 100;
|
||||
|
||||
/** Target images for the "healthy" class (2× normal) */
|
||||
/** Target images per disease for the FULL set */
|
||||
const TARGET_FULL = 10;
|
||||
|
||||
/** Target images for the "healthy" class */
|
||||
const TARGET_HEALTHY = 400;
|
||||
|
||||
/** Core plants that get higher image targets */
|
||||
const CORE_PLANTS = new Set([
|
||||
// Houseplants
|
||||
"monstera",
|
||||
"pothos",
|
||||
"snake-plant",
|
||||
"peace-lily",
|
||||
"orchid",
|
||||
"succulent",
|
||||
"fiddle-leaf-fig",
|
||||
"aloe-vera",
|
||||
"cactus",
|
||||
"fern",
|
||||
// Garden plants
|
||||
"tomato",
|
||||
"basil",
|
||||
"rose",
|
||||
"pepper",
|
||||
"strawberry",
|
||||
"cucumber",
|
||||
"squash",
|
||||
"lettuce",
|
||||
"spinach",
|
||||
"cabbage",
|
||||
"lavender",
|
||||
"mint",
|
||||
"jasmine",
|
||||
"sunflower",
|
||||
"daisy",
|
||||
"zucchini",
|
||||
"bean",
|
||||
"eggplant",
|
||||
"chili",
|
||||
// General disease patterns
|
||||
"general",
|
||||
]);
|
||||
|
||||
/** Delay between DuckDuckGo search API calls (ms) */
|
||||
const SEARCH_DELAY = 1500;
|
||||
|
||||
/** Delay between image downloads (ms) */
|
||||
const DOWNLOAD_DELAY = 300;
|
||||
const DOWNLOAD_DELAY = 100;
|
||||
|
||||
/** Max concurrent downloads */
|
||||
const CONCURRENT_DOWNLOADS = 5;
|
||||
const CONCURRENT_DOWNLOADS = 10;
|
||||
|
||||
/** Minimum image size in bytes to accept (reject tiny placeholders) */
|
||||
/** 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 image content types */
|
||||
const ALLOWED_CONTENT_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"];
|
||||
|
||||
/** Allowed file extensions */
|
||||
const ALLOWED_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp"];
|
||||
|
||||
@@ -62,22 +116,16 @@ const ALLOWED_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp"];
|
||||
const UA =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
||||
|
||||
/** Class ID for healthy plants */
|
||||
const HEALTHY_CLASS = "healthy";
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface DiseaseSeed {
|
||||
interface DbDisease {
|
||||
id: string;
|
||||
plantId: string;
|
||||
name: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface PlantSeed {
|
||||
id: string;
|
||||
commonName: string;
|
||||
scientificName: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[key: string]: any;
|
||||
imageUrl: string | null;
|
||||
}
|
||||
|
||||
interface DuckDuckGoImageResult {
|
||||
@@ -93,10 +141,7 @@ interface ClassProgress {
|
||||
count: number;
|
||||
downloaded: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
/** URLs we've already seen (to avoid duplicates) */
|
||||
seenUrls: string[];
|
||||
/** Whether we've exhausted search results */
|
||||
exhausted: boolean;
|
||||
}
|
||||
|
||||
@@ -105,15 +150,27 @@ interface Progress {
|
||||
classes: Record<string, ClassProgress>;
|
||||
}
|
||||
|
||||
/** Class ID for healthy plants */
|
||||
const HEALTHY_CLASS = "healthy";
|
||||
// ─── DB Loading ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load all diseases from the database with their existing image URLs.
|
||||
*/
|
||||
async function loadDiseasesFromDb(): Promise<DbDisease[]> {
|
||||
const db = getDb();
|
||||
const rows = await db
|
||||
.select({
|
||||
id: diseases.id,
|
||||
plantId: diseases.plantId,
|
||||
name: diseases.name,
|
||||
imageUrl: diseases.imageUrl,
|
||||
})
|
||||
.from(diseases)
|
||||
.orderBy(diseases.id);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ─── DuckDuckGo API ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract the vqd token from DuckDuckGo's search page.
|
||||
* Required for paginating image results.
|
||||
*/
|
||||
async function getVqdToken(query: string): Promise<string> {
|
||||
const url = `https://duckduckgo.com/?q=${encodeURIComponent(query)}&t=h_&iax=images&ia=images`;
|
||||
|
||||
@@ -122,25 +179,15 @@ async function getVqdToken(query: string): Promise<string> {
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to get vqd token: ${res.status}`);
|
||||
}
|
||||
if (!res.ok) throw new Error(`Failed to get vqd token: ${res.status}`);
|
||||
|
||||
const html = await res.text();
|
||||
|
||||
// Extract vqd token from the HTML
|
||||
// Format: vqd='<token>' or vqd="<token>"
|
||||
const match = html.match(/vqd['"]?\s*[:=]\s*['"]([a-f0-9-]+)['"]/);
|
||||
if (!match) {
|
||||
throw new Error(`Could not extract vqd token from DuckDuckGo response for "${query}"`);
|
||||
}
|
||||
if (!match) throw new Error(`Could not extract vqd token for "${query}"`);
|
||||
|
||||
return match[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a page of DuckDuckGo image results.
|
||||
*/
|
||||
async function searchImagesDuckDuckGo(
|
||||
query: string,
|
||||
vqd: string,
|
||||
@@ -161,12 +208,9 @@ async function searchImagesDuckDuckGo(
|
||||
if (res.status === 429) {
|
||||
console.warn(" ⚠ Rate limited (429). Waiting 10s...");
|
||||
await sleep(10_000);
|
||||
return searchImagesDuckDuckGo(query, vqd, page); // Retry
|
||||
}
|
||||
if (res.status === 403) {
|
||||
console.warn(" ⚠ Forbidden (403). Token may have expired.");
|
||||
return []; // Token expired — no more pages
|
||||
return searchImagesDuckDuckGo(query, vqd, page);
|
||||
}
|
||||
if (res.status === 403) return [];
|
||||
throw new Error(`DuckDuckGo search failed: ${res.status}`);
|
||||
}
|
||||
|
||||
@@ -174,11 +218,7 @@ async function searchImagesDuckDuckGo(
|
||||
return data.results ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Search DuckDuckGo images, automatically paginating to collect up to `target` results.
|
||||
* Returns unique image URLs.
|
||||
*/
|
||||
async function collectImages(
|
||||
async function collectImagesDuckDuckGo(
|
||||
query: string,
|
||||
target: number,
|
||||
seenUrls: Set<string>,
|
||||
@@ -188,27 +228,29 @@ async function collectImages(
|
||||
let exhausted = false;
|
||||
let consecutiveEmpty = 0;
|
||||
|
||||
// Get vqd token
|
||||
let vqd: string;
|
||||
try {
|
||||
vqd = await getVqdToken(query);
|
||||
} catch (err) {
|
||||
console.warn(` ⚠ Failed to get vqd token: ${err instanceof Error ? err.message : "unknown"}`);
|
||||
console.warn(` ⚠ DDG token failed: ${err instanceof Error ? err.message : "unknown"}`);
|
||||
return { urls: [], exhausted: true };
|
||||
}
|
||||
|
||||
while (results.length < target) {
|
||||
const MAX_PAGES = 5;
|
||||
let lowNoveltyCount = 0;
|
||||
|
||||
while (results.length < target && page <= MAX_PAGES) {
|
||||
await sleep(SEARCH_DELAY);
|
||||
|
||||
let pageResults: DuckDuckGoImageResult[];
|
||||
try {
|
||||
pageResults = await searchImagesDuckDuckGo(query, vqd, page);
|
||||
} catch (err) {
|
||||
console.warn(` ⚠ Search error: ${err instanceof Error ? err.message : "unknown"}`);
|
||||
console.warn(` ⚠ DDG error: ${err instanceof Error ? err.message : "unknown"}`);
|
||||
break;
|
||||
}
|
||||
|
||||
if (pageResults.length === 0) {
|
||||
if (!pageResults || pageResults.length === 0) {
|
||||
consecutiveEmpty++;
|
||||
if (consecutiveEmpty >= 3) {
|
||||
exhausted = true;
|
||||
@@ -223,78 +265,160 @@ async function collectImages(
|
||||
|
||||
for (const r of pageResults) {
|
||||
if (results.length >= target) break;
|
||||
|
||||
const imgUrl = r.image || r.url;
|
||||
|
||||
// Skip if we've already seen this URL
|
||||
if (!imgUrl || typeof imgUrl !== "string") continue;
|
||||
if (seenUrls.has(imgUrl)) continue;
|
||||
|
||||
// Validate URL looks like an image
|
||||
const ext = extname(new URL(imgUrl).pathname).toLowerCase();
|
||||
if (!ALLOWED_EXTENSIONS.includes(ext) && !ext) {
|
||||
// No extension - still try, could be a CDN URL
|
||||
try {
|
||||
new URL(imgUrl);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenUrls.add(imgUrl);
|
||||
results.push(imgUrl);
|
||||
newCount++;
|
||||
}
|
||||
|
||||
if (newCount === 0 && pageResults.every((r) => seenUrls.has(r.image || r.url))) {
|
||||
// All results on this page were already seen
|
||||
page++;
|
||||
continue;
|
||||
const newRatio = newCount / pageResults.length;
|
||||
if (newRatio < 0.05) {
|
||||
lowNoveltyCount++;
|
||||
if (lowNoveltyCount >= 2) break;
|
||||
} else {
|
||||
lowNoveltyCount = 0;
|
||||
}
|
||||
|
||||
if (results.length < target) {
|
||||
page++;
|
||||
}
|
||||
if (results.length < target) page++;
|
||||
}
|
||||
|
||||
return { urls: results.slice(0, target), exhausted };
|
||||
}
|
||||
|
||||
// ─── iNaturalist API ─────────────────────────────────────────────────────────
|
||||
|
||||
async function searchImagesInaturalist(
|
||||
query: string,
|
||||
target: number,
|
||||
seenUrls: Set<string>,
|
||||
): Promise<{ urls: string[]; exhausted: boolean }> {
|
||||
const results: string[] = [];
|
||||
const perPage = Math.min(target, 200);
|
||||
|
||||
const apiUrl =
|
||||
`https://api.inaturalist.org/v1/observations` +
|
||||
`?q=${encodeURIComponent(query)}` +
|
||||
`&photos_only=true` +
|
||||
`&quality_grade=research` +
|
||||
`&per_page=${perPage}` +
|
||||
`&order_by=observed_on&order=desc`;
|
||||
|
||||
try {
|
||||
const res = await fetch(apiUrl, {
|
||||
headers: { "User-Agent": UA, Accept: "application/json" },
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!res.ok) return { urls: [], exhausted: false };
|
||||
|
||||
const data = (await res.json()) as {
|
||||
results: Array<{ photos: Array<{ url: string }> }>;
|
||||
};
|
||||
|
||||
for (const obs of data.results ?? []) {
|
||||
if (results.length >= target) break;
|
||||
for (const photo of obs.photos ?? []) {
|
||||
if (results.length >= target) break;
|
||||
const url = photo.url;
|
||||
if (!url || seenUrls.has(url)) continue;
|
||||
const fullUrl = url.replace("/medium.", "/original.");
|
||||
seenUrls.add(fullUrl);
|
||||
results.push(fullUrl);
|
||||
}
|
||||
}
|
||||
|
||||
return { urls: results, exhausted: results.length < target };
|
||||
} catch {
|
||||
return { urls: results, exhausted: false };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Wikimedia Commons API ──────────────────────────────────────────────────
|
||||
|
||||
async function searchImagesCommons(
|
||||
query: string,
|
||||
target: number,
|
||||
seenUrls: Set<string>,
|
||||
): Promise<{ urls: string[]; exhausted: boolean }> {
|
||||
const results: string[] = [];
|
||||
let sroffset = 0;
|
||||
|
||||
while (results.length < target) {
|
||||
const params = new URLSearchParams({
|
||||
action: "query",
|
||||
list: "search",
|
||||
srsearch: query,
|
||||
srnamespace: "6",
|
||||
srlimit: "50",
|
||||
sroffset: String(sroffset),
|
||||
format: "json",
|
||||
origin: "*", // server-side API call
|
||||
});
|
||||
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Download a single image from a URL to the target path.
|
||||
* Returns true if successful, false otherwise.
|
||||
*/
|
||||
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" },
|
||||
headers: { "User-Agent": UA, Accept: "image/webp,image/png,image/jpeg,*/*" },
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
|
||||
if (!res.ok) return false;
|
||||
|
||||
const contentType = res.headers.get("content-type") || "";
|
||||
const contentLength = parseInt(res.headers.get("content-length") || "0", 10);
|
||||
|
||||
// Validate content type
|
||||
if (!ALLOWED_CONTENT_TYPES.some((t) => contentType.includes(t))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate size
|
||||
if (contentLength > 0 && contentLength < MIN_IMAGE_SIZE) return false;
|
||||
if (contentLength > MAX_IMAGE_SIZE) return false;
|
||||
if (contentType.includes("text/html")) return false;
|
||||
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
|
||||
// Double-check actual buffer size
|
||||
if (buffer.length < MIN_IMAGE_SIZE) return false;
|
||||
if (buffer.length > MAX_IMAGE_SIZE) return false;
|
||||
|
||||
// Determine correct extension from content type or URL
|
||||
let ext = extname(new URL(url).pathname).toLowerCase();
|
||||
if (!ALLOWED_EXTENSIONS.includes(ext)) {
|
||||
// Map from content type
|
||||
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"; // Default
|
||||
else ext = ".jpg";
|
||||
}
|
||||
|
||||
const filePath = destPath.replace(/\.\w+$/, ext);
|
||||
@@ -305,9 +429,6 @@ async function downloadImage(url: string, destPath: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download multiple images concurrently, respecting a per-download delay.
|
||||
*/
|
||||
async function downloadBatch(
|
||||
urls: string[],
|
||||
classDir: string,
|
||||
@@ -317,7 +438,6 @@ async function downloadBatch(
|
||||
let failed = 0;
|
||||
let index = startIndex;
|
||||
|
||||
// Process in chunks to control concurrency
|
||||
for (let i = 0; i < urls.length; i += CONCURRENT_DOWNLOADS) {
|
||||
const chunk = urls.slice(i, i + CONCURRENT_DOWNLOADS);
|
||||
|
||||
@@ -325,16 +445,23 @@ async function downloadBatch(
|
||||
chunk.map(async (url) => {
|
||||
const paddedIndex = String(index).padStart(4, "0");
|
||||
const destPath = resolve(classDir, `img_${paddedIndex}.jpg`);
|
||||
|
||||
const success = await downloadImage(url, destPath);
|
||||
await sleep(DOWNLOAD_DELAY);
|
||||
return { success, index: index++ };
|
||||
return { success, index: index++, url: url.substring(0, 50) };
|
||||
}),
|
||||
);
|
||||
|
||||
for (const r of results) {
|
||||
if (r.success) downloaded++;
|
||||
else failed++;
|
||||
else {
|
||||
failed++;
|
||||
if (failed % 20 === 1) console.log(` ⚠ Failed: ${r.url}...`);
|
||||
}
|
||||
}
|
||||
|
||||
const total = downloaded + failed;
|
||||
if (total % 30 === 0 || total === urls.length) {
|
||||
console.log(` Progress: ${downloaded}/${urls.length} (${failed} failed)`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,7 +488,6 @@ function getClassProgress(progress: Progress, classId: string): ClassProgress {
|
||||
count: 0,
|
||||
downloaded: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
seenUrls: [],
|
||||
exhausted: false,
|
||||
};
|
||||
@@ -369,26 +495,22 @@ function getClassProgress(progress: Progress, classId: string): ClassProgress {
|
||||
return progress.classes[classId];
|
||||
}
|
||||
|
||||
// ─── Search Query Building ──────────────────────────────────────────────────
|
||||
// ─── Query Building ─────────────────────────────────────────────────────────
|
||||
|
||||
function buildSearchQueries(disease: DiseaseSeed, plant: PlantSeed | null): string[] {
|
||||
const name = disease.name;
|
||||
const plantName = plant?.commonName || disease.plantId;
|
||||
|
||||
return [
|
||||
`${name} ${plantName} leaf disease`,
|
||||
`${plantName} ${name} symptoms`,
|
||||
`${name} plant disease`,
|
||||
`${plantName} diseased leaf`,
|
||||
];
|
||||
function buildSearchQueries(disease: DbDisease): string[] {
|
||||
const name = disease.name || disease.id.replace(/-/g, " ");
|
||||
const plant = disease.plantId.replace(/-/g, " ");
|
||||
// Every query keeps the disease NAME to avoid noisy labels
|
||||
return [`${name} ${plant} leaf disease`, `${plant} ${name} symptoms`, `${name} ${plant}`];
|
||||
}
|
||||
|
||||
function buildHealthyQueries(plant: PlantSeed): string[] {
|
||||
function buildHealthyQueries(plant: string): string[] {
|
||||
const name = plant.replace(/-/g, " ");
|
||||
return [
|
||||
`healthy ${plant.commonName} leaf`,
|
||||
`${plant.commonName} leaf closeup`,
|
||||
`healthy ${plant.commonName} plant`,
|
||||
`${plant.commonName} foliage`,
|
||||
`healthy ${name} leaf`,
|
||||
`${name} leaf closeup`,
|
||||
`healthy ${name} plant`,
|
||||
`${name} foliage`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -400,64 +522,97 @@ async function collectClassImages(
|
||||
target: number,
|
||||
progress: Progress,
|
||||
classDir: string,
|
||||
existingUrls: string[] = [],
|
||||
fastMode = false, // Skip slow DuckDuckGo, use iNat + Commons only
|
||||
): Promise<void> {
|
||||
const cp = getClassProgress(progress, classId);
|
||||
const seenUrls = new Set(cp.seenUrls);
|
||||
|
||||
if (cp.count >= target) {
|
||||
console.log(` ✓ Already have ${cp.count}/${target} images`);
|
||||
console.log(` ✓ Already have ${cp.count}/${target}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cp.exhausted) {
|
||||
console.log(` ✓ Already exhausted search results (${cp.count}/${target} images)`);
|
||||
console.log(` ✓ Exhausted (${cp.count}/${target})`);
|
||||
return;
|
||||
}
|
||||
|
||||
mkdirSync(classDir, { recursive: true });
|
||||
|
||||
const totalUrls: string[] = [];
|
||||
const allUrls: string[] = [];
|
||||
let exhausted = false;
|
||||
|
||||
// Search with each query until we hit the target
|
||||
for (const query of queries) {
|
||||
if (totalUrls.length >= target) break;
|
||||
|
||||
console.log(` Searching: "${query}"...`);
|
||||
const result = await collectImages(query, target - totalUrls.length, seenUrls);
|
||||
|
||||
totalUrls.push(...result.urls);
|
||||
cp.seenUrls = Array.from(seenUrls);
|
||||
|
||||
if (result.exhausted) {
|
||||
exhausted = true;
|
||||
// ── Source 0: Existing DB URLs ──────────────────────────────────────────
|
||||
const freshDbUrls = existingUrls.filter((u) => !seenUrls.has(u));
|
||||
if (freshDbUrls.length > 0) {
|
||||
console.log(` DB: ${freshDbUrls.length} existing URLs`);
|
||||
for (const url of freshDbUrls) {
|
||||
if (allUrls.length >= target) break;
|
||||
seenUrls.add(url);
|
||||
allUrls.push(url);
|
||||
}
|
||||
|
||||
if (totalUrls.length >= target) break;
|
||||
}
|
||||
|
||||
if (totalUrls.length === 0) {
|
||||
// ── Source 1: DuckDuckGo ──────────────────────────────────────────────
|
||||
// Skip DDG in fast mode (full set — DDG is slowest source)
|
||||
if (!fastMode && allUrls.length < target) {
|
||||
for (const query of queries) {
|
||||
if (allUrls.length >= target) break;
|
||||
process.stdout.write(` DDG: "${query.substring(0, 40)}"... `);
|
||||
const result = await collectImagesDuckDuckGo(query, target - allUrls.length, seenUrls);
|
||||
allUrls.push(...result.urls);
|
||||
if (result.exhausted) exhausted = true;
|
||||
console.log(`${result.urls.length} new`);
|
||||
if (allUrls.length >= target) break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Source 2: iNaturalist ──────────────────────────────────────────────
|
||||
if (allUrls.length < target) {
|
||||
const primaryQuery = queries[0];
|
||||
console.log(` iNat: Searching...`);
|
||||
const result = await searchImagesInaturalist(primaryQuery, target - allUrls.length, seenUrls);
|
||||
allUrls.push(...result.urls);
|
||||
if (result.exhausted) exhausted = true;
|
||||
console.log(` iNat: ${result.urls.length} images`);
|
||||
}
|
||||
|
||||
// ── Source 3: Wikimedia Commons ────────────────────────────────────────
|
||||
if (allUrls.length < target) {
|
||||
const primaryQuery = queries[0];
|
||||
console.log(` Commons: Searching...`);
|
||||
const result = await searchImagesCommons(primaryQuery, target - allUrls.length, seenUrls);
|
||||
allUrls.push(...result.urls);
|
||||
if (result.exhausted) exhausted = true;
|
||||
console.log(` Commons: ${result.urls.length} images`);
|
||||
}
|
||||
|
||||
if (allUrls.length === 0) {
|
||||
cp.exhausted = exhausted;
|
||||
saveProgress(progress);
|
||||
console.log(` ✗ No images found for "${classId}"`);
|
||||
console.log(` ✗ No images found`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(` Found ${totalUrls.length} unique image URLs. Downloading...`);
|
||||
// Save progress with seen URLs BEFORE downloading
|
||||
cp.seenUrls = Array.from(seenUrls);
|
||||
cp.exhausted = exhausted;
|
||||
saveProgress(progress);
|
||||
|
||||
// Download the images
|
||||
const { downloaded, failed } = await downloadBatch(totalUrls, classDir, cp.count);
|
||||
console.log(` Downloading ${allUrls.length} images...`);
|
||||
|
||||
const { downloaded, failed } = await downloadBatch(allUrls, classDir, cp.count);
|
||||
|
||||
cp.count += downloaded;
|
||||
cp.downloaded += downloaded;
|
||||
cp.failed += failed;
|
||||
cp.exhausted = exhausted;
|
||||
|
||||
saveProgress(progress);
|
||||
|
||||
const pct = Math.round((cp.count / target) * 100);
|
||||
console.log(
|
||||
` ${downloaded > 0 ? "✓" : "✗"} Got ${downloaded} images (${failed} failed). Total: ${cp.count}/${target} (${pct}%)`,
|
||||
` ${downloaded > 0 ? "✓" : "✗"} Got ${downloaded}/${allUrls.length} (${failed} failed). Total: ${cp.count}/${target} (${pct}%)`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -465,25 +620,18 @@ async function collectClassImages(
|
||||
|
||||
async function main() {
|
||||
console.log("=".repeat(60));
|
||||
console.log("PLANT DISEASE DATASET COLLECTOR");
|
||||
console.log("PLANT DISEASE DATASET COLLECTOR — FULL DB");
|
||||
console.log("=".repeat(60));
|
||||
|
||||
// Load knowledge base
|
||||
const diseases = JSON.parse(readFileSync(DISEASES_JSON, "utf-8")) as DiseaseSeed[];
|
||||
const plants = JSON.parse(readFileSync(PLANTS_JSON, "utf-8")) as PlantSeed[];
|
||||
// Load diseases from DB
|
||||
console.log("\nLoading diseases from database...");
|
||||
const dbDiseases = await loadDiseasesFromDb();
|
||||
console.log(` ${dbDiseases.length} diseases loaded`);
|
||||
|
||||
const plantMap = new Map<string, PlantSeed>();
|
||||
for (const p of plants) {
|
||||
plantMap.set(p.id, p);
|
||||
}
|
||||
|
||||
console.log(`\nLoaded ${diseases.length} diseases, ${plants.length} plants`);
|
||||
console.log(
|
||||
`Target: ${TARGET_PER_DISEASE} images/disease (×${diseases.length} = ${diseases.length * TARGET_PER_DISEASE})`,
|
||||
);
|
||||
console.log(`Target: ${TARGET_HEALTHY} images for "healthy" class`);
|
||||
console.log(`Output: ${DATASET_DIR}/`);
|
||||
console.log("");
|
||||
const coreDiseases = dbDiseases.filter((d) => CORE_PLANTS.has(d.plantId));
|
||||
const fullDiseases = dbDiseases.filter((d) => !CORE_PLANTS.has(d.plantId));
|
||||
console.log(` Core plants: ${coreDiseases.length} diseases (target: ${TARGET_CORE})`);
|
||||
console.log(` Full set: ${fullDiseases.length} diseases (target: ${TARGET_FULL})`);
|
||||
|
||||
// Load progress
|
||||
mkdirSync(DATASET_DIR, { recursive: true });
|
||||
@@ -491,28 +639,46 @@ async function main() {
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// ── Phase 1: Disease classes ──────────────────────────────────────────────
|
||||
|
||||
console.log("─".repeat(60));
|
||||
console.log("PHASE 1: Disease Images");
|
||||
console.log("─".repeat(60));
|
||||
|
||||
for (let i = 0; i < diseases.length; i++) {
|
||||
const disease = diseases[i];
|
||||
const plant = plantMap.get(disease.plantId) ?? null;
|
||||
const classDir = resolve(DATASET_DIR, disease.id);
|
||||
const queries = buildSearchQueries(disease, plant);
|
||||
|
||||
const pct = Math.round((i / diseases.length) * 100);
|
||||
console.log(`\n[${i + 1}/${diseases.length}] (${pct}%) ${disease.name} (${disease.id})`);
|
||||
|
||||
await collectClassImages(disease.id, queries, TARGET_PER_DISEASE, progress, classDir);
|
||||
}
|
||||
|
||||
// ── Phase 2: Healthy class ────────────────────────────────────────────────
|
||||
// ── Phase 1: Core set ──────────────────────────────────────────────────
|
||||
|
||||
console.log("\n" + "─".repeat(60));
|
||||
console.log("PHASE 2: Healthy Plant Images");
|
||||
console.log("PHASE 1: Core Diseases (100 images each)");
|
||||
console.log("─".repeat(60));
|
||||
|
||||
for (let i = 0; i < coreDiseases.length; i++) {
|
||||
const d = coreDiseases[i];
|
||||
const classDir = resolve(DATASET_DIR, d.id);
|
||||
const queries = buildSearchQueries(d);
|
||||
const existingUrls = d.imageUrl ? [d.imageUrl] : [];
|
||||
|
||||
const pct = Math.round((i / coreDiseases.length) * 100);
|
||||
console.log(`\n[${i + 1}/${coreDiseases.length}] (${pct}%) ${d.name || d.id} (${d.plantId})`);
|
||||
|
||||
await collectClassImages(d.id, queries, TARGET_CORE, progress, classDir, existingUrls);
|
||||
}
|
||||
|
||||
// ── Phase 2: Full set ──────────────────────────────────────────────────
|
||||
|
||||
console.log("\n" + "─".repeat(60));
|
||||
console.log("PHASE 2: Full Disease Set (10 images each)");
|
||||
console.log("─".repeat(60));
|
||||
|
||||
for (let i = 0; i < fullDiseases.length; i++) {
|
||||
const d = fullDiseases[i];
|
||||
const classDir = resolve(DATASET_DIR, d.id);
|
||||
const queries = buildSearchQueries(d);
|
||||
const existingUrls = d.imageUrl ? [d.imageUrl] : [];
|
||||
|
||||
const pct = Math.round((i / fullDiseases.length) * 100);
|
||||
console.log(`\n[${i + 1}/${fullDiseases.length}] (${pct}%) ${d.id} (${d.plantId})`);
|
||||
|
||||
await collectClassImages(d.id, queries, TARGET_FULL, progress, classDir, existingUrls, true);
|
||||
}
|
||||
|
||||
// ── Phase 3: Healthy class ──────────────────────────────────────────────
|
||||
|
||||
console.log("\n" + "─".repeat(60));
|
||||
console.log("PHASE 3: Healthy Plant Images");
|
||||
console.log("─".repeat(60));
|
||||
|
||||
const healthyDir = resolve(DATASET_DIR, HEALTHY_CLASS);
|
||||
@@ -520,39 +686,50 @@ async function main() {
|
||||
const healthySeen = new Set(healthyCp.seenUrls);
|
||||
|
||||
if (healthyCp.count >= TARGET_HEALTHY) {
|
||||
console.log(`\n ✓ Already have ${healthyCp.count}/${TARGET_HEALTHY} healthy images`);
|
||||
console.log(`\n ✓ Already have ${healthyCp.count}/${TARGET_HEALTHY}`);
|
||||
} else {
|
||||
// Build a pool of healthy plant queries
|
||||
// Collect all unique plants
|
||||
const allPlants = [...new Set(dbDiseases.map((d) => d.plantId))];
|
||||
const allHealthyQueries: string[] = [];
|
||||
for (const plant of plants) {
|
||||
for (const plant of allPlants) {
|
||||
allHealthyQueries.push(...buildHealthyQueries(plant));
|
||||
}
|
||||
|
||||
const healthySources = [
|
||||
{ name: "DDG", collector: collectImagesDuckDuckGo },
|
||||
{ name: "iNat", collector: searchImagesInaturalist },
|
||||
{ name: "Commons", collector: searchImagesCommons },
|
||||
] as const;
|
||||
|
||||
const totalHealthyUrls: string[] = [];
|
||||
let healthyExhausted = false;
|
||||
let anyRemaining = false;
|
||||
|
||||
for (const query of allHealthyQueries) {
|
||||
for (const source of healthySources) {
|
||||
if (totalHealthyUrls.length >= TARGET_HEALTHY) break;
|
||||
if (healthyExhausted) break;
|
||||
console.log(`\n Source: ${source.name}`);
|
||||
|
||||
console.log(`\n Searching: "${query}"...`);
|
||||
const result = await collectImages(
|
||||
query,
|
||||
TARGET_HEALTHY - totalHealthyUrls.length,
|
||||
healthySeen,
|
||||
);
|
||||
for (const query of allHealthyQueries.slice(0, 20)) {
|
||||
if (totalHealthyUrls.length >= TARGET_HEALTHY) break;
|
||||
|
||||
totalHealthyUrls.push(...result.urls);
|
||||
|
||||
if (result.exhausted) {
|
||||
healthyExhausted = true;
|
||||
process.stdout.write(` "${query}"... `);
|
||||
const result = await source.collector(
|
||||
query,
|
||||
TARGET_HEALTHY - totalHealthyUrls.length,
|
||||
healthySeen,
|
||||
);
|
||||
totalHealthyUrls.push(...result.urls);
|
||||
if (!result.exhausted) anyRemaining = true;
|
||||
console.log(`${result.urls.length} new`);
|
||||
}
|
||||
}
|
||||
|
||||
healthyCp.seenUrls = Array.from(healthySeen);
|
||||
|
||||
if (totalHealthyUrls.length > 0) {
|
||||
console.log(`\n Found ${totalHealthyUrls.length} healthy image URLs. Downloading...`);
|
||||
healthyCp.exhausted = !anyRemaining;
|
||||
saveProgress(progress);
|
||||
|
||||
console.log(`\n Downloading ${totalHealthyUrls.length} healthy images...`);
|
||||
const { downloaded, failed } = await downloadBatch(
|
||||
totalHealthyUrls,
|
||||
healthyDir,
|
||||
@@ -562,14 +739,12 @@ async function main() {
|
||||
healthyCp.count += downloaded;
|
||||
healthyCp.downloaded += downloaded;
|
||||
healthyCp.failed += failed;
|
||||
healthyCp.exhausted = healthyExhausted;
|
||||
|
||||
const pct = Math.round((healthyCp.count / TARGET_HEALTHY) * 100);
|
||||
console.log(
|
||||
` Got ${downloaded} images (${failed} failed). Total: ${healthyCp.count}/${TARGET_HEALTHY} (${pct}%)`,
|
||||
` Got ${downloaded} images. Total: ${healthyCp.count}/${TARGET_HEALTHY} (${pct}%)`,
|
||||
);
|
||||
} else {
|
||||
healthyCp.exhausted = true;
|
||||
console.log(` ✗ No healthy images found`);
|
||||
}
|
||||
|
||||
@@ -580,76 +755,27 @@ async function main() {
|
||||
|
||||
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
||||
const mins = Math.floor(elapsed / 60);
|
||||
const secs = elapsed % 60;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
|
||||
let totalDownloaded = 0;
|
||||
let totalFailed = 0;
|
||||
let totalTarget = 0;
|
||||
|
||||
for (const [classId, cp] of Object.entries(progress.classes)) {
|
||||
for (const [, cp] of Object.entries(progress.classes)) {
|
||||
totalDownloaded += cp.downloaded || 0;
|
||||
totalFailed += cp.failed || 0;
|
||||
totalTarget += classId === HEALTHY_CLASS ? TARGET_HEALTHY : TARGET_PER_DISEASE;
|
||||
}
|
||||
|
||||
const totalSize = await getDatasetSize();
|
||||
const sizeGb = (totalSize / (1024 * 1024 * 1024)).toFixed(2);
|
||||
|
||||
console.log("\n" + "=".repeat(60));
|
||||
console.log("COMPLETE");
|
||||
console.log("=".repeat(60));
|
||||
console.log(` Time: ${mins}m ${secs}s`);
|
||||
console.log(` Time: ${hrs}h ${mins % 60}m`);
|
||||
console.log(` Downloaded: ${totalDownloaded} images`);
|
||||
console.log(` Failed: ${totalFailed} images`);
|
||||
console.log(` Target: ${totalTarget} images`);
|
||||
console.log(` Dataset size: ${sizeGb} GB`);
|
||||
console.log(` Dataset location: ${DATASET_DIR}/`);
|
||||
console.log("");
|
||||
console.log("Next steps:");
|
||||
console.log(" 1. Run the fine-tuning script to train on this dataset");
|
||||
console.log(" 2. The fine-tuning script will resize to 160×160 and augment");
|
||||
console.log(` Dataset: ${DATASET_DIR}/`);
|
||||
|
||||
await closeDb();
|
||||
console.log("=".repeat(60));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total size of the dataset directory.
|
||||
*/
|
||||
async function getDatasetSize(): Promise<number> {
|
||||
let total = 0;
|
||||
if (!existsSync(DATASET_DIR)) return 0;
|
||||
|
||||
const entries = readdirSync(DATASET_DIR, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.name.startsWith(".")) {
|
||||
const fullPath = resolve(DATASET_DIR, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
total += dirSize(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
function dirSize(dirPath: string): number {
|
||||
let total = 0;
|
||||
try {
|
||||
const entries = readdirSync(dirPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dirPath, entry.name);
|
||||
if (entry.isFile()) {
|
||||
total += statSync(fullPath).size;
|
||||
} else if (entry.isDirectory()) {
|
||||
total += dirSize(fullPath);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// skip errors
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
143
apps/web/src/app/api/flag/report/route.ts
Normal file
143
apps/web/src/app/api/flag/report/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
147
apps/web/src/app/api/flag/route.ts
Normal file
147
apps/web/src/app/api/flag/route.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
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_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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
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 ───
|
||||
|
||||
@@ -86,7 +87,7 @@ function DiseaseCard({
|
||||
</div>
|
||||
|
||||
{/* Disease image or placeholder */}
|
||||
<div className="mb-4 rounded-lg overflow-hidden border border-zinc-200 dark:border-zinc-700">
|
||||
<div className="mb-2 rounded-lg overflow-hidden border border-zinc-200 dark:border-zinc-700 relative">
|
||||
{disease.imageUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -128,6 +129,16 @@ function DiseaseCard({
|
||||
</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>
|
||||
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed mb-4">
|
||||
{disease.description}
|
||||
@@ -137,9 +148,18 @@ function DiseaseCard({
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Symptoms */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-red-600 dark:text-red-400 mb-2 flex items-center gap-1">
|
||||
<span aria-hidden="true">⚠️</span> Symptoms
|
||||
</h4>
|
||||
<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
|
||||
@@ -155,9 +175,18 @@ function DiseaseCard({
|
||||
|
||||
{/* Causes */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-orange-600 dark:text-orange-400 mb-2 flex items-center gap-1">
|
||||
<span aria-hidden="true">🔍</span> Causes
|
||||
</h4>
|
||||
<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
|
||||
@@ -173,9 +202,18 @@ function DiseaseCard({
|
||||
|
||||
{/* Treatment Steps */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-leaf-green-600 dark:text-leaf-green-400 mb-2 flex items-center gap-1">
|
||||
<span aria-hidden="true">💊</span> Treatment Steps
|
||||
</h4>
|
||||
<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">
|
||||
@@ -187,9 +225,18 @@ function DiseaseCard({
|
||||
|
||||
{/* Prevention Tips */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-leaf-green-600 dark:text-leaf-green-400 mb-2 flex items-center gap-1">
|
||||
<span aria-hidden="true">🛡️</span> Prevention Tips
|
||||
</h4>
|
||||
<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
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getPlantWithDiseases } from "@/lib/api/diseases-db";
|
||||
import { getPlantDescription } from "@/lib/display-helpers";
|
||||
import DiseaseCards from "./DiseaseCards";
|
||||
import PlantViewTracker from "@/components/PlantViewTracker";
|
||||
import FlagPlantImage from "@/components/FlagPlantImage";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ plantId: string }>;
|
||||
@@ -114,6 +115,7 @@ export default async function PlantDetailPage({ params }: Props) {
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<FlagPlantImage plantId={plantId} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
|
||||
@@ -6,6 +6,7 @@ import ConfidenceBadge, { getConfidenceColors } from "@/components/ConfidenceBad
|
||||
import SymptomChecker from "@/components/SymptomChecker";
|
||||
import TreatmentTimeline, { treatmentStepsWithUrgency } from "@/components/TreatmentTimeline";
|
||||
import LookalikeWarning from "@/components/LookalikeWarning";
|
||||
import FlagButton from "@/components/FlagButton";
|
||||
import { getLookalikeDiseases } from "@/lib/api/diseases";
|
||||
|
||||
/**
|
||||
@@ -45,15 +46,18 @@ export default function DiseaseCard({
|
||||
<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"
|
||||
${
|
||||
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`}>
|
||||
<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>
|
||||
@@ -71,13 +75,16 @@ export default function DiseaseCard({
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Rank / causal agent icon */}
|
||||
<div className={`
|
||||
<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"
|
||||
${
|
||||
isPrimary
|
||||
? `${colors.accent} text-white`
|
||||
: "bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400"
|
||||
}
|
||||
`}>
|
||||
`}
|
||||
>
|
||||
{rank}
|
||||
</div>
|
||||
|
||||
@@ -93,9 +100,7 @@ export default function DiseaseCard({
|
||||
<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>
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400 line-clamp-2">{summary}</p>
|
||||
</div>
|
||||
|
||||
{/* Expand/collapse chevron */}
|
||||
@@ -105,7 +110,11 @@ export default function DiseaseCard({
|
||||
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" />
|
||||
<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>
|
||||
@@ -133,17 +142,47 @@ export default function DiseaseCard({
|
||||
|
||||
{/* 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>
|
||||
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-2 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>
|
||||
<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">
|
||||
@@ -156,12 +195,30 @@ export default function DiseaseCard({
|
||||
|
||||
{/* Treatment timeline */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-2 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>
|
||||
<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}
|
||||
@@ -170,12 +227,30 @@ export default function DiseaseCard({
|
||||
|
||||
{/* Prevention tips */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-2 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>
|
||||
<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">
|
||||
@@ -187,9 +262,7 @@ export default function DiseaseCard({
|
||||
</div>
|
||||
|
||||
{/* Lookalike warnings */}
|
||||
{lookalikes.length > 0 && (
|
||||
<LookalikeWarning disease={disease} lookalikes={lookalikes} />
|
||||
)}
|
||||
{lookalikes.length > 0 && <LookalikeWarning disease={disease} lookalikes={lookalikes} />}
|
||||
|
||||
{/* Feedback buttons */}
|
||||
<div className="pt-2 border-t border-zinc-200 dark:border-zinc-700">
|
||||
@@ -203,9 +276,10 @@ export default function DiseaseCard({
|
||||
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"
|
||||
${
|
||||
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"}
|
||||
@@ -221,9 +295,10 @@ export default function DiseaseCard({
|
||||
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"
|
||||
${
|
||||
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"}
|
||||
|
||||
178
apps/web/src/components/FlagButton.tsx
Normal file
178
apps/web/src/components/FlagButton.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
/**
|
||||
* Content types that can be flagged for manual review.
|
||||
*/
|
||||
export type FlagContentType =
|
||||
| "plant_image"
|
||||
| "disease_image"
|
||||
| "disease_symptoms"
|
||||
| "disease_causes"
|
||||
| "disease_treatment"
|
||||
| "disease_prevention";
|
||||
|
||||
interface FlagButtonProps {
|
||||
/** Type of content being flagged */
|
||||
contentType: FlagContentType;
|
||||
/** The ID of the plant or disease */
|
||||
contentId: string;
|
||||
/** The specific field name (e.g., "image", "symptoms", "causes", "treatment", "prevention") */
|
||||
fieldName: string;
|
||||
/** Optional human-readable label for display (e.g., "This plant image") */
|
||||
label?: string;
|
||||
/** Optional notes/reason pre-filled for flagging */
|
||||
notes?: string;
|
||||
/** Small variant for inline use */
|
||||
small?: boolean;
|
||||
/** Optional class name override */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FlagButton — a small button that lets users flag content for manual review.
|
||||
*
|
||||
* When clicked, it POSTs to /api/flag which either creates or increments
|
||||
* a flag count in the flagged_content table.
|
||||
*
|
||||
* Shows visual feedback: "Flagged!" toast-like state for a few seconds.
|
||||
*/
|
||||
export default function FlagButton({
|
||||
contentType,
|
||||
contentId,
|
||||
fieldName,
|
||||
label,
|
||||
small = false,
|
||||
className = "",
|
||||
}: FlagButtonProps) {
|
||||
const [state, setState] = useState<"idle" | "loading" | "flagged" | "error">("idle");
|
||||
const [flagCount, setFlagCount] = useState(0);
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
|
||||
const handleFlag = useCallback(async () => {
|
||||
if (state === "loading" || state === "flagged") return;
|
||||
|
||||
setState("loading");
|
||||
setErrorMsg("");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/flag", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contentType, contentId, fieldName }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({ message: "Failed to flag content" }));
|
||||
throw new Error(data.message || "Failed to flag content");
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setFlagCount(data.flagCount ?? 1);
|
||||
setState("flagged");
|
||||
|
||||
// Reset back to idle after a moment so user can flag again if needed
|
||||
setTimeout(() => {
|
||||
setState("idle");
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
setErrorMsg(err instanceof Error ? err.message : "Failed to flag");
|
||||
setState("error");
|
||||
|
||||
setTimeout(() => {
|
||||
setState("idle");
|
||||
setErrorMsg("");
|
||||
}, 3000);
|
||||
}
|
||||
}, [contentType, contentId, fieldName, state]);
|
||||
|
||||
// ─── Button state styles ────────────────────────────────────────────────────
|
||||
|
||||
const baseClasses = small
|
||||
? "inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium transition-all"
|
||||
: "inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium transition-all";
|
||||
|
||||
const idleClasses =
|
||||
"text-zinc-400 dark:text-zinc-500 hover:text-amber-600 dark:hover:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-950/30 border border-transparent hover:border-amber-200 dark:hover:border-amber-800";
|
||||
|
||||
const loadingClasses = "text-zinc-300 dark:text-zinc-600 cursor-wait";
|
||||
|
||||
const flaggedClasses =
|
||||
"text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-700";
|
||||
|
||||
const errorClasses =
|
||||
"text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/40 border border-red-200 dark:border-red-800";
|
||||
|
||||
const stateClasses =
|
||||
state === "loading"
|
||||
? loadingClasses
|
||||
: state === "flagged"
|
||||
? flaggedClasses
|
||||
: state === "error"
|
||||
? errorClasses
|
||||
: idleClasses;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFlag}
|
||||
disabled={state === "loading"}
|
||||
className={`${baseClasses} ${stateClasses} ${className}`}
|
||||
title={
|
||||
state === "flagged"
|
||||
? `Flagged (${flagCount}×)`
|
||||
: state === "error"
|
||||
? errorMsg
|
||||
: `Flag for manual review${label ? ` — ${label}` : ""}`
|
||||
}
|
||||
aria-label={
|
||||
state === "flagged"
|
||||
? `Flagged for review (${flagCount} times)`
|
||||
: `Flag this ${label || "content"} for manual review`
|
||||
}
|
||||
>
|
||||
{/* Flag icon */}
|
||||
{state === "loading" ? (
|
||||
<svg
|
||||
className={`${small ? "h-3 w-3" : "h-3.5 w-3.5"} animate-spin`}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className={`${small ? "h-3 w-3" : "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>
|
||||
)}
|
||||
|
||||
{/* Text */}
|
||||
{state === "flagged" ? (
|
||||
<span>Flagged{flagCount > 1 ? ` (${flagCount}×)` : ""}</span>
|
||||
) : state === "error" ? (
|
||||
<span>Error</span>
|
||||
) : (
|
||||
<span>Flag</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
26
apps/web/src/components/FlagPlantImage.tsx
Normal file
26
apps/web/src/components/FlagPlantImage.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import FlagButton from "@/components/FlagButton";
|
||||
|
||||
/**
|
||||
* Client component wrapper to add a flag button for plant images
|
||||
* on the detail page (which is a server component).
|
||||
*/
|
||||
export default function FlagPlantImage({ plantId }: { plantId: string }) {
|
||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-1 right-1 z-10" onClick={handleClick}>
|
||||
<FlagButton
|
||||
contentType="plant_image"
|
||||
contentId={plantId}
|
||||
fieldName="image"
|
||||
label="plant image"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import FlagButton from "@/components/FlagButton";
|
||||
import { useCallback } from "react";
|
||||
|
||||
export interface PlantCardData {
|
||||
id: string;
|
||||
@@ -23,6 +25,11 @@ interface PlantCardProps {
|
||||
* Used on the homepage featured section and browse grid.
|
||||
*/
|
||||
export default function PlantCard({ plant, showDiseaseCount = true }: PlantCardProps) {
|
||||
const handleFlagClick = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/browse/${plant.id}`}
|
||||
@@ -58,6 +65,16 @@ export default function PlantCard({ plant, showDiseaseCount = true }: PlantCardP
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{/* Flag button overlay at bottom-right of image */}
|
||||
<div className="absolute bottom-1.5 right-1.5 z-10" onClick={handleFlagClick}>
|
||||
<FlagButton
|
||||
contentType="plant_image"
|
||||
contentId={plant.id}
|
||||
fieldName="image"
|
||||
label="plant image"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
|
||||
@@ -10,7 +10,14 @@ import { drizzle, type LibSQLDatabase } from "drizzle-orm/libsql";
|
||||
import { createClient } from "@libsql/client";
|
||||
import * as schema from "./schema";
|
||||
|
||||
export type { PlantRow, PlantInsert, DiseaseRow, DiseaseInsert } from "./schema";
|
||||
export type {
|
||||
PlantRow,
|
||||
PlantInsert,
|
||||
DiseaseRow,
|
||||
DiseaseInsert,
|
||||
FlaggedContentRow,
|
||||
FlaggedContentInsert,
|
||||
} from "./schema";
|
||||
|
||||
export { schema };
|
||||
|
||||
|
||||
@@ -113,6 +113,51 @@ export const plantViews = sqliteTable(
|
||||
}),
|
||||
);
|
||||
|
||||
// ─── Flagged Content Table ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Stores user-flagged content for manual review.
|
||||
* content_type: what kind of content is flagged
|
||||
* content_id: the ID of the plant or disease
|
||||
* field_name: specific field being flagged (e.g., "image", "symptoms", "causes", "treatment", "prevention")
|
||||
* flag_count: number of times this item has been flagged
|
||||
*/
|
||||
export const flaggedContent = sqliteTable(
|
||||
"flagged_content",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
contentType: text("content_type", {
|
||||
enum: [
|
||||
"plant_image",
|
||||
"disease_image",
|
||||
"disease_symptoms",
|
||||
"disease_causes",
|
||||
"disease_treatment",
|
||||
"disease_prevention",
|
||||
],
|
||||
}).notNull(),
|
||||
contentId: text("content_id").notNull(),
|
||||
fieldName: text("field_name").notNull(),
|
||||
notes: text("notes").default(""),
|
||||
flagCount: integer("flag_count").notNull().default(1),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(datetime('now'))`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(datetime('now'))`),
|
||||
},
|
||||
(table) => ({
|
||||
contentTypeIdx: index("idx_flagged_content_type").on(table.contentType),
|
||||
contentIdIdx: index("idx_flagged_content_id").on(table.contentId),
|
||||
}),
|
||||
);
|
||||
|
||||
// ─── Type helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
export type FlaggedContentRow = typeof flaggedContent.$inferSelect;
|
||||
export type FlaggedContentInsert = typeof flaggedContent.$inferInsert;
|
||||
|
||||
// ─── Relation Inference ──────────────────────────────────────────────────────
|
||||
|
||||
export const plantsRelations = {};
|
||||
|
||||
Reference in New Issue
Block a user