From c3a0fb8814a6c5d59ee5ccee27dee81e2a1a5fe4 Mon Sep 17 00:00:00 2001 From: "Tomi P. Hakala" Date: Sun, 8 Feb 2026 18:20:10 +0200 Subject: [PATCH 1/4] feat: add species lists with fetch, custom lists, and detection filtering Add a Species tab that lets users fetch expected bird species for a location and week from birda CLI (eBird data), create custom species lists from the label service, and filter detections by species list. - New database tables: species_lists, species_list_entries (migration 2) - birda CLI species command wrapper using buffered JSON - Species page with two-panel layout, fetch modal, custom list modal - Species list dropdown filter on DetectionsPage - Portal fix for CoordinateInput map picker in nested modals - Use $state.snapshot() to avoid Proxy serialization over IPC Co-Authored-By: Claude Opus 4.6 --- messages/en.json | 48 +- shared/types.ts | 56 ++ src/main/birda/species.ts | 48 ++ src/main/db/database.ts | 35 + src/main/db/detections.ts | 4 + src/main/db/species-lists.ts | 102 +++ src/main/index.ts | 9 +- src/main/ipc/handlers.ts | 2 + src/main/ipc/species.ts | 60 ++ src/preload/index.ts | 6 + src/renderer/src/App.svelte | 3 + .../src/lib/components/CoordinateInput.svelte | 12 +- .../src/lib/components/Sidebar.svelte | 3 +- src/renderer/src/lib/stores/app.svelte.ts | 3 +- src/renderer/src/lib/utils/ipc.ts | 33 + src/renderer/src/pages/DetectionsPage.svelte | 48 +- src/renderer/src/pages/SpeciesPage.svelte | 648 ++++++++++++++++++ 17 files changed, 1112 insertions(+), 8 deletions(-) create mode 100644 src/main/birda/species.ts create mode 100644 src/main/db/species-lists.ts create mode 100644 src/main/ipc/species.ts create mode 100644 src/renderer/src/pages/SpeciesPage.svelte diff --git a/messages/en.json b/messages/en.json index e614a58..f30c41c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -98,6 +98,7 @@ "sidebar_analysis": "Analysis", "sidebar_detections": "Detections", "sidebar_map": "Map", + "sidebar_species": "Species", "sidebar_settings": "Settings", "status_detections": "{count} detections", @@ -284,5 +285,50 @@ "wizard_finish": "Finish Setup", "wizard_next": "Next", "wizard_back": "Back", - "wizard_stepOf": "Step {current} of {total}" + "wizard_stepOf": "Step {current} of {total}", + + "species_title": "Species Lists", + "species_fetchNew": "Fetch New", + "species_customList": "Create Custom", + "species_empty": "No species lists yet", + "species_emptyHint": "Fetch species for a location or create a custom list.", + "species_deleteList": "Delete list", + "species_speciesCount": "{count} species", + "species_sourceFetched": "Fetched", + "species_sourceCustom": "Custom", + "species_allSpecies": "All species", + "species_selectList": "Select a species list to view its contents.", + "species_useAsFilter": "Use as Detection Filter", + "species_searchInList": "Filter species in list...", + + "species_fetch_title": "Fetch Species List", + "species_fetch_subtitle": "Get expected bird species for a location and time of year.", + "species_fetch_week": "Week", + "species_fetch_weekHint": "1–48", + "species_fetch_threshold": "Frequency Threshold", + "species_fetch_thresholdHint": "Minimum eBird reporting frequency (default: 3%)", + "species_fetch_button": "Fetch Species", + "species_fetch_fetching": "Fetching species...", + "species_fetch_error": "Failed to fetch species: {error}", + "species_fetch_resultCount": "{count} species found", + "species_fetch_listName": "List Name", + "species_fetch_listNamePlaceholder": "e.g. Helsinki, Week 24", + "species_fetch_save": "Save List", + + "species_custom_title": "Create Custom Species List", + "species_custom_name": "List Name", + "species_custom_namePlaceholder": "My species list...", + "species_custom_description": "Description", + "species_custom_descriptionPlaceholder": "Optional description...", + "species_custom_searchAdd": "Search and add species...", + "species_custom_selectedCount": "{count} selected", + "species_custom_create": "Create List", + + "species_table_commonName": "Common Name", + "species_table_scientificName": "Scientific Name", + "species_table_frequency": "Frequency", + + "species_detail_location": "Location", + "species_detail_week": "Week {week}", + "species_detail_threshold": "Threshold: {threshold}%" } diff --git a/shared/types.ts b/shared/types.ts index b0b952e..1931858 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -105,6 +105,7 @@ export interface DetectionFilter { location_id?: number | undefined; min_confidence?: number | undefined; run_id?: number | undefined; + species_list_id?: number | undefined; limit?: number | undefined; offset?: number | undefined; sort_column?: string | undefined; @@ -207,3 +208,58 @@ export interface DetectionsPayload { end_time: number; }[]; } + +// === Species Lists === + +/** A species list stored in the database */ +export interface SpeciesList { + id: number; + name: string; + description: string | null; + source: 'fetched' | 'custom'; + latitude: number | null; + longitude: number | null; + week: number | null; + threshold: number | null; + species_count: number; + created_at: string; +} + +/** An entry within a species list */ +export interface SpeciesListEntry { + id: number; + list_id: number; + scientific_name: string; + common_name: string | null; + frequency: number | null; +} + +/** Entry enriched with resolved common name from label service */ +export interface EnrichedSpeciesListEntry extends SpeciesListEntry { + resolved_common_name: string; +} + +/** Request to fetch species from birda CLI */ +export interface SpeciesFetchRequest { + latitude: number; + longitude: number; + week: number; + threshold?: number; +} + +/** A single species returned from birda CLI species command */ +export interface BirdaSpeciesResult { + scientific_name: string; + common_name: string; + frequency: number; +} + +/** Full result payload from birda CLI species command */ +export interface BirdaSpeciesResponse { + lat: number; + lon: number; + week: number; + threshold: number; + species_count: number; + species: BirdaSpeciesResult[]; +} diff --git a/src/main/birda/species.ts b/src/main/birda/species.ts new file mode 100644 index 0000000..a88b5ac --- /dev/null +++ b/src/main/birda/species.ts @@ -0,0 +1,48 @@ +import { execFile } from 'child_process'; +import { findBirda } from './runner'; +import type { BirdaSpeciesResponse } from '$shared/types'; + +interface BirdaJsonEnvelope { + spec_version: string; + timestamp: string; + event: string; + payload: Record; +} + +export async function fetchSpecies( + latitude: number, + longitude: number, + week: number, + threshold?: number, +): Promise { + const birdaPath = await findBirda(); + const args = [ + '--output-mode', + 'json', + 'species', + '--lat', + String(latitude), + '--lon', + String(longitude), + '--week', + String(week), + ]; + if (threshold !== undefined) { + args.push('--threshold', String(threshold)); + } + + return new Promise((resolve, reject) => { + execFile(birdaPath, args, { maxBuffer: 10 * 1024 * 1024, timeout: 30000 }, (err, stdout, stderr) => { + if (err) { + reject(new Error(`birda species command failed: ${stderr || err.message}`)); + return; + } + try { + const envelope = JSON.parse(stdout) as BirdaJsonEnvelope; + resolve(envelope.payload as unknown as BirdaSpeciesResponse); + } catch { + reject(new Error(`Failed to parse birda species output: ${stdout.slice(0, 200)}`)); + } + }); + }); +} diff --git a/src/main/db/database.ts b/src/main/db/database.ts index b114119..db7bd54 100644 --- a/src/main/db/database.ts +++ b/src/main/db/database.ts @@ -54,6 +54,39 @@ function runMigrations(db: Database.Database): void { db.prepare('INSERT INTO schema_migrations (version) VALUES (?)').run(1); })(); } + + // Migration 2: Species lists + if (!applied.has(2)) { + db.transaction(() => { + db.exec(` + CREATE TABLE IF NOT EXISTS species_lists ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT, + source TEXT NOT NULL CHECK (source IN ('fetched','custom')), + latitude REAL, + longitude REAL, + week INTEGER, + threshold REAL, + species_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS species_list_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + list_id INTEGER NOT NULL REFERENCES species_lists(id) ON DELETE CASCADE, + scientific_name TEXT NOT NULL, + common_name TEXT, + frequency REAL, + UNIQUE(list_id, scientific_name) + ) + `); + db.exec('CREATE INDEX IF NOT EXISTS idx_sle_list ON species_list_entries(list_id)'); + db.exec('CREATE INDEX IF NOT EXISTS idx_sle_species ON species_list_entries(scientific_name)'); + db.prepare('INSERT INTO schema_migrations (version) VALUES (?)').run(2); + })(); + } } export function clearDatabase(): { detections: number; runs: number; locations: number } { @@ -62,6 +95,8 @@ export function clearDatabase(): { detections: number; runs: number; locations: const detections = (d.prepare('SELECT COUNT(*) as c FROM detections').get() as { c: number }).c; const runs = (d.prepare('SELECT COUNT(*) as c FROM analysis_runs').get() as { c: number }).c; const locations = (d.prepare('SELECT COUNT(*) as c FROM locations').get() as { c: number }).c; + d.exec('DELETE FROM species_list_entries'); + d.exec('DELETE FROM species_lists'); d.exec('DELETE FROM detections'); d.exec('DELETE FROM analysis_runs'); d.exec('DELETE FROM locations'); diff --git a/src/main/db/detections.ts b/src/main/db/detections.ts index a0af53c..cdc5c9b 100644 --- a/src/main/db/detections.ts +++ b/src/main/db/detections.ts @@ -60,6 +60,10 @@ export function getDetections(filter: DetectionFilter): { detections: Detection[ conditions.push('run_id = ?'); params.push(filter.run_id); } + if (filter.species_list_id) { + conditions.push('scientific_name IN (SELECT scientific_name FROM species_list_entries WHERE list_id = ?)'); + params.push(filter.species_list_id); + } const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; const limit = filter.limit ?? 100; diff --git a/src/main/db/species-lists.ts b/src/main/db/species-lists.ts new file mode 100644 index 0000000..9b9e5ac --- /dev/null +++ b/src/main/db/species-lists.ts @@ -0,0 +1,102 @@ +import { getDb } from './database'; +import type { SpeciesList, SpeciesListEntry, BirdaSpeciesResult } from '$shared/types'; + +export function createSpeciesList( + name: string, + source: 'fetched' | 'custom', + species: BirdaSpeciesResult[], + opts?: { + description?: string; + latitude?: number; + longitude?: number; + week?: number; + threshold?: number; + }, +): SpeciesList { + const db = getDb(); + return db.transaction(() => { + const result = db + .prepare( + `INSERT INTO species_lists (name, description, source, latitude, longitude, week, threshold, species_count) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + name, + opts?.description ?? null, + source, + opts?.latitude ?? null, + opts?.longitude ?? null, + opts?.week ?? null, + opts?.threshold ?? null, + species.length, + ); + + const listId = result.lastInsertRowid as number; + const stmt = db.prepare( + `INSERT INTO species_list_entries (list_id, scientific_name, common_name, frequency) + VALUES (?, ?, ?, ?)`, + ); + for (const s of species) { + stmt.run(listId, s.scientific_name, s.common_name, s.frequency); + } + + return getSpeciesListById(listId)!; + })(); +} + +export function getSpeciesLists(): SpeciesList[] { + const db = getDb(); + return db.prepare('SELECT * FROM species_lists ORDER BY created_at DESC').all() as SpeciesList[]; +} + +export function getSpeciesListById(id: number): SpeciesList | undefined { + const db = getDb(); + return db.prepare('SELECT * FROM species_lists WHERE id = ?').get(id) as SpeciesList | undefined; +} + +export function getSpeciesListEntries(listId: number): SpeciesListEntry[] { + const db = getDb(); + return db + .prepare( + `SELECT * FROM species_list_entries + WHERE list_id = ? + ORDER BY frequency DESC, scientific_name ASC`, + ) + .all(listId) as SpeciesListEntry[]; +} + +export function deleteSpeciesList(id: number): void { + const db = getDb(); + db.prepare('DELETE FROM species_lists WHERE id = ?').run(id); +} + +export function getSpeciesListScientificNames(listId: number): string[] { + const db = getDb(); + const rows = db.prepare('SELECT scientific_name FROM species_list_entries WHERE list_id = ?').all(listId) as { + scientific_name: string; + }[]; + return rows.map((r) => r.scientific_name); +} + +export function createCustomSpeciesList(name: string, scientificNames: string[], description?: string): SpeciesList { + const db = getDb(); + return db.transaction(() => { + const result = db + .prepare( + `INSERT INTO species_lists (name, description, source, species_count) + VALUES (?, ?, 'custom', ?)`, + ) + .run(name, description ?? null, scientificNames.length); + + const listId = result.lastInsertRowid as number; + const stmt = db.prepare( + `INSERT INTO species_list_entries (list_id, scientific_name) + VALUES (?, ?)`, + ); + for (const sn of scientificNames) { + stmt.run(listId, sn); + } + + return getSpeciesListById(listId)!; + })(); +} diff --git a/src/main/index.ts b/src/main/index.ts index 1d2192c..285762a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -89,13 +89,18 @@ function createMenu() { click: () => mainWindow?.webContents.send('menu:switch-tab', 'map'), }, { - label: 'Models', + label: 'Species', accelerator: 'CmdOrCtrl+3', + click: () => mainWindow?.webContents.send('menu:switch-tab', 'species'), + }, + { + label: 'Models', + accelerator: 'CmdOrCtrl+4', click: () => mainWindow?.webContents.send('menu:switch-tab', 'models'), }, { label: 'Settings', - accelerator: 'CmdOrCtrl+4', + accelerator: 'CmdOrCtrl+5', click: () => mainWindow?.webContents.send('menu:switch-tab', 'settings'), }, { type: 'separator' }, diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 85083b0..945ed0a 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -5,6 +5,7 @@ import { registerLabelHandlers } from './labels'; import { registerLicenseHandlers } from './licenses'; import { registerModelHandlers } from './models'; import { registerSettingsHandlers } from './settings'; +import { registerSpeciesHandlers } from './species'; export async function registerHandlers(): Promise { registerAnalysisHandlers(); @@ -13,5 +14,6 @@ export async function registerHandlers(): Promise { registerLabelHandlers(); registerLicenseHandlers(); registerModelHandlers(); + registerSpeciesHandlers(); await registerSettingsHandlers(); } diff --git a/src/main/ipc/species.ts b/src/main/ipc/species.ts new file mode 100644 index 0000000..44ddcc9 --- /dev/null +++ b/src/main/ipc/species.ts @@ -0,0 +1,60 @@ +import { ipcMain } from 'electron'; +import { fetchSpecies } from '../birda/species'; +import { + createSpeciesList, + getSpeciesLists, + getSpeciesListEntries, + deleteSpeciesList, + createCustomSpeciesList, +} from '../db/species-lists'; +import { resolveAll } from '../labels/label-service'; +import type { + SpeciesFetchRequest, + SpeciesListEntry, + EnrichedSpeciesListEntry, + BirdaSpeciesResponse, +} from '$shared/types'; + +function enrichEntries(entries: SpeciesListEntry[]): EnrichedSpeciesListEntry[] { + const scientificNames = entries.map((e) => e.scientific_name); + const nameMap = resolveAll(scientificNames); + return entries.map((e) => ({ + ...e, + resolved_common_name: nameMap.get(e.scientific_name) ?? e.common_name ?? e.scientific_name, + })); +} + +export function registerSpeciesHandlers(): void { + ipcMain.handle('species:fetch', async (_event, request: SpeciesFetchRequest) => { + return fetchSpecies(request.latitude, request.longitude, request.week, request.threshold); + }); + + ipcMain.handle('species:save-list', (_event, name: string, response: BirdaSpeciesResponse) => { + return createSpeciesList(name, 'fetched', response.species, { + latitude: response.lat, + longitude: response.lon, + week: response.week, + threshold: response.threshold, + }); + }); + + ipcMain.handle( + 'species:create-custom-list', + (_event, name: string, scientificNames: string[], description?: string) => { + return createCustomSpeciesList(name, scientificNames, description); + }, + ); + + ipcMain.handle('species:get-lists', () => { + return getSpeciesLists(); + }); + + ipcMain.handle('species:get-entries', (_event, listId: number) => { + const entries = getSpeciesListEntries(listId); + return enrichEntries(entries); + }); + + ipcMain.handle('species:delete-list', (_event, id: number) => { + deleteSpeciesList(id); + }); +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 1db8c10..fd14d90 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -31,6 +31,12 @@ const ALLOWED_INVOKE_CHANNELS = new Set([ 'labels:resolve-all', 'labels:search-by-common-name', 'labels:available-languages', + 'species:fetch', + 'species:save-list', + 'species:create-custom-list', + 'species:get-lists', + 'species:get-entries', + 'species:delete-list', 'fs:open-file-dialog', 'fs:open-executable-dialog', 'fs:open-folder-dialog', diff --git a/src/renderer/src/App.svelte b/src/renderer/src/App.svelte index 037eae7..417db17 100644 --- a/src/renderer/src/App.svelte +++ b/src/renderer/src/App.svelte @@ -8,6 +8,7 @@ import AnalysisPage from './pages/AnalysisPage.svelte'; import DetectionsPage from './pages/DetectionsPage.svelte'; import MapPage from './pages/MapPage.svelte'; + import SpeciesPage from './pages/SpeciesPage.svelte'; import SettingsPage from './pages/SettingsPage.svelte'; import { appState } from '$lib/stores/app.svelte'; import { @@ -205,6 +206,8 @@ {:else if appState.activeTab === 'map'} + {:else if appState.activeTab === 'species'} + {:else if appState.activeTab === 'settings'} {/if} diff --git a/src/renderer/src/lib/components/CoordinateInput.svelte b/src/renderer/src/lib/components/CoordinateInput.svelte index 0894ca1..1657120 100644 --- a/src/renderer/src/lib/components/CoordinateInput.svelte +++ b/src/renderer/src/lib/components/CoordinateInput.svelte @@ -22,6 +22,16 @@ latitude = Math.round(e.lngLat.lat * 10000) / 10000; longitude = Math.round(e.lngLat.lng * 10000) / 10000; } + + // Portal action: moves element to document.body so nested dialogs escape parent stacking contexts + function portal(node: HTMLElement) { + document.body.appendChild(node); + return { + destroy() { + node.remove(); + }, + }; + }
@@ -71,7 +81,7 @@
{#if showMapModal} - +