Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 52 additions & 1 deletion messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@
"sidebar_analysis": "Analysis",
"sidebar_detections": "Detections",
"sidebar_map": "Map",
"sidebar_species": "Species",
"sidebar_settings": "Settings",

"status_detections": "{count} detections",
Expand Down Expand Up @@ -284,5 +285,55 @@
"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}%",

"species_fetch_weekError": "Week must be between 1 and 48",
"species_fetch_coordsError": "Please set coordinates",
"species_fetch_moreSpecies": "...and {count} more",
"species_loading": "Loading..."
}
56 changes: 56 additions & 0 deletions shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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[];
}
54 changes: 54 additions & 0 deletions src/main/birda/species.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
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<string, unknown>;
}

export async function fetchSpecies(
latitude: number,
longitude: number,
week: number,
threshold?: number,
): Promise<BirdaSpeciesResponse> {
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;
const payload = envelope.payload;
if (!payload || typeof payload !== 'object' || !('species' in payload)) {
reject(new Error('Unexpected payload format from birda species command'));
return;
}
resolve(payload as unknown as BirdaSpeciesResponse);
} catch (e) {
const detail = e instanceof Error ? e.message : String(e);
reject(new Error(`Failed to parse birda species output: ${detail}. Output: ${stdout.slice(0, 200)}`));
}
Comment on lines +40 to +51
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The type assertion as unknown as BirdaSpeciesResponse is unsafe and can lead to runtime errors if the birda CLI payload format changes. A simple property check would make this more robust. Additionally, the catch block for JSON parsing should capture the error object for better diagnostics.

      try {
        const envelope = JSON.parse(stdout) as BirdaJsonEnvelope;
        const payload = envelope.payload;
        if (payload && typeof payload === 'object' && 'species' in payload) {
          resolve(payload as BirdaSpeciesResponse);
        } else {
          reject(new Error('Unexpected payload format from birda species command.'));
        }
      } catch (e) {
        const errorDetails = e instanceof Error ? e.message : String(e);
        reject(new Error(`Failed to parse birda species output: ${errorDetails}. Output: ${stdout.slice(0, 200)}`));
      }

});
});
}
35 changes: 35 additions & 0 deletions src/main/db/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } {
Expand All @@ -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');
Expand Down
4 changes: 4 additions & 0 deletions src/main/db/detections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
98 changes: 98 additions & 0 deletions src/main/db/species-lists.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
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);
}

const created = getSpeciesListById(listId);
if (!created) throw new Error(`Failed to retrieve species list ${listId} after creation`);
return created;
})();
}

export function getSpeciesLists(): SpeciesList[] {
const db = getDb();
return db.prepare('SELECT * FROM species_lists ORDER BY created_at DESC').all() as SpeciesList[];
}

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 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);
}

const created = getSpeciesListById(listId);
if (!created) throw new Error(`Failed to retrieve custom species list ${listId} after creation`);
return created;
})();
}
9 changes: 7 additions & 2 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,18 @@
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' },
Expand Down Expand Up @@ -210,7 +215,7 @@
// Read saved language preference
let language = 'en';
try {
const settingsRaw = await fs.promises.readFile(

Check warning on line 218 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / Lint, Type Check & Build

Found readFile from package "fs" with non literal argument at index 0
path.join(app.getPath('userData'), 'birda-gui-settings.json'),
'utf-8',
);
Expand Down
2 changes: 2 additions & 0 deletions src/main/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
registerAnalysisHandlers();
Expand All @@ -13,5 +14,6 @@ export async function registerHandlers(): Promise<void> {
registerLabelHandlers();
registerLicenseHandlers();
registerModelHandlers();
registerSpeciesHandlers();
await registerSettingsHandlers();
}
Loading
Loading