diff --git a/.gitignore b/.gitignore index 43b5057..fe0adc8 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ # dotenv files .env .codex/ +data/ # User-specific files *.rsuser diff --git a/plant-manager-importer/Program.cs b/plant-manager-importer/Program.cs new file mode 100644 index 0000000..7c65c87 --- /dev/null +++ b/plant-manager-importer/Program.cs @@ -0,0 +1,586 @@ +using System.Globalization; +using System.IO.Compression; +using System.Xml.Linq; +using Microsoft.Data.Sqlite; + +if (args.Length == 0) +{ + PrintUsage(); + return 1; +} + +var command = args[0].ToLowerInvariant(); +var options = ParseOptions(args.Skip(1).ToArray()); + +try +{ + return command switch + { + "build-db" => await BuildDbAsync(options), + "verify" => await VerifyAsync(options), + _ => UsageError($"Unknown command '{args[0]}'.") + }; +} +catch (Exception ex) +{ + Console.Error.WriteLine(ex.Message); + return 1; +} + +static async Task BuildDbAsync(Dictionary options) +{ + if (!options.TryGetValue("source", out var source)) + { + return UsageError("Missing required option --source."); + } + + if (!options.TryGetValue("output", out var output)) + { + return UsageError("Missing required option --output."); + } + + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(output)) ?? "."); + + if (File.Exists(output)) + { + File.Delete(output); + } + + await using var connection = new SqliteConnection($"Data Source={output}"); + await connection.OpenAsync(); + await CreateSchemaAsync(connection); + + await using var transaction = connection.BeginTransaction(); + var recordCount = 0; + foreach (var record in ReadRecords(source)) + { + await UpsertRecordAsync(connection, transaction, record); + recordCount++; + } + + await transaction.CommitAsync(); + await RebuildSearchAsync(connection); + + Console.WriteLine($"Imported {recordCount.ToString(CultureInfo.InvariantCulture)} records into {output}"); + return 0; +} + +static async Task VerifyAsync(Dictionary options) +{ + if (!options.TryGetValue("database", out var database)) + { + return UsageError("Missing required option --database."); + } + + var checks = new[] + { + new VerifyCheck("ficus"), + new VerifyCheck("monstera"), + new VerifyCheck("alocasia"), + new VerifyCheck("croton") + }; + + await using var connection = new SqliteConnection($"Data Source={database}"); + await connection.OpenAsync(); + + var failed = false; + foreach (var check in checks) + { + var result = await SearchFirstCanonicalNameAsync(connection, check.Query); + var passed = result is not null; + Console.WriteLine($"{(passed ? "PASS" : "FAIL")} {check.Query} -> {result ?? "(no result)"}"); + failed |= !passed; + } + + var recordCount = await CountRecordsAsync(connection); + var gbifCount = await CountGbifRecordsAsync(connection); + Console.WriteLine($"Records: {recordCount.ToString(CultureInfo.InvariantCulture)}"); + Console.WriteLine($"GBIF records: {gbifCount.ToString(CultureInfo.InvariantCulture)}"); + + return failed || recordCount == 0 || gbifCount == 0 ? 1 : 0; +} + +static IEnumerable ReadRecords(string source) +{ + if (File.Exists(source) && Path.GetExtension(source).Equals(".zip", StringComparison.OrdinalIgnoreCase)) + { + return ReadGbifDwcaZipRecords(source); + } + + if (Directory.Exists(source) && File.Exists(Path.Combine(source, "meta.xml"))) + { + return ReadGbifDwcaDirectoryRecords(source); + } + + throw new FileNotFoundException($"Could not find a GBIF backbone zip or extracted GBIF archive at '{source}'."); +} + +static IEnumerable ReadGbifDwcaZipRecords(string sourceFile) +{ + using var archive = ZipFile.OpenRead(sourceFile); + var layout = ReadDwcaLayout(OpenArchiveEntry(archive, "meta.xml")); + return ReadGbifDwcaRecords(layout, fileName => OpenArchiveEntry(archive, fileName)); +} + +static IEnumerable ReadGbifDwcaDirectoryRecords(string sourceDirectory) +{ + var layout = ReadDwcaLayout(File.OpenRead(Path.Combine(sourceDirectory, "meta.xml"))); + return ReadGbifDwcaRecords(layout, fileName => File.OpenRead(Path.Combine(sourceDirectory, fileName))); +} + +static IEnumerable ReadGbifDwcaRecords( + DwcaLayout layout, + Func openFile) +{ + var records = new Dictionary(StringComparer.OrdinalIgnoreCase); + using (var taxonStream = openFile(layout.Taxon.FileName)) + using (var reader = new StreamReader(taxonStream)) + { + var lineNumber = 0; + while (!reader.EndOfStream) + { + lineNumber++; + var line = reader.ReadLine(); + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + var fields = line.Split('\t'); + var kingdom = layout.Taxon.Value(fields, "kingdom"); + var rank = layout.Taxon.Value(fields, "taxonRank"); + var status = layout.Taxon.Value(fields, "taxonomicStatus"); + if (!EqualsIgnoreCase(kingdom, "Plantae") || + !EqualsIgnoreCase(rank, "species") || + !EqualsIgnoreCase(status, "accepted")) + { + continue; + } + + var externalId = layout.Taxon.Value(fields, "taxonID") ?? layout.Taxon.Value(fields, "id"); + var scientificName = layout.Taxon.Value(fields, "scientificName"); + if (string.IsNullOrWhiteSpace(externalId) || string.IsNullOrWhiteSpace(scientificName)) + { + Console.Error.WriteLine($"Skipping Taxon.tsv line {lineNumber.ToString(CultureInfo.InvariantCulture)} with missing taxonID or scientificName."); + continue; + } + + records[externalId] = new GbifRecordBuilder( + externalId, + scientificName, + EmptyToNull(layout.Taxon.Value(fields, "canonicalName")), + EmptyToNull(layout.Taxon.Value(fields, "scientificNameAuthorship")), + EmptyToNull(layout.Taxon.Value(fields, "family")), + EmptyToNull(layout.Taxon.Value(fields, "genus")), + EmptyToNull(layout.Taxon.Value(fields, "specificEpithet")), + EmptyToNull(rank), + EmptyToNull(status)); + } + } + + if (layout.VernacularName is not null) + { + using var vernacularStream = openFile(layout.VernacularName.FileName); + using var reader = new StreamReader(vernacularStream); + while (!reader.EndOfStream) + { + var line = reader.ReadLine(); + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + var fields = line.Split('\t'); + var taxonId = layout.VernacularName.Value(fields, "coreid"); + if (string.IsNullOrWhiteSpace(taxonId) || !records.TryGetValue(taxonId, out var record)) + { + continue; + } + + var language = layout.VernacularName.Value(fields, "language"); + if (!IsEnglishOrUnknown(language)) + { + continue; + } + + var name = layout.VernacularName.Value(fields, "vernacularName"); + if (!string.IsNullOrWhiteSpace(name)) + { + record.AddAlias(name); + } + } + } + + return records.Values + .OrderBy(record => record.CanonicalName ?? record.ScientificName, StringComparer.OrdinalIgnoreCase) + .Select(record => record.ToImportRecord()); +} + +static Stream OpenArchiveEntry(ZipArchive archive, string fileName) +{ + var entry = archive.GetEntry(fileName) + ?? archive.Entries.FirstOrDefault(item => item.FullName.Equals(fileName, StringComparison.OrdinalIgnoreCase)) + ?? throw new FileNotFoundException($"Could not find '{fileName}' in GBIF archive."); + return entry.Open(); +} + +static DwcaLayout ReadDwcaLayout(Stream metaXmlStream) +{ + using var stream = metaXmlStream; + var document = XDocument.Load(stream); + var coreElement = document.Descendants() + .FirstOrDefault(element => element.Name.LocalName == "core" && HasRowType(element, "Taxon")) + ?? throw new InvalidDataException("GBIF archive meta.xml does not contain a Taxon core."); + + var extensionElement = document.Descendants() + .FirstOrDefault(element => element.Name.LocalName == "extension" && HasRowType(element, "VernacularName")); + + return new DwcaLayout( + ReadDwcaFileLayout(coreElement), + extensionElement is null ? null : ReadDwcaFileLayout(extensionElement)); +} + +static DwcaFileLayout ReadDwcaFileLayout(XElement element) +{ + var fileName = element.Descendants() + .FirstOrDefault(child => child.Name.LocalName == "location") + ?.Value + ?? throw new InvalidDataException("Darwin Core Archive file location missing from meta.xml."); + + var fields = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var child in element.Elements()) + { + var indexText = child.Attribute("index")?.Value; + if (indexText is null || !int.TryParse(indexText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var index)) + { + continue; + } + + if (child.Name.LocalName == "id") + { + fields["id"] = index; + fields["taxonID"] = index; + } + else if (child.Name.LocalName == "coreid") + { + fields["coreid"] = index; + } + + var term = child.Attribute("term")?.Value; + if (term is not null) + { + fields[TermName(term)] = index; + } + } + + return new DwcaFileLayout(fileName, fields); +} + +static bool HasRowType(XElement element, string rowTypeName) => + element.Attribute("rowType")?.Value.EndsWith(rowTypeName, StringComparison.OrdinalIgnoreCase) == true; + +static string TermName(string term) +{ + var slashIndex = term.LastIndexOf('/'); + var hashIndex = term.LastIndexOf('#'); + var index = Math.Max(slashIndex, hashIndex); + return index >= 0 ? term[(index + 1)..] : term; +} + +static bool EqualsIgnoreCase(string? left, string right) => + string.Equals(left, right, StringComparison.OrdinalIgnoreCase); + +static bool IsEnglishOrUnknown(string? language) => + string.IsNullOrWhiteSpace(language) || + EqualsIgnoreCase(language, "en") || + EqualsIgnoreCase(language, "eng") || + EqualsIgnoreCase(language, "english"); + +static async Task CreateSchemaAsync(SqliteConnection connection) +{ + await ExecuteAsync(connection, """ + CREATE TABLE IF NOT EXISTS PlantInfoRecords ( + Id INTEGER NOT NULL CONSTRAINT PK_PlantInfoRecords PRIMARY KEY AUTOINCREMENT, + Source TEXT NOT NULL, + ExternalId TEXT NOT NULL, + ScientificName TEXT NOT NULL, + CanonicalName TEXT NULL, + Authorship TEXT NULL, + CommonName TEXT NULL, + AliasesText TEXT NULL, + Family TEXT NULL, + Genus TEXT NULL, + Species TEXT NULL, + Rank TEXT NULL, + Status TEXT NULL + ); + """); + + await ExecuteAsync(connection, "CREATE UNIQUE INDEX IF NOT EXISTS IX_PlantInfoRecords_Source_ExternalId ON PlantInfoRecords (Source, ExternalId);"); + await ExecuteAsync(connection, "CREATE INDEX IF NOT EXISTS IX_PlantInfoRecords_CanonicalName ON PlantInfoRecords (CanonicalName);"); + await ExecuteAsync(connection, "CREATE INDEX IF NOT EXISTS IX_PlantInfoRecords_CommonName ON PlantInfoRecords (CommonName);"); + await ExecuteAsync(connection, "CREATE INDEX IF NOT EXISTS IX_PlantInfoRecords_Family ON PlantInfoRecords (Family);"); + await ExecuteAsync(connection, "CREATE INDEX IF NOT EXISTS IX_PlantInfoRecords_Genus ON PlantInfoRecords (Genus);"); + + await ExecuteAsync(connection, """ + CREATE VIRTUAL TABLE IF NOT EXISTS PlantInfoSearch USING fts5( + CommonName, + ScientificName, + CanonicalName, + AliasesText, + Family, + Genus, + Species, + content='PlantInfoRecords', + content_rowid='Id' + ); + """); + + await ExecuteAsync(connection, """ + CREATE TRIGGER IF NOT EXISTS PlantInfoRecords_ai AFTER INSERT ON PlantInfoRecords BEGIN + INSERT INTO PlantInfoSearch(rowid, CommonName, ScientificName, CanonicalName, AliasesText, Family, Genus, Species) + VALUES (new.Id, new.CommonName, new.ScientificName, new.CanonicalName, new.AliasesText, new.Family, new.Genus, new.Species); + END; + """); + + await ExecuteAsync(connection, """ + CREATE TRIGGER IF NOT EXISTS PlantInfoRecords_ad AFTER DELETE ON PlantInfoRecords BEGIN + INSERT INTO PlantInfoSearch(PlantInfoSearch, rowid, CommonName, ScientificName, CanonicalName, AliasesText, Family, Genus, Species) + VALUES ('delete', old.Id, old.CommonName, old.ScientificName, old.CanonicalName, old.AliasesText, old.Family, old.Genus, old.Species); + END; + """); + + await ExecuteAsync(connection, """ + CREATE TRIGGER IF NOT EXISTS PlantInfoRecords_au AFTER UPDATE ON PlantInfoRecords BEGIN + INSERT INTO PlantInfoSearch(PlantInfoSearch, rowid, CommonName, ScientificName, CanonicalName, AliasesText, Family, Genus, Species) + VALUES ('delete', old.Id, old.CommonName, old.ScientificName, old.CanonicalName, old.AliasesText, old.Family, old.Genus, old.Species); + INSERT INTO PlantInfoSearch(rowid, CommonName, ScientificName, CanonicalName, AliasesText, Family, Genus, Species) + VALUES (new.Id, new.CommonName, new.ScientificName, new.CanonicalName, new.AliasesText, new.Family, new.Genus, new.Species); + END; + """); +} + +static async Task UpsertRecordAsync(SqliteConnection connection, SqliteTransaction transaction, PlantInfoImportRecord record) +{ + await using var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = """ + INSERT INTO PlantInfoRecords ( + Source, + ExternalId, + ScientificName, + CanonicalName, + Authorship, + CommonName, + AliasesText, + Family, + Genus, + Species, + Rank, + Status + ) + VALUES ( + $source, + $externalId, + $scientificName, + $canonicalName, + $authorship, + $commonName, + $aliasesText, + $family, + $genus, + $species, + $rank, + $status + ) + ON CONFLICT(Source, ExternalId) DO UPDATE SET + ScientificName = excluded.ScientificName, + CanonicalName = excluded.CanonicalName, + Authorship = excluded.Authorship, + CommonName = excluded.CommonName, + AliasesText = excluded.AliasesText, + Family = excluded.Family, + Genus = excluded.Genus, + Species = excluded.Species, + Rank = excluded.Rank, + Status = excluded.Status; + """; + + command.Parameters.AddWithValue("$source", record.Source); + command.Parameters.AddWithValue("$externalId", record.ExternalId); + command.Parameters.AddWithValue("$scientificName", record.ScientificName); + AddNullableParameter(command, "$canonicalName", record.CanonicalName); + AddNullableParameter(command, "$authorship", record.Authorship); + AddNullableParameter(command, "$commonName", record.CommonName); + AddNullableParameter(command, "$aliasesText", record.AliasesText); + AddNullableParameter(command, "$family", record.Family); + AddNullableParameter(command, "$genus", record.Genus); + AddNullableParameter(command, "$species", record.Species); + AddNullableParameter(command, "$rank", record.Rank); + AddNullableParameter(command, "$status", record.Status); + + await command.ExecuteNonQueryAsync(); +} + +static async Task RebuildSearchAsync(SqliteConnection connection) => + await ExecuteAsync(connection, "INSERT INTO PlantInfoSearch(PlantInfoSearch) VALUES ('rebuild');"); + +static async Task SearchFirstCanonicalNameAsync(SqliteConnection connection, string query) +{ + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT r.CanonicalName + FROM PlantInfoSearch search + JOIN PlantInfoRecords r ON r.Id = search.rowid + WHERE PlantInfoSearch MATCH $query + ORDER BY bm25(PlantInfoSearch) + LIMIT 1; + """; + command.Parameters.AddWithValue("$query", ToFtsQuery(query)); + + var result = await command.ExecuteScalarAsync(); + return result is null or DBNull ? null : (string)result; +} + +static async Task CountRecordsAsync(SqliteConnection connection) +{ + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT COUNT(*) FROM PlantInfoRecords;"; + var result = await command.ExecuteScalarAsync(); + return Convert.ToInt64(result, CultureInfo.InvariantCulture); +} + +static async Task CountGbifRecordsAsync(SqliteConnection connection) +{ + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT COUNT(*) FROM PlantInfoRecords WHERE Source = 'gbif';"; + var result = await command.ExecuteScalarAsync(); + return Convert.ToInt64(result, CultureInfo.InvariantCulture); +} + +static async Task ExecuteAsync(SqliteConnection connection, string sql) +{ + await using var command = connection.CreateCommand(); + command.CommandText = sql; + await command.ExecuteNonQueryAsync(); +} + +static void AddNullableParameter(SqliteCommand command, string name, string? value) => + command.Parameters.AddWithValue(name, string.IsNullOrWhiteSpace(value) ? DBNull.Value : value); + +static string? EmptyToNull(string? value) => string.IsNullOrWhiteSpace(value) ? null : value; + +static string ToFtsQuery(string query) => + string.Join( + " ", + query.Trim() + .ToLowerInvariant() + .Replace('-', ' ') + .Replace('\'', ' ') + .Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Where(term => term.Length > 1) + .Select(term => $"{term}*")); + +static Dictionary ParseOptions(string[] args) +{ + var options = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var i = 0; i < args.Length; i++) + { + var option = args[i]; + if (!option.StartsWith("--", StringComparison.Ordinal)) + { + throw new ArgumentException($"Expected option name, got '{option}'."); + } + + if (i + 1 >= args.Length || args[i + 1].StartsWith("--", StringComparison.Ordinal)) + { + throw new ArgumentException($"Missing value for option '{option}'."); + } + + options[option[2..]] = args[++i]; + } + + return options; +} + +static int UsageError(string message) +{ + Console.Error.WriteLine(message); + PrintUsage(); + return 1; +} + +static void PrintUsage() +{ + Console.WriteLine(""" + Usage: + dotnet run --project plant-manager-importer -- build-db --source --output + dotnet run --project plant-manager-importer -- verify --database + """); +} + +internal sealed record PlantInfoImportRecord( + string Source, + string ExternalId, + string ScientificName, + string? CanonicalName, + string? Authorship, + string? CommonName, + string? AliasesText, + string? Family, + string? Genus, + string? Species, + string? Rank, + string? Status); + +internal sealed record VerifyCheck(string Query); + +internal sealed record DwcaLayout(DwcaFileLayout Taxon, DwcaFileLayout? VernacularName); + +internal sealed record DwcaFileLayout(string FileName, IReadOnlyDictionary Fields) +{ + public string? Value(string[] values, string fieldName) => + Fields.TryGetValue(fieldName, out var index) && index < values.Length + ? values[index] + : null; +} + +internal sealed class GbifRecordBuilder( + string externalId, + string scientificName, + string? canonicalName, + string? authorship, + string? family, + string? genus, + string? species, + string? rank, + string? status) +{ + private readonly SortedSet aliases = new(StringComparer.OrdinalIgnoreCase); + + public string ScientificName { get; } = scientificName; + public string? CanonicalName { get; } = canonicalName; + + public void AddAlias(string alias) + { + if (!string.IsNullOrWhiteSpace(alias)) + { + aliases.Add(alias.Trim()); + } + } + + public PlantInfoImportRecord ToImportRecord() => + new( + "gbif", + externalId, + ScientificName, + CanonicalName, + authorship, + aliases.FirstOrDefault(), + aliases.Count == 0 ? null : string.Join('\n', aliases), + family, + genus, + species, + rank, + status); +} diff --git a/plant-manager-importer/plant-manager-importer.csproj b/plant-manager-importer/plant-manager-importer.csproj new file mode 100644 index 0000000..32e4dfb --- /dev/null +++ b/plant-manager-importer/plant-manager-importer.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + enable + enable + plant_manager_importer + + + + + + + diff --git a/plant-manager-web/src/App.tsx b/plant-manager-web/src/App.tsx index 36aafe3..b14c983 100644 --- a/plant-manager-web/src/App.tsx +++ b/plant-manager-web/src/App.tsx @@ -1,50 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; -import { - createActionResource, - createCareActivity, - createCareAction, - createPlant, - createPlantGroup, - createPlantLocation, - createRecipe, - assignPlantFlag, - completeCareTasksBulk, - createPlantFlag, - createPlantTaxon, - deleteActionResource, - deleteCareActivity, - deleteCareAction, - deletePlant, - deletePlantGroup, - deletePlantFlag, - deletePlantLocation, - deletePlantTaxon, - deleteRecipe, - downloadSpreadsheetExport, - getActionResources, - getCareActivities, - getCareActions, - getCareTasks, - getPlants, - getPlantFlags, - getPlantGroups, - getPlantLocations, - getPlantTaxa, - getRecipes, - removePlantFlagAssignment, - removePlantCareSchedulesBulk, - resolvePlantFlag, - savePlantCareSchedulesBulk, - updateActionResource, - updateCareActivity, - updateCareAction, - updatePlant, - updatePlantGroup, - updatePlantFlag, - updatePlantLocation, - updatePlantTaxon, - updateRecipe, -} from './api'; +import { useState } from 'react'; import { ActionsView } from './components/ActionsView'; import { ActivitiesView } from './components/ActivitiesView'; import { FlagsView } from './components/FlagsView'; @@ -57,1126 +11,188 @@ import { RecipesView } from './components/RecipesView'; import { ResourcesView } from './components/ResourcesView'; import { SchedulesView } from './components/SchedulesView'; import { TaxaView } from './components/TaxaView'; -import type { ActionResource, CareActivity, CareAction, CareTask, Plant, PlantFlag, PlantFlagDefinition, PlantGroup, PlantLocation, PlantTaxon, Recipe } from './domain'; -import { - emptyActionForm, - emptyActivityForm, - emptyBulkScheduleForm, - emptyFlagDefinitionForm, - emptyLocationForm, - emptyPlantForm, - emptyPlantGroupForm, - emptyPlantFlagForm, - emptyRecipeForm, - emptyResourceForm, - emptyTaxonForm, - toActionForm, - toActionPayload, - toActivityForm, - toActivityPayload, - toBulkSchedulePayload, - toFlagDefinitionForm, - toFlagDefinitionPayload, - toLocationForm, - toLocationPayload, - toPlantGroupForm, - toPlantGroupPayload, - toPlantFlagPayload, - toPlantForm, - toPlantPayload, - toRecipeForm, - toRecipePayload, - toResourceForm, - toResourcePayload, - toTaxonForm, - toTaxonPayload, - type ActionFormState, - type ActivityFormState, - type BulkScheduleFormState, - type FlagDefinitionFormState, - type LocationFormState, - type PlantGroupFormState, - type PlantFlagFormState, - type PlantFormState, - type RecipeFormState, - type ResourceFormState, - type TaxonFormState, - type View, -} from './form-state'; +import { type View } from './form-state'; +import { useAppActions } from './use-app-actions'; +import { useAppEditors } from './use-app-editors'; +import { useDashboardData } from './use-dashboard-data'; export function App() { const [view, setView] = useState('home'); - const [plants, setPlants] = useState([]); - const [plantTaxa, setPlantTaxa] = useState([]); - const [plantLocations, setPlantLocations] = useState([]); - const [plantGroups, setPlantGroups] = useState([]); - const [careActions, setCareActions] = useState([]); - const [actionResources, setActionResources] = useState([]); - const [recipes, setRecipes] = useState([]); - const [careActivities, setCareActivities] = useState([]); - const [plantFlagDefinitions, setPlantFlagDefinitions] = useState([]); - const [careTasks, setCareTasks] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [isSaving, setIsSaving] = useState(false); - const [isExporting, setIsExporting] = useState(false); - const [editingPlantId, setEditingPlantId] = useState(null); - const [editingTaxonId, setEditingTaxonId] = useState(null); - const [editingLocationId, setEditingLocationId] = useState(null); - const [editingPlantGroupId, setEditingPlantGroupId] = useState(null); - const [editingActionId, setEditingActionId] = useState(null); - const [editingResourceId, setEditingResourceId] = useState(null); - const [editingRecipeId, setEditingRecipeId] = useState(null); - const [editingActivityId, setEditingActivityId] = useState(null); - const [editingFlagDefinitionId, setEditingFlagDefinitionId] = useState(null); - const [isPlantEditorOpen, setIsPlantEditorOpen] = useState(false); - const [isTaxonEditorOpen, setIsTaxonEditorOpen] = useState(false); - const [isLocationEditorOpen, setIsLocationEditorOpen] = useState(false); - const [isPlantGroupEditorOpen, setIsPlantGroupEditorOpen] = useState(false); - const [isActionEditorOpen, setIsActionEditorOpen] = useState(false); - const [isResourceEditorOpen, setIsResourceEditorOpen] = useState(false); - const [isRecipeEditorOpen, setIsRecipeEditorOpen] = useState(false); - const [isActivityEditorOpen, setIsActivityEditorOpen] = useState(false); - const [isFlagDefinitionEditorOpen, setIsFlagDefinitionEditorOpen] = useState(false); - const [selectedPlantId, setSelectedPlantId] = useState(null); - const [selectedTaxonId, setSelectedTaxonId] = useState(null); - const [selectedLocationId, setSelectedLocationId] = useState(null); - const [selectedActionId, setSelectedActionId] = useState(null); - const [selectedResourceId, setSelectedResourceId] = useState(null); - const [selectedRecipeId, setSelectedRecipeId] = useState(null); - const [selectedActivityId, setSelectedActivityId] = useState(null); - const [selectedFlagDefinitionId, setSelectedFlagDefinitionId] = useState(null); - const [form, setForm] = useState(emptyPlantForm); - const [taxonForm, setTaxonForm] = useState(emptyTaxonForm); - const [locationForm, setLocationForm] = useState(emptyLocationForm); - const [plantGroupForm, setPlantGroupForm] = useState(emptyPlantGroupForm); - const [actionForm, setActionForm] = useState(emptyActionForm); - const [resourceForm, setResourceForm] = useState(emptyResourceForm); - const [recipeForm, setRecipeForm] = useState(emptyRecipeForm); - const [activityForm, setActivityForm] = useState(emptyActivityForm); - const [flagDefinitionForm, setFlagDefinitionForm] = useState(emptyFlagDefinitionForm); - const [plantFlagForm, setPlantFlagForm] = useState(emptyPlantFlagForm); - const [bulkScheduleForm, setBulkScheduleForm] = useState(emptyBulkScheduleForm); - const [error, setError] = useState(null); - - async function loadDashboard() { - try { - const [ - plantsResponse, - tasksResponse, - taxaResponse, - locationsResponse, - groupsResponse, - actionsResponse, - resourcesResponse, - recipesResponse, - activitiesResponse, - flagsResponse, - ] = await Promise.all([ - getPlants(), - getCareTasks(), - getPlantTaxa(), - getPlantLocations(), - getPlantGroups(), - getCareActions(), - getActionResources(), - getRecipes(), - getCareActivities(), - getPlantFlags(), - ]); - - setError(null); - setPlants(plantsResponse); - setCareTasks(tasksResponse); - setPlantTaxa(taxaResponse); - setPlantLocations(locationsResponse); - setPlantGroups(groupsResponse); - setCareActions(actionsResponse); - setActionResources(resourcesResponse); - setRecipes(recipesResponse); - setCareActivities(activitiesResponse); - setPlantFlagDefinitions(flagsResponse); - } catch { - setError('Could not reach the Plant-Man API. Start the backend and refresh.'); - } finally { - setIsLoading(false); - } - } - - async function loadLocations() { - const locationsResponse = await getPlantLocations(); - - setError(null); - setPlantLocations(locationsResponse); - } - - async function loadGroupsAndPlants() { - const [groupsResponse, plantsResponse] = await Promise.all([ - getPlantGroups(), - getPlants(), - ]); - - setError(null); - setPlantGroups(groupsResponse); - setPlants(plantsResponse); - } - - async function loadPlants() { - const plantsResponse = await getPlants(); - - setError(null); - setPlants(plantsResponse); - } - - async function loadPlantsAndCareTasks() { - const [plantsResponse, tasksResponse] = await Promise.all([ - getPlants(), - getCareTasks(), - ]); - - setError(null); - setPlants(plantsResponse); - setCareTasks(tasksResponse); - } - - async function loadTaxaAndPlants() { - const [plantsResponse, taxaResponse] = await Promise.all([ - getPlants(), - getPlantTaxa(), - ]); - - setError(null); - setPlants(plantsResponse); - setPlantTaxa(taxaResponse); - } - - async function loadFlagsAndPlants() { - const [plantsResponse, flagsResponse] = await Promise.all([ - getPlants(), - getPlantFlags(), - ]); - - setError(null); - setPlants(plantsResponse); - setPlantFlagDefinitions(flagsResponse); - } - - async function loadCareModel() { - const [ - plantsResponse, - tasksResponse, - actionsResponse, - resourcesResponse, - recipesResponse, - activitiesResponse, - ] = await Promise.all([ - getPlants(), - getCareTasks(), - getCareActions(), - getActionResources(), - getRecipes(), - getCareActivities(), - ]); - - setError(null); - setPlants(plantsResponse); - setCareTasks(tasksResponse); - setCareActions(actionsResponse); - setActionResources(resourcesResponse); - setRecipes(recipesResponse); - setCareActivities(activitiesResponse); - } - - async function loadRecipesAndResources() { - const [recipesResponse, resourcesResponse] = await Promise.all([ - getRecipes(), - getActionResources(), - ]); - - setError(null); - setRecipes(recipesResponse); - setActionResources(resourcesResponse); - } - - useEffect(() => { - queueMicrotask(() => { - void loadDashboard(); - }); - }, []); - - const dueCount = useMemo( - () => careTasks.filter((task) => task.status === 'due').length, - [careTasks], - ); - - const activePlant = plants.find((plant) => plant.id === editingPlantId); - const selectedPlant = plants.find((plant) => plant.id === selectedPlantId); - const activeTaxon = plantTaxa.find((taxon) => taxon.id === editingTaxonId); - const selectedTaxon = plantTaxa.find((taxon) => taxon.id === selectedTaxonId); - const activeLocation = plantLocations.find((location) => location.id === editingLocationId); - const selectedLocation = plantLocations.find((location) => location.id === selectedLocationId); - const activePlantGroup = plantGroups.find((group) => group.id === editingPlantGroupId); - const activeAction = careActions.find((action) => action.id === editingActionId); - const selectedAction = careActions.find((action) => action.id === selectedActionId); - const activeResource = actionResources.find((resource) => resource.id === editingResourceId); - const selectedResource = actionResources.find((resource) => resource.id === selectedResourceId); - const activeRecipe = recipes.find((recipe) => recipe.id === editingRecipeId); - const selectedRecipe = recipes.find((recipe) => recipe.id === selectedRecipeId); - const activeActivity = careActivities.find((activity) => activity.id === editingActivityId); - const selectedActivity = careActivities.find((activity) => activity.id === selectedActivityId); - const activeFlagDefinition = plantFlagDefinitions.find((flag) => flag.id === editingFlagDefinitionId); - const selectedFlagDefinition = plantFlagDefinitions.find((flag) => flag.id === selectedFlagDefinitionId); - - function updateForm(field: keyof PlantFormState, value: string) { - setForm((current) => ({ ...current, [field]: value })); - } - - function updateTaxonForm(field: keyof TaxonFormState, value: string) { - setTaxonForm((current) => ({ ...current, [field]: value })); - } - - function updateLocationForm(field: keyof LocationFormState, value: string) { - setLocationForm((current) => ({ ...current, [field]: value })); - } - - function updatePlantGroupForm(field: keyof PlantGroupFormState, value: string | string[]) { - setPlantGroupForm((current) => ({ ...current, [field]: value })); - } - - function updateActionForm(field: keyof ActionFormState, value: string) { - setActionForm((current) => ({ ...current, [field]: value })); - } - - function updateResourceForm(field: keyof ResourceFormState, value: string) { - setResourceForm((current) => ({ ...current, [field]: value })); - } - - function updateActivityForm(field: keyof ActivityFormState, value: ActivityFormState[keyof ActivityFormState]) { - setActivityForm((current) => ({ ...current, [field]: value })); - } - - function updateRecipeForm(field: keyof RecipeFormState, value: RecipeFormState[keyof RecipeFormState]) { - setRecipeForm((current) => ({ ...current, [field]: value })); - } - - function updateFlagDefinitionForm(field: keyof FlagDefinitionFormState, value: string) { - setFlagDefinitionForm((current) => ({ ...current, [field]: value })); - } - - function updatePlantFlagForm(field: keyof PlantFlagFormState, value: string) { - setPlantFlagForm((current) => ({ ...current, [field]: value })); - } - - function updateBulkScheduleForm( - field: keyof BulkScheduleFormState, - value: string | string[], - ) { - setBulkScheduleForm((current) => ({ ...current, [field]: value })); - } - - function startAddingPlant() { - setEditingPlantId(null); - setSelectedPlantId(null); - setForm(emptyPlantForm); - setIsPlantEditorOpen(true); - setView('plants'); - } - - function openPlantDetail(plant: Plant) { - setSelectedPlantId(plant.id); - setEditingPlantId(null); - setForm(emptyPlantForm); - setIsPlantEditorOpen(false); - setView('plant-management'); - } - - function startEditingPlant(plant: Plant) { - setSelectedPlantId(null); - setEditingPlantId(plant.id); - setForm(toPlantForm(plant)); - setIsPlantEditorOpen(true); - setView('plants'); - } - - function cancelEditing() { - setEditingPlantId(null); - setForm(emptyPlantForm); - setIsPlantEditorOpen(false); - } - - function openPlantReadOnlyDetail(plant: Plant) { - setSelectedPlantId(plant.id); - setEditingPlantId(null); - setForm(emptyPlantForm); - setIsPlantEditorOpen(false); - setView('plants'); - } - - function startAddingTaxon() { - setEditingTaxonId(null); - setSelectedTaxonId(null); - setTaxonForm(emptyTaxonForm); - setIsTaxonEditorOpen(true); - setView('taxa'); - } - - function startEditingTaxon(taxon: PlantTaxon) { - setSelectedTaxonId(null); - setEditingTaxonId(taxon.id); - setTaxonForm(toTaxonForm(taxon)); - setIsTaxonEditorOpen(true); - setView('taxa'); - } - - function cancelEditingTaxon() { - setEditingTaxonId(null); - setTaxonForm(emptyTaxonForm); - setIsTaxonEditorOpen(false); - } - - function startAddingLocation() { - setEditingLocationId(null); - setSelectedLocationId(null); - setLocationForm(emptyLocationForm); - setIsLocationEditorOpen(true); - setView('locations'); - } - - function startEditingLocation(location: PlantLocation) { - setSelectedLocationId(null); - setEditingLocationId(location.id); - setLocationForm(toLocationForm(location)); - setIsLocationEditorOpen(true); - setView('locations'); - } - - function cancelEditingLocation() { - setEditingLocationId(null); - setLocationForm(emptyLocationForm); - setIsLocationEditorOpen(false); - } - - function startAddingPlantGroup() { - setEditingPlantGroupId(null); - setPlantGroupForm(emptyPlantGroupForm); - setIsPlantGroupEditorOpen(true); - setView('groups'); - } - - function startEditingPlantGroup(group: PlantGroup) { - setEditingPlantGroupId(group.id); - setPlantGroupForm(toPlantGroupForm(group)); - setIsPlantGroupEditorOpen(true); - setView('groups'); - } - - function cancelEditingPlantGroup() { - setEditingPlantGroupId(null); - setPlantGroupForm(emptyPlantGroupForm); - setIsPlantGroupEditorOpen(false); - } - - function startAddingAction() { - setEditingActionId(null); - setSelectedActionId(null); - setActionForm(emptyActionForm); - setIsActionEditorOpen(true); - setView('actions'); - } - - function startEditingAction(action: CareAction) { - setSelectedActionId(null); - setEditingActionId(action.id); - setActionForm(toActionForm(action)); - setIsActionEditorOpen(true); - setView('actions'); - } - - function cancelEditingAction() { - setEditingActionId(null); - setActionForm(emptyActionForm); - setIsActionEditorOpen(false); - } - - function startAddingResource() { - setEditingResourceId(null); - setSelectedResourceId(null); - setResourceForm(emptyResourceForm); - setIsResourceEditorOpen(true); - setView('resources'); - } - - function startEditingResource(resource: ActionResource) { - setSelectedResourceId(null); - setEditingResourceId(resource.id); - setResourceForm(toResourceForm(resource)); - setIsResourceEditorOpen(true); - setView('resources'); - } - - function cancelEditingResource() { - setEditingResourceId(null); - setResourceForm(emptyResourceForm); - setIsResourceEditorOpen(false); - } - - function startAddingRecipe() { - setEditingRecipeId(null); - setSelectedRecipeId(null); - setRecipeForm(emptyRecipeForm); - setIsRecipeEditorOpen(true); - setView('recipes'); - } - - function startEditingRecipe(recipe: Recipe) { - setSelectedRecipeId(null); - setEditingRecipeId(recipe.id); - setRecipeForm(toRecipeForm(recipe)); - setIsRecipeEditorOpen(true); - setView('recipes'); - } - - function cancelEditingRecipe() { - setEditingRecipeId(null); - setRecipeForm(emptyRecipeForm); - setIsRecipeEditorOpen(false); - } - - function startAddingActivity() { - setEditingActivityId(null); - setSelectedActivityId(null); - setActivityForm(emptyActivityForm); - setIsActivityEditorOpen(true); - setView('activities'); - } - - function startEditingActivity(activity: CareActivity) { - setSelectedActivityId(null); - setEditingActivityId(activity.id); - setActivityForm(toActivityForm(activity)); - setIsActivityEditorOpen(true); - setView('activities'); - } - - function cancelEditingActivity() { - setEditingActivityId(null); - setActivityForm(emptyActivityForm); - setIsActivityEditorOpen(false); - } - - function startAddingFlagDefinition() { - setEditingFlagDefinitionId(null); - setSelectedFlagDefinitionId(null); - setFlagDefinitionForm(emptyFlagDefinitionForm); - setIsFlagDefinitionEditorOpen(true); - setView('flags'); - } - - function startEditingFlagDefinition(flag: PlantFlagDefinition) { - setSelectedFlagDefinitionId(null); - setEditingFlagDefinitionId(flag.id); - setFlagDefinitionForm(toFlagDefinitionForm(flag)); - setIsFlagDefinitionEditorOpen(true); - setView('flags'); - } - - function cancelEditingFlagDefinition() { - setEditingFlagDefinitionId(null); - setFlagDefinitionForm(emptyFlagDefinitionForm); - setIsFlagDefinitionEditorOpen(false); - } - - async function savePlant() { - if (!form.nickname.trim()) { - setError('Plant name is required.'); - return; - } - - setIsSaving(true); - try { - const payload = editingPlantId === null - ? toPlantPayload(form) - : { - ...toPlantPayload(form), - taxonId: activePlant?.taxonId ?? null, - locationId: activePlant?.locationId ?? null, - }; - if (editingPlantId === null) { - await createPlant(payload); - } else { - await updatePlant(editingPlantId, payload); - } - cancelEditing(); - await loadPlantsAndCareTasks(); - } catch { - setError('Could not save the plant.'); - } finally { - setIsSaving(false); - } - } - - async function updateManagedPlant(nextValues: { - taxonId?: number | null; - locationId?: number | null; - }) { - if (!selectedPlant) { - return; - } - - setIsSaving(true); - try { - await updatePlant(selectedPlant.id, { - nickname: selectedPlant.nickname, - birthday: selectedPlant.birthday, - taxonId: nextValues.taxonId === undefined ? selectedPlant.taxonId : nextValues.taxonId, - locationId: nextValues.locationId === undefined ? selectedPlant.locationId : nextValues.locationId, - careSchedules: null, - }); - await loadPlants(); - } catch { - setError('Could not update the plant assignment.'); - } finally { - setIsSaving(false); - } - } - - function selectManagedPlant(plantId: string) { - setSelectedPlantId(plantId ? Number(plantId) : null); - setPlantFlagForm(emptyPlantFlagForm); - } - - function setManagedPlantTaxon(taxonId: string) { - void updateManagedPlant({ taxonId: taxonId ? Number(taxonId) : null }); - } - - function setManagedPlantLocation(locationId: string) { - void updateManagedPlant({ locationId: locationId ? Number(locationId) : null }); - } - - async function removePlant(plant: Plant) { - const confirmed = window.confirm(`Delete ${plant.nickname}? This also removes its care log.`); - if (!confirmed) { - return; - } - - setIsSaving(true); - try { - await deletePlant(plant.id); - if (selectedPlantId === plant.id) { - setSelectedPlantId(null); - } - if (editingPlantId === plant.id) { - cancelEditing(); - } - await loadPlantsAndCareTasks(); - } catch { - setError('Could not delete the plant.'); - } finally { - setIsSaving(false); - } - } - - async function exportSpreadsheet() { - setIsExporting(true); - try { - setError(null); - await downloadSpreadsheetExport(); - } catch { - setError('Could not export the spreadsheet.'); - } finally { - setIsExporting(false); - } - } - - async function saveTaxon() { - if (!taxonForm.name.trim() || !taxonForm.genus.trim() || !taxonForm.species.trim()) { - setError('Name, genus, and species are required.'); - return; - } - - setIsSaving(true); - try { - const payload = toTaxonPayload(taxonForm); - if (editingTaxonId === null) { - await createPlantTaxon(payload); - } else { - await updatePlantTaxon(editingTaxonId, payload); - } - cancelEditingTaxon(); - await loadTaxaAndPlants(); - } catch { - setError('Could not save the taxon.'); - } finally { - setIsSaving(false); - } - } - - async function removeTaxon(taxon: PlantTaxon) { - const confirmed = window.confirm(`Delete ${taxon.name}? Taxa used by plants cannot be deleted.`); - if (!confirmed) { - return; - } - - setIsSaving(true); - try { - await deletePlantTaxon(taxon.id); - if (editingTaxonId === taxon.id) { - cancelEditingTaxon(); - } - await loadTaxaAndPlants(); - } catch { - setError('Could not delete the taxon. It may still be used by a plant.'); - } finally { - setIsSaving(false); - } - } - - async function saveLocation() { - if (!locationForm.name.trim()) { - setError('Location name is required.'); - return; - } - - setIsSaving(true); - try { - const payload = toLocationPayload(locationForm); - if (editingLocationId === null) { - await createPlantLocation(payload); - } else { - await updatePlantLocation(editingLocationId, payload); - } - cancelEditingLocation(); - await loadLocations(); - } catch { - setError('Could not save the location.'); - } finally { - setIsSaving(false); - } - } - - async function removeLocation(location: PlantLocation) { - const confirmed = window.confirm(`Delete ${location.name}? Locations assigned to plants cannot be deleted.`); - if (!confirmed) { - return; - } - - setIsSaving(true); - try { - await deletePlantLocation(location.id); - if (editingLocationId === location.id) { - cancelEditingLocation(); - } - await loadLocations(); - } catch { - await loadLocations(); - setError('Could not delete the location. It may still be assigned to a plant.'); - } finally { - setIsSaving(false); - } - } - - async function savePlantGroup() { - if (!plantGroupForm.name.trim()) { - setError('Group name is required.'); - return; - } - - setIsSaving(true); - try { - const payload = toPlantGroupPayload(plantGroupForm); - if (editingPlantGroupId === null) { - await createPlantGroup(payload); - } else { - await updatePlantGroup(editingPlantGroupId, payload); - } - cancelEditingPlantGroup(); - await loadGroupsAndPlants(); - } catch { - setError('Could not save the plant group.'); - } finally { - setIsSaving(false); - } - } - - async function removePlantGroup(group: PlantGroup) { - const confirmed = window.confirm(`Delete ${group.name}?`); - if (!confirmed) { - return; - } - - setIsSaving(true); - try { - await deletePlantGroup(group.id); - if (editingPlantGroupId === group.id) { - cancelEditingPlantGroup(); - } - await loadGroupsAndPlants(); - } catch { - setError('Could not delete the plant group.'); - } finally { - setIsSaving(false); - } - } - - async function saveAction() { - if (!actionForm.name.trim()) { - setError('Action name is required.'); - return; - } - - setIsSaving(true); - try { - const payload = toActionPayload(actionForm); - if (editingActionId === null) { - await createCareAction(payload); - } else { - await updateCareAction(editingActionId, payload); - } - cancelEditingAction(); - await loadCareModel(); - } catch { - setError('Could not save the action.'); - } finally { - setIsSaving(false); - } - } - - async function removeAction(action: CareAction) { - const confirmed = window.confirm(`Delete ${action.name}? Actions with care history cannot be deleted.`); - if (!confirmed) { - return; - } - - setIsSaving(true); - try { - await deleteCareAction(action.id); - if (editingActionId === action.id) { - cancelEditingAction(); - } - await loadCareModel(); - } catch { - await loadCareModel(); - setError('Could not delete the action. It may still have care history.'); - } finally { - setIsSaving(false); - } - } - - async function saveResource() { - if (!resourceForm.name.trim()) { - setError('Resource name is required.'); - return; - } - - setIsSaving(true); - try { - const payload = toResourcePayload(resourceForm); - if (editingResourceId === null) { - await createActionResource(payload); - } else { - await updateActionResource(editingResourceId, payload); - } - cancelEditingResource(); - await loadCareModel(); - await loadRecipesAndResources(); - } catch { - setError('Could not save the resource.'); - } finally { - setIsSaving(false); - } - } - - async function removeResource(resource: ActionResource) { - const confirmed = window.confirm(`Delete ${resource.name}?`); - if (!confirmed) { - return; - } - - setIsSaving(true); - try { - await deleteActionResource(resource.id); - if (editingResourceId === resource.id) { - cancelEditingResource(); - } - await loadCareModel(); - await loadRecipesAndResources(); - } catch { - setError('Could not delete the resource.'); - } finally { - setIsSaving(false); - } - } - - async function saveRecipe() { - if (!recipeForm.name.trim() || !recipeForm.type.trim() || !recipeForm.outputResourceName.trim() || recipeForm.components.length === 0) { - setError('Recipe name, type, produced resource, and at least one component are required.'); - return; - } - - setIsSaving(true); - try { - const payload = toRecipePayload(recipeForm); - if (editingRecipeId === null) { - await createRecipe(payload); - } else { - await updateRecipe(editingRecipeId, payload); - } - cancelEditingRecipe(); - await loadRecipesAndResources(); - } catch { - setError('Could not save the recipe.'); - } finally { - setIsSaving(false); - } - } - - async function removeRecipe(recipe: Recipe) { - const confirmed = window.confirm(`Delete ${recipe.name}?`); - if (!confirmed) { - return; - } - - setIsSaving(true); - try { - await deleteRecipe(recipe.id); - if (selectedRecipeId === recipe.id) { - setSelectedRecipeId(null); - } - if (editingRecipeId === recipe.id) { - cancelEditingRecipe(); - } - await loadRecipesAndResources(); - } catch { - setError('Could not delete the recipe.'); - } finally { - setIsSaving(false); - } - } - - async function saveActivity() { - if (!activityForm.name.trim() || activityForm.actions.length === 0) { - setError('Activity name and at least one action are required.'); - return; - } - - setIsSaving(true); - try { - const payload = toActivityPayload(activityForm); - if (editingActivityId === null) { - await createCareActivity(payload); - } else { - await updateCareActivity(editingActivityId, payload); - } - cancelEditingActivity(); - await loadCareModel(); - } catch { - setError('Could not save the activity.'); - } finally { - setIsSaving(false); - } - } - - async function removeActivity(activity: CareActivity) { - const confirmed = window.confirm(`Delete ${activity.name}? Activities in use cannot be deleted.`); - if (!confirmed) { - return; - } - - setIsSaving(true); - try { - await deleteCareActivity(activity.id); - if (editingActivityId === activity.id) { - cancelEditingActivity(); - } - await loadCareModel(); - } catch { - await loadCareModel(); - setError('Could not delete the activity. It may still be in use.'); - } finally { - setIsSaving(false); - } - } - - async function saveFlagDefinition() { - if (!flagDefinitionForm.name.trim()) { - setError('Flag name is required.'); - return; - } - - setIsSaving(true); - try { - const payload = toFlagDefinitionPayload(flagDefinitionForm); - if (editingFlagDefinitionId === null) { - await createPlantFlag(payload); - } else { - await updatePlantFlag(editingFlagDefinitionId, payload); - } - cancelEditingFlagDefinition(); - await loadFlagsAndPlants(); - } catch { - setError('Could not save the plant flag.'); - } finally { - setIsSaving(false); - } - } - - async function saveBulkSchedule() { - if (!bulkScheduleForm.careActivityId || bulkScheduleForm.plantIds.length === 0) { - setError('Select an activity and at least one plant.'); - return; - } - - setIsSaving(true); - try { - await savePlantCareSchedulesBulk(toBulkSchedulePayload(bulkScheduleForm)); - setBulkScheduleForm(emptyBulkScheduleForm); - await loadPlantsAndCareTasks(); - } catch { - setError('Could not apply the care schedule.'); - } finally { - setIsSaving(false); - } - } - - async function removeBulkSchedule() { - if (!bulkScheduleForm.careActivityId || bulkScheduleForm.plantIds.length === 0) { - setError('Select an activity and at least one plant.'); - return; - } - - setIsSaving(true); - try { - await removePlantCareSchedulesBulk(toBulkSchedulePayload(bulkScheduleForm)); - setBulkScheduleForm(emptyBulkScheduleForm); - await loadPlantsAndCareTasks(); - } catch { - setError('Could not remove the care schedule.'); - } finally { - setIsSaving(false); - } - } - - async function removeFlagDefinition(flag: PlantFlagDefinition) { - const confirmed = window.confirm(`Delete ${flag.name}? Flags assigned to plants cannot be deleted.`); - if (!confirmed) { - return; - } - - setIsSaving(true); - try { - await deletePlantFlag(flag.id); - if (editingFlagDefinitionId === flag.id) { - cancelEditingFlagDefinition(); - } - await loadFlagsAndPlants(); - } catch { - await loadFlagsAndPlants(); - setError('Could not delete the flag. It may still be assigned to a plant.'); - } finally { - setIsSaving(false); - } - } - - async function assignFlagToSelectedPlant() { - if (!selectedPlantId || !plantFlagForm.plantFlagDefinitionId) { - setError('Select a plant and flag first.'); - return; - } - - setIsSaving(true); - try { - await assignPlantFlag(selectedPlantId, toPlantFlagPayload(plantFlagForm)); - setPlantFlagForm(emptyPlantFlagForm); - await loadPlants(); - } catch { - setError('Could not attach the plant flag.'); - } finally { - setIsSaving(false); - } - } - - async function resolveAssignedPlantFlag(flag: PlantFlag) { - if (!selectedPlantId) { - return; - } - - setIsSaving(true); - try { - await resolvePlantFlag(selectedPlantId, flag.id); - await loadPlants(); - } catch { - setError('Could not resolve the plant flag.'); - } finally { - setIsSaving(false); - } - } - - async function removeAssignedPlantFlag(flag: PlantFlag) { - if (!selectedPlantId) { - return; - } - - const confirmed = window.confirm(`Remove ${flag.name} from this plant?`); - if (!confirmed) { - return; - } - - setIsSaving(true); - try { - await removePlantFlagAssignment(selectedPlantId, flag.id); - await loadPlants(); - } catch { - setError('Could not remove the plant flag.'); - } finally { - setIsSaving(false); - } - } - - async function completeTask(task: CareTask) { - setIsSaving(true); - try { - await completeCareTasksBulk({ - careActivityId: task.careActivityId, - plantIds: [task.plantId], - performedOn: getTodayInputDate(), - notes: '', - resources: [], - }); - await loadPlantsAndCareTasks(); - } catch { - setError('Could not log the care task.'); - } finally { - setIsSaving(false); - } - } - - async function completeBulkTasks(tasks: CareTask[]) { - const dueTasks = tasks.filter((task) => task.status === 'due'); - if (dueTasks.length === 0) { - return; - } - - const action = dueTasks[0].action; - const confirmed = window.confirm(`Log ${action} for ${dueTasks.length} due plants?`); - if (!confirmed) { - return; - } - - setIsSaving(true); - try { - await completeCareTasksBulk({ - careActivityId: dueTasks[0].careActivityId, - plantIds: dueTasks.map((task) => task.plantId), - performedOn: getTodayInputDate(), - notes: '', - resources: [], - }); - await loadPlantsAndCareTasks(); - } catch { - setError('Could not log the due tasks.'); - } finally { - setIsSaving(false); - } - } + const { + actionResources, + careActions, + careActivities, + careTasks, + dueCount, + error, + isLoading, + loadCareModel, + loadFlagsAndPlants, + loadGroupsAndPlants, + loadLocations, + loadPlants, + loadPlantsAndCareTasks, + loadRecipesAndResources, + loadTaxaAndPlants, + plantFlagDefinitions, + plantGroups, + plantLocations, + plantTaxa, + plants, + recipes, + setError, + } = useDashboardData(); + const editors = useAppEditors({ + actionResources, + careActions, + careActivities, + plantFlagDefinitions, + plantGroups, + plantLocations, + plantTaxa, + plants, + recipes, + }, setView); + const { + actionForm, + activeAction, + activeActivity, + activeFlagDefinition, + activeLocation, + activePlant, + activePlantGroup, + activeRecipe, + activeResource, + activeTaxon, + activityForm, + bulkScheduleForm, + cancelEditing, + cancelEditingAction, + cancelEditingActivity, + cancelEditingFlagDefinition, + cancelEditingLocation, + cancelEditingPlantGroup, + cancelEditingRecipe, + cancelEditingResource, + cancelEditingTaxon, + flagDefinitionForm, + form, + isActionEditorOpen, + isActivityEditorOpen, + isFlagDefinitionEditorOpen, + isLocationEditorOpen, + isPlantEditorOpen, + isPlantGroupEditorOpen, + isRecipeEditorOpen, + isResourceEditorOpen, + isTaxonEditorOpen, + locationForm, + openPlantDetail, + openPlantReadOnlyDetail, + plantFlagForm, + plantGroupForm, + recipeForm, + resourceForm, + selectedAction, + selectedActivity, + selectedFlagDefinition, + selectedLocation, + selectedPlant, + selectedPlantId, + selectedRecipe, + selectedResource, + selectedTaxon, + selectManagedPlant, + setSelectedActionId, + setSelectedActivityId, + setSelectedFlagDefinitionId, + setSelectedLocationId, + setSelectedPlantId, + setSelectedRecipeId, + setSelectedResourceId, + setSelectedTaxonId, + startAddingAction, + startAddingActivity, + startAddingFlagDefinition, + startAddingLocation, + startAddingPlant, + startAddingPlantGroup, + startAddingRecipe, + startAddingResource, + startAddingTaxon, + startAddingTaxonFromPlantInfo, + startEditingAction, + startEditingActivity, + startEditingFlagDefinition, + startEditingLocation, + startEditingPlant, + startEditingPlantGroup, + startEditingRecipe, + startEditingResource, + startEditingTaxon, + taxonForm, + updateActionForm, + updateActivityForm, + updateBulkScheduleForm, + updateFlagDefinitionForm, + updateForm, + updateLocationForm, + updatePlantFlagForm, + updatePlantGroupForm, + updateRecipeForm, + updateResourceForm, + updateTaxonForm, + } = editors; + const { + assignFlagToSelectedPlant, + completeBulkTasks, + completeTask, + exportSpreadsheet, + isExporting, + isSearchingPlantInfo, + isSaving, + importTaxonFromPlantInfo, + hasSearchedPlantInfo, + plantInfoQuery, + plantInfoResults, + removeAction, + removeActivity, + removeAssignedPlantFlag, + removeBulkSchedule, + removeFlagDefinition, + removeLocation, + removePlant, + removePlantGroup, + removeRecipe, + removeResource, + removeTaxon, + resolveAssignedPlantFlag, + saveAction, + saveActivity, + saveBulkSchedule, + saveFlagDefinition, + saveLocation, + savePlant, + savePlantGroup, + saveRecipe, + saveResource, + saveTaxon, + searchTaxonInfo, + setManagedPlantLocation, + setManagedPlantTaxon, + setPlantInfoQuery, + } = useAppActions({ + editors, + loadCareModel, + loadFlagsAndPlants, + loadGroupsAndPlants, + loadLocations, + loadPlants, + loadPlantsAndCareTasks, + loadRecipesAndResources, + loadTaxaAndPlants, + setError, + }); return (
@@ -1371,6 +387,7 @@ export function App() { activeTaxonName={activeTaxon?.name} error={error} form={taxonForm} + hasSearched={hasSearchedPlantInfo} isEditorOpen={isTaxonEditorOpen} isLoading={isLoading} isSaving={isSaving} @@ -1381,9 +398,16 @@ export function App() { onDelete={(taxon) => void removeTaxon(taxon)} onEdit={startEditingTaxon} onFieldChange={updateTaxonForm} + onImportResult={(result) => void importTaxonFromPlantInfo(result)} onNew={startAddingTaxon} onOpenDetail={(taxon) => setSelectedTaxonId(taxon.id)} + onPrefillResult={startAddingTaxonFromPlantInfo} onSave={() => void saveTaxon()} + onSearch={(query) => void searchTaxonInfo(query)} + onSearchQueryChange={setPlantInfoQuery} + plantInfoResults={plantInfoResults} + searchQuery={plantInfoQuery} + isSearching={isSearchingPlantInfo} /> ) : view === 'locations' ? ( ('/api/plant-taxa'); } +export async function searchPlantInfo(query: string) { + return request(`/api/plant-info/search?q=${encodeURIComponent(query)}`); +} + export async function getPlantLocations() { return request('/api/plant-locations'); } @@ -134,6 +139,13 @@ export async function createPlantTaxon(payload: PlantTaxonPayload) { }); } +export async function importPlantTaxon(payload: PlantInfoSearchResult) { + return request('/api/plant-taxa/import', { + method: 'POST', + body: JSON.stringify(payload), + }); +} + export async function updatePlantTaxon(id: number, payload: PlantTaxonPayload) { return request(`/api/plant-taxa/${id}`, { method: 'PUT', diff --git a/plant-manager-web/src/components/TaxaView.tsx b/plant-manager-web/src/components/TaxaView.tsx index eacfd53..df7b220 100644 --- a/plant-manager-web/src/components/TaxaView.tsx +++ b/plant-manager-web/src/components/TaxaView.tsx @@ -1,5 +1,5 @@ -import { Edit3, Eye, Plus, Save, Trash2, X } from 'lucide-react'; -import type { PlantTaxon } from '../domain'; +import { Download, Edit3, Eye, Plus, Save, Search, Trash2, X } from 'lucide-react'; +import type { PlantInfoSearchResult, PlantTaxon } from '../domain'; import type { TaxonFormState } from '../form-state'; import { formatTaxon } from '../form-state'; @@ -7,9 +7,13 @@ type TaxaViewProps = { activeTaxonName?: string; error: string | null; form: TaxonFormState; + hasSearched: boolean; isEditorOpen: boolean; isLoading: boolean; isSaving: boolean; + isSearching: boolean; + plantInfoResults: PlantInfoSearchResult[]; + searchQuery: string; selectedTaxon?: PlantTaxon; taxa: PlantTaxon[]; onCancel: () => void; @@ -17,18 +21,41 @@ type TaxaViewProps = { onDelete: (taxon: PlantTaxon) => void; onEdit: (taxon: PlantTaxon) => void; onFieldChange: (field: keyof TaxonFormState, value: string) => void; + onImportResult: (result: PlantInfoSearchResult) => void; onNew: () => void; onOpenDetail: (taxon: PlantTaxon) => void; + onPrefillResult: (result: PlantInfoSearchResult) => void; onSave: () => void; + onSearch: (query?: string) => void; + onSearchQueryChange: (value: string) => void; }; +const plantInfoSearchExamples = [ + 'ficus', + 'monstera', + 'alocasia', + 'croton', +]; + +function getPlantInfoSubtitle(result: PlantInfoSearchResult) { + if (result.commonName) { + return result.commonName; + } + + return [result.genus, result.species].filter(Boolean).join(' ') || 'Reference taxon'; +} + export function TaxaView({ activeTaxonName, error, form, + hasSearched, isEditorOpen, isLoading, isSaving, + isSearching, + plantInfoResults, + searchQuery, selectedTaxon, taxa, onCancel, @@ -36,9 +63,13 @@ export function TaxaView({ onDelete, onEdit, onFieldChange, + onImportResult, onNew, onOpenDetail, + onPrefillResult, onSave, + onSearch, + onSearchQueryChange, }: TaxaViewProps) { return ( <> @@ -56,6 +87,96 @@ export function TaxaView({ +
+
+
+

Offline lookup

+

Search plant info

+
+
+ +
+ + +
+ +
+ {plantInfoSearchExamples.map((query) => ( + + ))} +
+ + {plantInfoResults.length > 0 ? ( +
+ {plantInfoResults.map((result) => ( +
+
+

{result.canonicalName ?? result.scientificName}

+

{getPlantInfoSubtitle(result)}

+
+
+
Family
+
{result.family ?? 'Unknown'}
+
+
+
Genus
+
{result.genus ?? 'Unknown'}
+
+
+
Species
+
{result.species ?? 'Unknown'}
+
+
+
Status
+
{[result.rank, result.status].filter(Boolean).join(' / ') || 'Unknown'}
+
+
+
Source
+
{`${result.source}:${result.externalId}`}
+
+
+
+
+ + +
+
+ ))} +
+ ) : hasSearched && !isSearching ? ( +

No plant info matches that search.

+ ) : null} +
+ {selectedTaxon && !isEditorOpen ? (
@@ -97,6 +218,14 @@ export function TaxaView({ Authority {selectedTaxon.authority ?? 'None'}
+
+ Family + {selectedTaxon.family ?? 'None'} +
+
+ GBIF + {selectedTaxon.externalSource === 'gbif' ? selectedTaxon.externalId : 'Not linked'} +
) : null} @@ -156,6 +285,20 @@ export function TaxaView({ onChange={(event) => onFieldChange('authority', event.target.value)} /> + +
diff --git a/plant-manager-web/src/domain.ts b/plant-manager-web/src/domain.ts index 466f54c..269c004 100644 --- a/plant-manager-web/src/domain.ts +++ b/plant-manager-web/src/domain.ts @@ -49,6 +49,23 @@ export type PlantTaxon = { cultivar: string | null; variety: string | null; authority: string | null; + family: string | null; + commonName: string | null; + externalSource: string | null; + externalId: string | null; +}; + +export type PlantInfoSearchResult = { + source: string; + externalId: string; + scientificName: string; + canonicalName: string | null; + commonName: string | null; + rank: string | null; + status: string | null; + family: string | null; + genus: string | null; + species: string | null; }; export type PlantLocation = { @@ -220,6 +237,10 @@ export type PlantTaxonPayload = { cultivar: string | null; variety: string | null; authority: string | null; + family: string | null; + commonName: string | null; + externalSource: string | null; + externalId: string | null; }; export type PlantLocationPayload = { diff --git a/plant-manager-web/src/form-state.ts b/plant-manager-web/src/form-state.ts index dad53ba..6df4a4e 100644 --- a/plant-manager-web/src/form-state.ts +++ b/plant-manager-web/src/form-state.ts @@ -9,6 +9,7 @@ import type { Recipe, RecipePayload, Plant, + PlantInfoSearchResult, AssignPlantFlagPayload, PlantPayload, PlantFlagDefinition, @@ -37,6 +38,10 @@ export const emptyTaxonForm = { cultivar: '', variety: '', authority: '', + family: '', + commonName: '', + externalSource: '', + externalId: '', }; export const emptyLocationForm = { @@ -187,6 +192,25 @@ export function toTaxonForm(taxon: PlantTaxon): TaxonFormState { cultivar: taxon.cultivar ?? '', variety: taxon.variety ?? '', authority: taxon.authority ?? '', + family: taxon.family ?? '', + commonName: taxon.commonName ?? '', + externalSource: taxon.externalSource ?? '', + externalId: taxon.externalId ?? '', + }; +} + +export function toTaxonFormFromPlantInfo(result: PlantInfoSearchResult): TaxonFormState { + const canonicalName = result.canonicalName ?? result.scientificName; + + return { + ...emptyTaxonForm, + name: result.commonName ?? canonicalName, + genus: result.genus ?? '', + species: result.species ?? canonicalName.split(' ')[1] ?? '', + family: result.family ?? '', + commonName: result.commonName ?? '', + externalSource: result.source, + externalId: result.externalId, }; } @@ -198,6 +222,10 @@ export function toTaxonPayload(form: TaxonFormState): PlantTaxonPayload { cultivar: form.cultivar.trim() || null, variety: form.variety.trim() || null, authority: form.authority.trim() || null, + family: form.family.trim() || null, + commonName: form.commonName.trim() || null, + externalSource: form.externalSource.trim() || null, + externalId: form.externalId.trim() || null, }; } diff --git a/plant-manager-web/src/styles.css b/plant-manager-web/src/styles.css index e59a216..229276a 100644 --- a/plant-manager-web/src/styles.css +++ b/plant-manager-web/src/styles.css @@ -750,7 +750,79 @@ dd { gap: 9px; } +.search-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: 10px; +} + +.query-chip-row { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 8px 0 12px; +} + +.query-chip { + min-height: 30px; + padding: 0 10px; + border: 1px solid #cfc4b4; + border-radius: 999px; + background: var(--field); + color: #33463b; + font-size: 0.78rem; + font-weight: 600; + cursor: pointer; +} + +.query-chip:hover { + border-color: var(--link); + background: var(--sage); + color: var(--link); +} + +.plant-info-result { + min-width: 0; +} + +.plant-info-result h3, +.plant-info-result p { + margin: 0; +} + +.plant-info-result p { + margin-top: 3px; + color: var(--muted); +} + +.plant-info-meta { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px 12px; + margin: 10px 0 0; +} + +.plant-info-meta div { + min-width: 0; +} + +.plant-info-meta dt { + color: #65756a; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; +} + +.plant-info-meta dd { + margin: 2px 0 0; + overflow-wrap: anywhere; + color: #26362d; + font-size: 0.78rem; +} + .plant-form label, +.search-row label, .flag-assignment-form label { display: grid; gap: 4px; @@ -762,6 +834,7 @@ dd { .plant-form input, .plant-form select, .plant-form textarea, +.search-row input, .flag-assignment-form input, .flag-assignment-form select { width: 100%; @@ -1430,6 +1503,7 @@ dd { .detail-row, .plant-detail-grid, .plant-form, + .search-row, .resource-entry, .resource-amount, .activity-config-row, @@ -1443,6 +1517,10 @@ dd { grid-template-columns: 1fr; } + .plant-info-meta { + grid-template-columns: 1fr; + } + .recipe-table-row { grid-template-columns: 1fr; align-items: start; diff --git a/plant-manager-web/src/use-app-actions.ts b/plant-manager-web/src/use-app-actions.ts new file mode 100644 index 0000000..ab90fd9 --- /dev/null +++ b/plant-manager-web/src/use-app-actions.ts @@ -0,0 +1,761 @@ +import { useState } from 'react'; +import { + assignPlantFlag, + completeCareTasksBulk, + createActionResource, + createCareAction, + createCareActivity, + createPlant, + createPlantFlag, + createPlantGroup, + createPlantLocation, + createPlantTaxon, + createRecipe, + deleteActionResource, + deleteCareAction, + deleteCareActivity, + deletePlant, + deletePlantFlag, + deletePlantGroup, + deletePlantLocation, + deletePlantTaxon, + deleteRecipe, + downloadSpreadsheetExport, + importPlantTaxon, + removePlantCareSchedulesBulk, + removePlantFlagAssignment, + resolvePlantFlag, + savePlantCareSchedulesBulk, + searchPlantInfo, + updateActionResource, + updateCareAction, + updateCareActivity, + updatePlant, + updatePlantFlag, + updatePlantGroup, + updatePlantLocation, + updatePlantTaxon, + updateRecipe, +} from './api'; +import type { CareTask, Plant, PlantFlag, PlantInfoSearchResult } from './domain'; +import { + emptyBulkScheduleForm, + emptyPlantFlagForm, + toActionPayload, + toActivityPayload, + toBulkSchedulePayload, + toFlagDefinitionPayload, + toLocationPayload, + toPlantGroupPayload, + toPlantFlagPayload, + toPlantPayload, + toRecipePayload, + toResourcePayload, + toTaxonPayload, +} from './form-state'; +import type { useAppEditors } from './use-app-editors'; +import type { useDashboardData } from './use-dashboard-data'; + +type Editors = ReturnType; +type DashboardData = ReturnType; + +type AppActionOptions = { + editors: Editors; + loadCareModel: DashboardData['loadCareModel']; + loadFlagsAndPlants: DashboardData['loadFlagsAndPlants']; + loadGroupsAndPlants: DashboardData['loadGroupsAndPlants']; + loadLocations: DashboardData['loadLocations']; + loadPlants: DashboardData['loadPlants']; + loadPlantsAndCareTasks: DashboardData['loadPlantsAndCareTasks']; + loadRecipesAndResources: DashboardData['loadRecipesAndResources']; + loadTaxaAndPlants: DashboardData['loadTaxaAndPlants']; + setError: DashboardData['setError']; +}; + +export function useAppActions({ + editors, + loadCareModel, + loadFlagsAndPlants, + loadGroupsAndPlants, + loadLocations, + loadPlants, + loadPlantsAndCareTasks, + loadRecipesAndResources, + loadTaxaAndPlants, + setError, +}: AppActionOptions) { + const [isSaving, setIsSaving] = useState(false); + const [isExporting, setIsExporting] = useState(false); + const [isSearchingPlantInfo, setIsSearchingPlantInfo] = useState(false); + const [plantInfoQuery, setPlantInfoQuery] = useState(''); + const [plantInfoResults, setPlantInfoResults] = useState([]); + const [hasSearchedPlantInfo, setHasSearchedPlantInfo] = useState(false); + + async function savePlant() { + if (!editors.form.nickname.trim()) { + setError('Plant name is required.'); + return; + } + + setIsSaving(true); + try { + const payload = editors.editingPlantId === null + ? toPlantPayload(editors.form) + : { + ...toPlantPayload(editors.form), + taxonId: editors.activePlant?.taxonId ?? null, + locationId: editors.activePlant?.locationId ?? null, + }; + if (editors.editingPlantId === null) { + await createPlant(payload); + } else { + await updatePlant(editors.editingPlantId, payload); + } + editors.cancelEditing(); + await loadPlantsAndCareTasks(); + } catch { + setError('Could not save the plant.'); + } finally { + setIsSaving(false); + } + } + + async function updateManagedPlant(nextValues: { + taxonId?: number | null; + locationId?: number | null; + }) { + if (!editors.selectedPlant) { + return; + } + + setIsSaving(true); + try { + await updatePlant(editors.selectedPlant.id, { + nickname: editors.selectedPlant.nickname, + birthday: editors.selectedPlant.birthday, + taxonId: nextValues.taxonId === undefined ? editors.selectedPlant.taxonId : nextValues.taxonId, + locationId: nextValues.locationId === undefined ? editors.selectedPlant.locationId : nextValues.locationId, + careSchedules: null, + }); + await loadPlants(); + } catch { + setError('Could not update the plant assignment.'); + } finally { + setIsSaving(false); + } + } + + function setManagedPlantTaxon(taxonId: string) { + void updateManagedPlant({ taxonId: taxonId ? Number(taxonId) : null }); + } + + function setManagedPlantLocation(locationId: string) { + void updateManagedPlant({ locationId: locationId ? Number(locationId) : null }); + } + + async function removePlant(plant: Plant) { + const confirmed = window.confirm(`Delete ${plant.nickname}? This also removes its care log.`); + if (!confirmed) { + return; + } + + setIsSaving(true); + try { + await deletePlant(plant.id); + if (editors.selectedPlantId === plant.id) { + editors.setSelectedPlantId(null); + } + if (editors.editingPlantId === plant.id) { + editors.cancelEditing(); + } + await loadPlantsAndCareTasks(); + } catch { + setError('Could not delete the plant.'); + } finally { + setIsSaving(false); + } + } + + async function exportSpreadsheet() { + setIsExporting(true); + try { + setError(null); + await downloadSpreadsheetExport(); + } catch { + setError('Could not export the spreadsheet.'); + } finally { + setIsExporting(false); + } + } + + async function saveTaxon() { + if (!editors.taxonForm.name.trim() || !editors.taxonForm.genus.trim() || !editors.taxonForm.species.trim()) { + setError('Name, genus, and species are required.'); + return; + } + + setIsSaving(true); + try { + const payload = toTaxonPayload(editors.taxonForm); + if (editors.editingTaxonId === null) { + await createPlantTaxon(payload); + } else { + await updatePlantTaxon(editors.editingTaxonId, payload); + } + editors.cancelEditingTaxon(); + await loadTaxaAndPlants(); + } catch { + setError('Could not save the taxon.'); + } finally { + setIsSaving(false); + } + } + + async function searchTaxonInfo(queryOverride?: string) { + const query = queryOverride ?? plantInfoQuery; + setPlantInfoQuery(query); + + if (query.trim().length < 2) { + setError('Search needs at least 2 characters.'); + return; + } + + setIsSearchingPlantInfo(true); + try { + setError(null); + setHasSearchedPlantInfo(true); + setPlantInfoResults(await searchPlantInfo(query)); + } catch { + setError('Could not search offline plant info.'); + } finally { + setIsSearchingPlantInfo(false); + } + } + + async function importTaxonFromPlantInfo(result: PlantInfoSearchResult) { + setIsSaving(true); + try { + await importPlantTaxon(result); + setPlantInfoResults([]); + setPlantInfoQuery(''); + setHasSearchedPlantInfo(false); + await loadTaxaAndPlants(); + } catch { + setError('Could not import the GBIF taxon.'); + } finally { + setIsSaving(false); + } + } + + async function removeTaxon(taxon: Parameters[0]) { + const confirmed = window.confirm(`Delete ${taxon.name}? Taxa used by plants cannot be deleted.`); + if (!confirmed) { + return; + } + + setIsSaving(true); + try { + await deletePlantTaxon(taxon.id); + if (editors.editingTaxonId === taxon.id) { + editors.cancelEditingTaxon(); + } + await loadTaxaAndPlants(); + } catch { + setError('Could not delete the taxon. It may still be used by a plant.'); + } finally { + setIsSaving(false); + } + } + + async function saveLocation() { + if (!editors.locationForm.name.trim()) { + setError('Location name is required.'); + return; + } + + setIsSaving(true); + try { + const payload = toLocationPayload(editors.locationForm); + if (editors.editingLocationId === null) { + await createPlantLocation(payload); + } else { + await updatePlantLocation(editors.editingLocationId, payload); + } + editors.cancelEditingLocation(); + await loadLocations(); + } catch { + setError('Could not save the location.'); + } finally { + setIsSaving(false); + } + } + + async function removeLocation(location: Parameters[0]) { + const confirmed = window.confirm(`Delete ${location.name}? Locations assigned to plants cannot be deleted.`); + if (!confirmed) { + return; + } + + setIsSaving(true); + try { + await deletePlantLocation(location.id); + if (editors.editingLocationId === location.id) { + editors.cancelEditingLocation(); + } + await loadLocations(); + } catch { + await loadLocations(); + setError('Could not delete the location. It may still be assigned to a plant.'); + } finally { + setIsSaving(false); + } + } + + async function savePlantGroup() { + if (!editors.plantGroupForm.name.trim()) { + setError('Group name is required.'); + return; + } + + setIsSaving(true); + try { + const payload = toPlantGroupPayload(editors.plantGroupForm); + if (editors.editingPlantGroupId === null) { + await createPlantGroup(payload); + } else { + await updatePlantGroup(editors.editingPlantGroupId, payload); + } + editors.cancelEditingPlantGroup(); + await loadGroupsAndPlants(); + } catch { + setError('Could not save the plant group.'); + } finally { + setIsSaving(false); + } + } + + async function removePlantGroup(group: Parameters[0]) { + const confirmed = window.confirm(`Delete ${group.name}?`); + if (!confirmed) { + return; + } + + setIsSaving(true); + try { + await deletePlantGroup(group.id); + if (editors.editingPlantGroupId === group.id) { + editors.cancelEditingPlantGroup(); + } + await loadGroupsAndPlants(); + } catch { + setError('Could not delete the plant group.'); + } finally { + setIsSaving(false); + } + } + + async function saveAction() { + if (!editors.actionForm.name.trim()) { + setError('Action name is required.'); + return; + } + + setIsSaving(true); + try { + const payload = toActionPayload(editors.actionForm); + if (editors.editingActionId === null) { + await createCareAction(payload); + } else { + await updateCareAction(editors.editingActionId, payload); + } + editors.cancelEditingAction(); + await loadCareModel(); + } catch { + setError('Could not save the action.'); + } finally { + setIsSaving(false); + } + } + + async function removeAction(action: Parameters[0]) { + const confirmed = window.confirm(`Delete ${action.name}? Actions with care history cannot be deleted.`); + if (!confirmed) { + return; + } + + setIsSaving(true); + try { + await deleteCareAction(action.id); + if (editors.editingActionId === action.id) { + editors.cancelEditingAction(); + } + await loadCareModel(); + } catch { + await loadCareModel(); + setError('Could not delete the action. It may still have care history.'); + } finally { + setIsSaving(false); + } + } + + async function saveResource() { + if (!editors.resourceForm.name.trim()) { + setError('Resource name is required.'); + return; + } + + setIsSaving(true); + try { + const payload = toResourcePayload(editors.resourceForm); + if (editors.editingResourceId === null) { + await createActionResource(payload); + } else { + await updateActionResource(editors.editingResourceId, payload); + } + editors.cancelEditingResource(); + await loadCareModel(); + await loadRecipesAndResources(); + } catch { + setError('Could not save the resource.'); + } finally { + setIsSaving(false); + } + } + + async function removeResource(resource: Parameters[0]) { + const confirmed = window.confirm(`Delete ${resource.name}?`); + if (!confirmed) { + return; + } + + setIsSaving(true); + try { + await deleteActionResource(resource.id); + if (editors.editingResourceId === resource.id) { + editors.cancelEditingResource(); + } + await loadCareModel(); + await loadRecipesAndResources(); + } catch { + setError('Could not delete the resource.'); + } finally { + setIsSaving(false); + } + } + + async function saveRecipe() { + if (!editors.recipeForm.name.trim() || !editors.recipeForm.type.trim() || !editors.recipeForm.outputResourceName.trim() || editors.recipeForm.components.length === 0) { + setError('Recipe name, type, produced resource, and at least one component are required.'); + return; + } + + setIsSaving(true); + try { + const payload = toRecipePayload(editors.recipeForm); + if (editors.editingRecipeId === null) { + await createRecipe(payload); + } else { + await updateRecipe(editors.editingRecipeId, payload); + } + editors.cancelEditingRecipe(); + await loadRecipesAndResources(); + } catch { + setError('Could not save the recipe.'); + } finally { + setIsSaving(false); + } + } + + async function removeRecipe(recipe: Parameters[0]) { + const confirmed = window.confirm(`Delete ${recipe.name}?`); + if (!confirmed) { + return; + } + + setIsSaving(true); + try { + await deleteRecipe(recipe.id); + if (editors.selectedRecipeId === recipe.id) { + editors.setSelectedRecipeId(null); + } + if (editors.editingRecipeId === recipe.id) { + editors.cancelEditingRecipe(); + } + await loadRecipesAndResources(); + } catch { + setError('Could not delete the recipe.'); + } finally { + setIsSaving(false); + } + } + + async function saveActivity() { + if (!editors.activityForm.name.trim() || editors.activityForm.actions.length === 0) { + setError('Activity name and at least one action are required.'); + return; + } + + setIsSaving(true); + try { + const payload = toActivityPayload(editors.activityForm); + if (editors.editingActivityId === null) { + await createCareActivity(payload); + } else { + await updateCareActivity(editors.editingActivityId, payload); + } + editors.cancelEditingActivity(); + await loadCareModel(); + } catch { + setError('Could not save the activity.'); + } finally { + setIsSaving(false); + } + } + + async function removeActivity(activity: Parameters[0]) { + const confirmed = window.confirm(`Delete ${activity.name}? Activities in use cannot be deleted.`); + if (!confirmed) { + return; + } + + setIsSaving(true); + try { + await deleteCareActivity(activity.id); + if (editors.editingActivityId === activity.id) { + editors.cancelEditingActivity(); + } + await loadCareModel(); + } catch { + await loadCareModel(); + setError('Could not delete the activity. It may still be in use.'); + } finally { + setIsSaving(false); + } + } + + async function saveFlagDefinition() { + if (!editors.flagDefinitionForm.name.trim()) { + setError('Flag name is required.'); + return; + } + + setIsSaving(true); + try { + const payload = toFlagDefinitionPayload(editors.flagDefinitionForm); + if (editors.editingFlagDefinitionId === null) { + await createPlantFlag(payload); + } else { + await updatePlantFlag(editors.editingFlagDefinitionId, payload); + } + editors.cancelEditingFlagDefinition(); + await loadFlagsAndPlants(); + } catch { + setError('Could not save the plant flag.'); + } finally { + setIsSaving(false); + } + } + + async function saveBulkSchedule() { + if (!editors.bulkScheduleForm.careActivityId || editors.bulkScheduleForm.plantIds.length === 0) { + setError('Select an activity and at least one plant.'); + return; + } + + setIsSaving(true); + try { + await savePlantCareSchedulesBulk(toBulkSchedulePayload(editors.bulkScheduleForm)); + editors.setBulkScheduleForm(emptyBulkScheduleForm); + await loadPlantsAndCareTasks(); + } catch { + setError('Could not apply the care schedule.'); + } finally { + setIsSaving(false); + } + } + + async function removeBulkSchedule() { + if (!editors.bulkScheduleForm.careActivityId || editors.bulkScheduleForm.plantIds.length === 0) { + setError('Select an activity and at least one plant.'); + return; + } + + setIsSaving(true); + try { + await removePlantCareSchedulesBulk(toBulkSchedulePayload(editors.bulkScheduleForm)); + editors.setBulkScheduleForm(emptyBulkScheduleForm); + await loadPlantsAndCareTasks(); + } catch { + setError('Could not remove the care schedule.'); + } finally { + setIsSaving(false); + } + } + + async function removeFlagDefinition(flag: Parameters[0]) { + const confirmed = window.confirm(`Delete ${flag.name}? Flags assigned to plants cannot be deleted.`); + if (!confirmed) { + return; + } + + setIsSaving(true); + try { + await deletePlantFlag(flag.id); + if (editors.editingFlagDefinitionId === flag.id) { + editors.cancelEditingFlagDefinition(); + } + await loadFlagsAndPlants(); + } catch { + await loadFlagsAndPlants(); + setError('Could not delete the flag. It may still be assigned to a plant.'); + } finally { + setIsSaving(false); + } + } + + async function assignFlagToSelectedPlant() { + if (!editors.selectedPlantId || !editors.plantFlagForm.plantFlagDefinitionId) { + setError('Select a plant and flag first.'); + return; + } + + setIsSaving(true); + try { + await assignPlantFlag(editors.selectedPlantId, toPlantFlagPayload(editors.plantFlagForm)); + editors.setPlantFlagForm(emptyPlantFlagForm); + await loadPlants(); + } catch { + setError('Could not attach the plant flag.'); + } finally { + setIsSaving(false); + } + } + + async function resolveAssignedPlantFlag(flag: PlantFlag) { + if (!editors.selectedPlantId) { + return; + } + + setIsSaving(true); + try { + await resolvePlantFlag(editors.selectedPlantId, flag.id); + await loadPlants(); + } catch { + setError('Could not resolve the plant flag.'); + } finally { + setIsSaving(false); + } + } + + async function removeAssignedPlantFlag(flag: PlantFlag) { + if (!editors.selectedPlantId) { + return; + } + + const confirmed = window.confirm(`Remove ${flag.name} from this plant?`); + if (!confirmed) { + return; + } + + setIsSaving(true); + try { + await removePlantFlagAssignment(editors.selectedPlantId, flag.id); + await loadPlants(); + } catch { + setError('Could not remove the plant flag.'); + } finally { + setIsSaving(false); + } + } + + async function completeTask(task: CareTask) { + setIsSaving(true); + try { + await completeCareTasksBulk({ + careActivityId: task.careActivityId, + plantIds: [task.plantId], + performedOn: getTodayInputDate(), + notes: '', + resources: [], + }); + await loadPlantsAndCareTasks(); + } catch { + setError('Could not log the care task.'); + } finally { + setIsSaving(false); + } + } + + async function completeBulkTasks(tasks: CareTask[]) { + const dueTasks = tasks.filter((task) => task.status === 'due'); + if (dueTasks.length === 0) { + return; + } + + const action = dueTasks[0].action; + const confirmed = window.confirm(`Log ${action} for ${dueTasks.length} due plants?`); + if (!confirmed) { + return; + } + + setIsSaving(true); + try { + await completeCareTasksBulk({ + careActivityId: dueTasks[0].careActivityId, + plantIds: dueTasks.map((task) => task.plantId), + performedOn: getTodayInputDate(), + notes: '', + resources: [], + }); + await loadPlantsAndCareTasks(); + } catch { + setError('Could not log the due tasks.'); + } finally { + setIsSaving(false); + } + } + + return { + assignFlagToSelectedPlant, + completeBulkTasks, + completeTask, + exportSpreadsheet, + hasSearchedPlantInfo, + isExporting, + isSearchingPlantInfo, + isSaving, + importTaxonFromPlantInfo, + plantInfoQuery, + plantInfoResults, + removeAction, + removeActivity, + removeAssignedPlantFlag, + removeBulkSchedule, + removeFlagDefinition, + removeLocation, + removePlant, + removePlantGroup, + removeRecipe, + removeResource, + removeTaxon, + resolveAssignedPlantFlag, + saveAction, + saveActivity, + saveBulkSchedule, + saveFlagDefinition, + saveLocation, + savePlant, + savePlantGroup, + saveRecipe, + saveResource, + saveTaxon, + searchTaxonInfo, + setManagedPlantLocation, + setManagedPlantTaxon, + setPlantInfoQuery, + }; +} + +function getTodayInputDate() { + return new Date().toISOString().slice(0, 10); +} diff --git a/plant-manager-web/src/use-app-editors.ts b/plant-manager-web/src/use-app-editors.ts new file mode 100644 index 0000000..46a4f72 --- /dev/null +++ b/plant-manager-web/src/use-app-editors.ts @@ -0,0 +1,502 @@ +import { useState } from 'react'; +import type { + ActionResource, + CareActivity, + CareAction, + Plant, + PlantInfoSearchResult, + PlantFlagDefinition, + PlantGroup, + PlantLocation, + PlantTaxon, + Recipe, +} from './domain'; +import { + emptyActionForm, + emptyActivityForm, + emptyBulkScheduleForm, + emptyFlagDefinitionForm, + emptyLocationForm, + emptyPlantForm, + emptyPlantGroupForm, + emptyPlantFlagForm, + emptyRecipeForm, + emptyResourceForm, + emptyTaxonForm, + toActionForm, + toActivityForm, + toFlagDefinitionForm, + toLocationForm, + toPlantForm, + toPlantGroupForm, + toRecipeForm, + toResourceForm, + toTaxonForm, + toTaxonFormFromPlantInfo, + type ActionFormState, + type ActivityFormState, + type BulkScheduleFormState, + type FlagDefinitionFormState, + type LocationFormState, + type PlantFormState, + type PlantGroupFormState, + type PlantFlagFormState, + type RecipeFormState, + type ResourceFormState, + type TaxonFormState, + type View, +} from './form-state'; + +type AppEditorData = { + actionResources: ActionResource[]; + careActions: CareAction[]; + careActivities: CareActivity[]; + plantFlagDefinitions: PlantFlagDefinition[]; + plantGroups: PlantGroup[]; + plantLocations: PlantLocation[]; + plantTaxa: PlantTaxon[]; + plants: Plant[]; + recipes: Recipe[]; +}; + +export function useAppEditors(data: AppEditorData, setView: (view: View) => void) { + const [editingPlantId, setEditingPlantId] = useState(null); + const [editingTaxonId, setEditingTaxonId] = useState(null); + const [editingLocationId, setEditingLocationId] = useState(null); + const [editingPlantGroupId, setEditingPlantGroupId] = useState(null); + const [editingActionId, setEditingActionId] = useState(null); + const [editingResourceId, setEditingResourceId] = useState(null); + const [editingRecipeId, setEditingRecipeId] = useState(null); + const [editingActivityId, setEditingActivityId] = useState(null); + const [editingFlagDefinitionId, setEditingFlagDefinitionId] = useState(null); + const [isPlantEditorOpen, setIsPlantEditorOpen] = useState(false); + const [isTaxonEditorOpen, setIsTaxonEditorOpen] = useState(false); + const [isLocationEditorOpen, setIsLocationEditorOpen] = useState(false); + const [isPlantGroupEditorOpen, setIsPlantGroupEditorOpen] = useState(false); + const [isActionEditorOpen, setIsActionEditorOpen] = useState(false); + const [isResourceEditorOpen, setIsResourceEditorOpen] = useState(false); + const [isRecipeEditorOpen, setIsRecipeEditorOpen] = useState(false); + const [isActivityEditorOpen, setIsActivityEditorOpen] = useState(false); + const [isFlagDefinitionEditorOpen, setIsFlagDefinitionEditorOpen] = useState(false); + const [selectedPlantId, setSelectedPlantId] = useState(null); + const [selectedTaxonId, setSelectedTaxonId] = useState(null); + const [selectedLocationId, setSelectedLocationId] = useState(null); + const [selectedActionId, setSelectedActionId] = useState(null); + const [selectedResourceId, setSelectedResourceId] = useState(null); + const [selectedRecipeId, setSelectedRecipeId] = useState(null); + const [selectedActivityId, setSelectedActivityId] = useState(null); + const [selectedFlagDefinitionId, setSelectedFlagDefinitionId] = useState(null); + const [form, setForm] = useState(emptyPlantForm); + const [taxonForm, setTaxonForm] = useState(emptyTaxonForm); + const [locationForm, setLocationForm] = useState(emptyLocationForm); + const [plantGroupForm, setPlantGroupForm] = useState(emptyPlantGroupForm); + const [actionForm, setActionForm] = useState(emptyActionForm); + const [resourceForm, setResourceForm] = useState(emptyResourceForm); + const [recipeForm, setRecipeForm] = useState(emptyRecipeForm); + const [activityForm, setActivityForm] = useState(emptyActivityForm); + const [flagDefinitionForm, setFlagDefinitionForm] = useState(emptyFlagDefinitionForm); + const [plantFlagForm, setPlantFlagForm] = useState(emptyPlantFlagForm); + const [bulkScheduleForm, setBulkScheduleForm] = useState(emptyBulkScheduleForm); + + const activePlant = data.plants.find((plant) => plant.id === editingPlantId); + const selectedPlant = data.plants.find((plant) => plant.id === selectedPlantId); + const activeTaxon = data.plantTaxa.find((taxon) => taxon.id === editingTaxonId); + const selectedTaxon = data.plantTaxa.find((taxon) => taxon.id === selectedTaxonId); + const activeLocation = data.plantLocations.find((location) => location.id === editingLocationId); + const selectedLocation = data.plantLocations.find((location) => location.id === selectedLocationId); + const activePlantGroup = data.plantGroups.find((group) => group.id === editingPlantGroupId); + const activeAction = data.careActions.find((action) => action.id === editingActionId); + const selectedAction = data.careActions.find((action) => action.id === selectedActionId); + const activeResource = data.actionResources.find((resource) => resource.id === editingResourceId); + const selectedResource = data.actionResources.find((resource) => resource.id === selectedResourceId); + const activeRecipe = data.recipes.find((recipe) => recipe.id === editingRecipeId); + const selectedRecipe = data.recipes.find((recipe) => recipe.id === selectedRecipeId); + const activeActivity = data.careActivities.find((activity) => activity.id === editingActivityId); + const selectedActivity = data.careActivities.find((activity) => activity.id === selectedActivityId); + const activeFlagDefinition = data.plantFlagDefinitions.find((flag) => flag.id === editingFlagDefinitionId); + const selectedFlagDefinition = data.plantFlagDefinitions.find((flag) => flag.id === selectedFlagDefinitionId); + + function updateForm(field: keyof PlantFormState, value: string) { + setForm((current) => ({ ...current, [field]: value })); + } + + function updateTaxonForm(field: keyof TaxonFormState, value: string) { + setTaxonForm((current) => ({ ...current, [field]: value })); + } + + function updateLocationForm(field: keyof LocationFormState, value: string) { + setLocationForm((current) => ({ ...current, [field]: value })); + } + + function updatePlantGroupForm(field: keyof PlantGroupFormState, value: string | string[]) { + setPlantGroupForm((current) => ({ ...current, [field]: value })); + } + + function updateActionForm(field: keyof ActionFormState, value: string) { + setActionForm((current) => ({ ...current, [field]: value })); + } + + function updateResourceForm(field: keyof ResourceFormState, value: string) { + setResourceForm((current) => ({ ...current, [field]: value })); + } + + function updateActivityForm(field: keyof ActivityFormState, value: ActivityFormState[keyof ActivityFormState]) { + setActivityForm((current) => ({ ...current, [field]: value })); + } + + function updateRecipeForm(field: keyof RecipeFormState, value: RecipeFormState[keyof RecipeFormState]) { + setRecipeForm((current) => ({ ...current, [field]: value })); + } + + function updateFlagDefinitionForm(field: keyof FlagDefinitionFormState, value: string) { + setFlagDefinitionForm((current) => ({ ...current, [field]: value })); + } + + function updatePlantFlagForm(field: keyof PlantFlagFormState, value: string) { + setPlantFlagForm((current) => ({ ...current, [field]: value })); + } + + function updateBulkScheduleForm( + field: keyof BulkScheduleFormState, + value: string | string[], + ) { + setBulkScheduleForm((current) => ({ ...current, [field]: value })); + } + + function startAddingPlant() { + setEditingPlantId(null); + setSelectedPlantId(null); + setForm(emptyPlantForm); + setIsPlantEditorOpen(true); + setView('plants'); + } + + function openPlantDetail(plant: Plant) { + setSelectedPlantId(plant.id); + setEditingPlantId(null); + setForm(emptyPlantForm); + setIsPlantEditorOpen(false); + setView('plant-management'); + } + + function startEditingPlant(plant: Plant) { + setSelectedPlantId(null); + setEditingPlantId(plant.id); + setForm(toPlantForm(plant)); + setIsPlantEditorOpen(true); + setView('plants'); + } + + function cancelEditing() { + setEditingPlantId(null); + setForm(emptyPlantForm); + setIsPlantEditorOpen(false); + } + + function openPlantReadOnlyDetail(plant: Plant) { + setSelectedPlantId(plant.id); + setEditingPlantId(null); + setForm(emptyPlantForm); + setIsPlantEditorOpen(false); + setView('plants'); + } + + function startAddingTaxon() { + setEditingTaxonId(null); + setSelectedTaxonId(null); + setTaxonForm(emptyTaxonForm); + setIsTaxonEditorOpen(true); + setView('taxa'); + } + + function startEditingTaxon(taxon: PlantTaxon) { + setSelectedTaxonId(null); + setEditingTaxonId(taxon.id); + setTaxonForm(toTaxonForm(taxon)); + setIsTaxonEditorOpen(true); + setView('taxa'); + } + + function startAddingTaxonFromPlantInfo(result: PlantInfoSearchResult) { + setEditingTaxonId(null); + setSelectedTaxonId(null); + setTaxonForm(toTaxonFormFromPlantInfo(result)); + setIsTaxonEditorOpen(true); + setView('taxa'); + } + + function cancelEditingTaxon() { + setEditingTaxonId(null); + setTaxonForm(emptyTaxonForm); + setIsTaxonEditorOpen(false); + } + + function startAddingLocation() { + setEditingLocationId(null); + setSelectedLocationId(null); + setLocationForm(emptyLocationForm); + setIsLocationEditorOpen(true); + setView('locations'); + } + + function startEditingLocation(location: PlantLocation) { + setSelectedLocationId(null); + setEditingLocationId(location.id); + setLocationForm(toLocationForm(location)); + setIsLocationEditorOpen(true); + setView('locations'); + } + + function cancelEditingLocation() { + setEditingLocationId(null); + setLocationForm(emptyLocationForm); + setIsLocationEditorOpen(false); + } + + function startAddingPlantGroup() { + setEditingPlantGroupId(null); + setPlantGroupForm(emptyPlantGroupForm); + setIsPlantGroupEditorOpen(true); + setView('groups'); + } + + function startEditingPlantGroup(group: PlantGroup) { + setEditingPlantGroupId(group.id); + setPlantGroupForm(toPlantGroupForm(group)); + setIsPlantGroupEditorOpen(true); + setView('groups'); + } + + function cancelEditingPlantGroup() { + setEditingPlantGroupId(null); + setPlantGroupForm(emptyPlantGroupForm); + setIsPlantGroupEditorOpen(false); + } + + function startAddingAction() { + setEditingActionId(null); + setSelectedActionId(null); + setActionForm(emptyActionForm); + setIsActionEditorOpen(true); + setView('actions'); + } + + function startEditingAction(action: CareAction) { + setSelectedActionId(null); + setEditingActionId(action.id); + setActionForm(toActionForm(action)); + setIsActionEditorOpen(true); + setView('actions'); + } + + function cancelEditingAction() { + setEditingActionId(null); + setActionForm(emptyActionForm); + setIsActionEditorOpen(false); + } + + function startAddingResource() { + setEditingResourceId(null); + setSelectedResourceId(null); + setResourceForm(emptyResourceForm); + setIsResourceEditorOpen(true); + setView('resources'); + } + + function startEditingResource(resource: ActionResource) { + setSelectedResourceId(null); + setEditingResourceId(resource.id); + setResourceForm(toResourceForm(resource)); + setIsResourceEditorOpen(true); + setView('resources'); + } + + function cancelEditingResource() { + setEditingResourceId(null); + setResourceForm(emptyResourceForm); + setIsResourceEditorOpen(false); + } + + function startAddingRecipe() { + setEditingRecipeId(null); + setSelectedRecipeId(null); + setRecipeForm(emptyRecipeForm); + setIsRecipeEditorOpen(true); + setView('recipes'); + } + + function startEditingRecipe(recipe: Recipe) { + setSelectedRecipeId(null); + setEditingRecipeId(recipe.id); + setRecipeForm(toRecipeForm(recipe)); + setIsRecipeEditorOpen(true); + setView('recipes'); + } + + function cancelEditingRecipe() { + setEditingRecipeId(null); + setRecipeForm(emptyRecipeForm); + setIsRecipeEditorOpen(false); + } + + function startAddingActivity() { + setEditingActivityId(null); + setSelectedActivityId(null); + setActivityForm(emptyActivityForm); + setIsActivityEditorOpen(true); + setView('activities'); + } + + function startEditingActivity(activity: CareActivity) { + setSelectedActivityId(null); + setEditingActivityId(activity.id); + setActivityForm(toActivityForm(activity)); + setIsActivityEditorOpen(true); + setView('activities'); + } + + function cancelEditingActivity() { + setEditingActivityId(null); + setActivityForm(emptyActivityForm); + setIsActivityEditorOpen(false); + } + + function startAddingFlagDefinition() { + setEditingFlagDefinitionId(null); + setSelectedFlagDefinitionId(null); + setFlagDefinitionForm(emptyFlagDefinitionForm); + setIsFlagDefinitionEditorOpen(true); + setView('flags'); + } + + function startEditingFlagDefinition(flag: PlantFlagDefinition) { + setSelectedFlagDefinitionId(null); + setEditingFlagDefinitionId(flag.id); + setFlagDefinitionForm(toFlagDefinitionForm(flag)); + setIsFlagDefinitionEditorOpen(true); + setView('flags'); + } + + function cancelEditingFlagDefinition() { + setEditingFlagDefinitionId(null); + setFlagDefinitionForm(emptyFlagDefinitionForm); + setIsFlagDefinitionEditorOpen(false); + } + + function selectManagedPlant(plantId: string) { + setSelectedPlantId(plantId ? Number(plantId) : null); + setPlantFlagForm(emptyPlantFlagForm); + } + + return { + actionForm, + activeAction, + activeActivity, + activeFlagDefinition, + activeLocation, + activePlant, + activePlantGroup, + activeRecipe, + activeResource, + activeTaxon, + activityForm, + bulkScheduleForm, + cancelEditing, + cancelEditingAction, + cancelEditingActivity, + cancelEditingFlagDefinition, + cancelEditingLocation, + cancelEditingPlantGroup, + cancelEditingRecipe, + cancelEditingResource, + cancelEditingTaxon, + editingActionId, + editingActivityId, + editingFlagDefinitionId, + editingLocationId, + editingPlantGroupId, + editingPlantId, + editingRecipeId, + editingResourceId, + editingTaxonId, + flagDefinitionForm, + form, + isActionEditorOpen, + isActivityEditorOpen, + isFlagDefinitionEditorOpen, + isLocationEditorOpen, + isPlantEditorOpen, + isPlantGroupEditorOpen, + isRecipeEditorOpen, + isResourceEditorOpen, + isTaxonEditorOpen, + locationForm, + openPlantDetail, + openPlantReadOnlyDetail, + plantFlagForm, + plantGroupForm, + recipeForm, + resourceForm, + selectedAction, + selectedActivity, + selectedFlagDefinition, + selectedLocation, + selectedPlant, + selectedPlantId, + selectedRecipe, + selectedRecipeId, + selectedResource, + selectedTaxon, + selectManagedPlant, + setBulkScheduleForm, + setEditingActionId, + setEditingActivityId, + setEditingFlagDefinitionId, + setEditingLocationId, + setEditingPlantGroupId, + setEditingPlantId, + setEditingRecipeId, + setEditingResourceId, + setEditingTaxonId, + setPlantFlagForm, + setSelectedActionId, + setSelectedActivityId, + setSelectedFlagDefinitionId, + setSelectedLocationId, + setSelectedPlantId, + setSelectedRecipeId, + setSelectedResourceId, + setSelectedTaxonId, + startAddingAction, + startAddingActivity, + startAddingFlagDefinition, + startAddingLocation, + startAddingPlant, + startAddingPlantGroup, + startAddingRecipe, + startAddingResource, + startAddingTaxon, + startAddingTaxonFromPlantInfo, + startEditingAction, + startEditingActivity, + startEditingFlagDefinition, + startEditingLocation, + startEditingPlant, + startEditingPlantGroup, + startEditingRecipe, + startEditingResource, + startEditingTaxon, + taxonForm, + updateActionForm, + updateActivityForm, + updateBulkScheduleForm, + updateFlagDefinitionForm, + updateForm, + updateLocationForm, + updatePlantFlagForm, + updatePlantGroupForm, + updateRecipeForm, + updateResourceForm, + updateTaxonForm, + }; +} diff --git a/plant-manager-web/src/use-dashboard-data.ts b/plant-manager-web/src/use-dashboard-data.ts new file mode 100644 index 0000000..7550929 --- /dev/null +++ b/plant-manager-web/src/use-dashboard-data.ts @@ -0,0 +1,215 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + getActionResources, + getCareActivities, + getCareActions, + getCareTasks, + getPlants, + getPlantFlags, + getPlantGroups, + getPlantLocations, + getPlantTaxa, + getRecipes, +} from './api'; +import type { + ActionResource, + CareActivity, + CareAction, + CareTask, + Plant, + PlantFlagDefinition, + PlantGroup, + PlantLocation, + PlantTaxon, + Recipe, +} from './domain'; + +export function useDashboardData() { + const [plants, setPlants] = useState([]); + const [plantTaxa, setPlantTaxa] = useState([]); + const [plantLocations, setPlantLocations] = useState([]); + const [plantGroups, setPlantGroups] = useState([]); + const [careActions, setCareActions] = useState([]); + const [actionResources, setActionResources] = useState([]); + const [recipes, setRecipes] = useState([]); + const [careActivities, setCareActivities] = useState([]); + const [plantFlagDefinitions, setPlantFlagDefinitions] = useState([]); + const [careTasks, setCareTasks] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + async function loadDashboard() { + try { + const [ + plantsResponse, + tasksResponse, + taxaResponse, + locationsResponse, + groupsResponse, + actionsResponse, + resourcesResponse, + recipesResponse, + activitiesResponse, + flagsResponse, + ] = await Promise.all([ + getPlants(), + getCareTasks(), + getPlantTaxa(), + getPlantLocations(), + getPlantGroups(), + getCareActions(), + getActionResources(), + getRecipes(), + getCareActivities(), + getPlantFlags(), + ]); + + setError(null); + setPlants(plantsResponse); + setCareTasks(tasksResponse); + setPlantTaxa(taxaResponse); + setPlantLocations(locationsResponse); + setPlantGroups(groupsResponse); + setCareActions(actionsResponse); + setActionResources(resourcesResponse); + setRecipes(recipesResponse); + setCareActivities(activitiesResponse); + setPlantFlagDefinitions(flagsResponse); + } catch { + setError('Could not reach the Plant-Man API. Start the backend and refresh.'); + } finally { + setIsLoading(false); + } + } + + async function loadLocations() { + const locationsResponse = await getPlantLocations(); + + setError(null); + setPlantLocations(locationsResponse); + } + + async function loadGroupsAndPlants() { + const [groupsResponse, plantsResponse] = await Promise.all([ + getPlantGroups(), + getPlants(), + ]); + + setError(null); + setPlantGroups(groupsResponse); + setPlants(plantsResponse); + } + + async function loadPlants() { + const plantsResponse = await getPlants(); + + setError(null); + setPlants(plantsResponse); + } + + async function loadPlantsAndCareTasks() { + const [plantsResponse, tasksResponse] = await Promise.all([ + getPlants(), + getCareTasks(), + ]); + + setError(null); + setPlants(plantsResponse); + setCareTasks(tasksResponse); + } + + async function loadTaxaAndPlants() { + const [plantsResponse, taxaResponse] = await Promise.all([ + getPlants(), + getPlantTaxa(), + ]); + + setError(null); + setPlants(plantsResponse); + setPlantTaxa(taxaResponse); + } + + async function loadFlagsAndPlants() { + const [plantsResponse, flagsResponse] = await Promise.all([ + getPlants(), + getPlantFlags(), + ]); + + setError(null); + setPlants(plantsResponse); + setPlantFlagDefinitions(flagsResponse); + } + + async function loadCareModel() { + const [ + plantsResponse, + tasksResponse, + actionsResponse, + resourcesResponse, + recipesResponse, + activitiesResponse, + ] = await Promise.all([ + getPlants(), + getCareTasks(), + getCareActions(), + getActionResources(), + getRecipes(), + getCareActivities(), + ]); + + setError(null); + setPlants(plantsResponse); + setCareTasks(tasksResponse); + setCareActions(actionsResponse); + setActionResources(resourcesResponse); + setRecipes(recipesResponse); + setCareActivities(activitiesResponse); + } + + async function loadRecipesAndResources() { + const [recipesResponse, resourcesResponse] = await Promise.all([ + getRecipes(), + getActionResources(), + ]); + + setError(null); + setRecipes(recipesResponse); + setActionResources(resourcesResponse); + } + + useEffect(() => { + queueMicrotask(() => { + void loadDashboard(); + }); + }, []); + + const dueCount = useMemo( + () => careTasks.filter((task) => task.status === 'due').length, + [careTasks], + ); + + return { + actionResources, + careActions, + careActivities, + careTasks, + dueCount, + error, + isLoading, + loadCareModel, + loadFlagsAndPlants, + loadGroupsAndPlants, + loadLocations, + loadPlants, + loadPlantsAndCareTasks, + loadRecipesAndResources, + loadTaxaAndPlants, + plantFlagDefinitions, + plantGroups, + plantLocations, + plantTaxa, + plants, + recipes, + setError, + }; +} diff --git a/plant-manager.sln b/plant-manager.sln index 679359d..359773e 100644 --- a/plant-manager.sln +++ b/plant-manager.sln @@ -1,25 +1,31 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.14.36414.22 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "plant-manager", "plant-manager\plant-manager.csproj", "{9623820E-0AE2-4500-80AD-85239C402D9D}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {9623820E-0AE2-4500-80AD-85239C402D9D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {9623820E-0AE2-4500-80AD-85239C402D9D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {9623820E-0AE2-4500-80AD-85239C402D9D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {9623820E-0AE2-4500-80AD-85239C402D9D}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {BA0B2435-3E48-42D6-94FE-8B691EA47558} - EndGlobalSection -EndGlobal + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36414.22 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "plant-manager", "plant-manager\plant-manager.csproj", "{9623820E-0AE2-4500-80AD-85239C402D9D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "plant-manager-importer", "plant-manager-importer\plant-manager-importer.csproj", "{C076A21A-59C8-4423-9DE7-AC6316B37E8D}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {9623820E-0AE2-4500-80AD-85239C402D9D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9623820E-0AE2-4500-80AD-85239C402D9D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9623820E-0AE2-4500-80AD-85239C402D9D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9623820E-0AE2-4500-80AD-85239C402D9D}.Release|Any CPU.Build.0 = Release|Any CPU + {C076A21A-59C8-4423-9DE7-AC6316B37E8D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C076A21A-59C8-4423-9DE7-AC6316B37E8D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C076A21A-59C8-4423-9DE7-AC6316B37E8D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C076A21A-59C8-4423-9DE7-AC6316B37E8D}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {BA0B2435-3E48-42D6-94FE-8B691EA47558} + EndGlobalSection +EndGlobal diff --git a/plant-manager/Contracts.cs b/plant-manager/Contracts.cs index b308560..fa49d2f 100644 --- a/plant-manager/Contracts.cs +++ b/plant-manager/Contracts.cs @@ -47,7 +47,23 @@ namespace plant_manager string Species, string? Cultivar, string? Variety, - string? Authority); + string? Authority, + string? Family, + string? CommonName, + string? ExternalSource, + string? ExternalId); + + public record ImportPlantTaxonRequest( + string Source, + string ExternalId, + string ScientificName, + string? CanonicalName, + string? CommonName, + string? Rank, + string? Status, + string? Family, + string? Genus, + string? Species); public record SavePlantLocationRequest( string Name, @@ -178,7 +194,11 @@ namespace plant_manager string Species, string? Cultivar, string? Variety, - string? Authority) + string? Authority, + string? Family, + string? CommonName, + string? ExternalSource, + string? ExternalId) { public static PlantTaxonDto FromTaxon(PlantTaxon taxon) => new( @@ -188,7 +208,11 @@ namespace plant_manager taxon.Species, taxon.Cultivar, taxon.Variety, - taxon.Authority); + taxon.Authority, + taxon.Family, + taxon.CommonName, + taxon.ExternalSource, + taxon.ExternalId); } public record PlantLocationDto( diff --git a/plant-manager/Data/ApplicationDbContext.cs b/plant-manager/Data/ApplicationDbContext.cs index 755ac46..20b2d59 100644 --- a/plant-manager/Data/ApplicationDbContext.cs +++ b/plant-manager/Data/ApplicationDbContext.cs @@ -38,6 +38,11 @@ namespace plant_manager.Data entity.Property(e => e.Cultivar).HasMaxLength(120); entity.Property(e => e.Variety).HasMaxLength(120); entity.Property(e => e.Authority).HasMaxLength(120); + entity.Property(e => e.Family).HasMaxLength(120); + entity.Property(e => e.CommonName).HasMaxLength(120); + entity.Property(e => e.ExternalSource).HasMaxLength(40); + entity.Property(e => e.ExternalId).HasMaxLength(80); + entity.HasIndex(e => new { e.ExternalSource, e.ExternalId }).IsUnique(false); }); modelBuilder.Entity(entity => diff --git a/plant-manager/Data/Migrations/20260604231106_AddGbifPlantTaxonMetadata.Designer.cs b/plant-manager/Data/Migrations/20260604231106_AddGbifPlantTaxonMetadata.Designer.cs new file mode 100644 index 0000000..0d2614e --- /dev/null +++ b/plant-manager/Data/Migrations/20260604231106_AddGbifPlantTaxonMetadata.Designer.cs @@ -0,0 +1,807 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using plant_manager.Data; + +#nullable disable + +namespace plant_manager.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260604231106_AddGbifPlantTaxonMetadata")] + partial class AddGbifPlantTaxonMetadata + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); + + modelBuilder.Entity("plant_manager.Data.Models.ActionLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActionNameSnapshot") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("CareActionId") + .HasColumnType("INTEGER"); + + b.Property("CareActivityId") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("PerformedOn") + .HasColumnType("TEXT"); + + b.Property("PlantId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CareActionId"); + + b.HasIndex("CareActivityId"); + + b.HasIndex("PlantId"); + + b.ToTable("ActionLogs"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.ActionLogResource", b => + { + b.Property("ActionLogId") + .HasColumnType("INTEGER"); + + b.Property("ActionResourceId") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasPrecision(10, 2) + .HasColumnType("TEXT"); + + b.Property("Unit") + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.HasKey("ActionLogId", "ActionResourceId"); + + b.HasIndex("ActionResourceId"); + + b.ToTable("ActionLogResources"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.ActionResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ActionResources"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.CareAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasMaxLength(400) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("CareActions"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.CareActivity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("CareActivities"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.CareActivityAction", b => + { + b.Property("CareActivityId") + .HasColumnType("INTEGER"); + + b.Property("CareActionId") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("CareActivityId", "CareActionId"); + + b.HasIndex("CareActionId"); + + b.HasIndex("CareActivityId", "SortOrder") + .IsUnique(); + + b.ToTable("CareActivityActions"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.CareActivityActionResource", b => + { + b.Property("CareActivityId") + .HasColumnType("INTEGER"); + + b.Property("CareActionId") + .HasColumnType("INTEGER"); + + b.Property("ActionResourceId") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Quantity") + .HasPrecision(10, 2) + .HasColumnType("TEXT"); + + b.Property("Unit") + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.HasKey("CareActivityId", "CareActionId", "ActionResourceId"); + + b.HasIndex("ActionResourceId"); + + b.ToTable("CareActivityActionResources"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.Plant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Birthday") + .HasColumnType("TEXT"); + + b.Property("LocationId") + .HasColumnType("INTEGER"); + + b.Property("Nickname") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("TaxonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LocationId"); + + b.HasIndex("TaxonId"); + + b.ToTable("Plants"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.PlantCareSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CareActionId") + .HasColumnType("INTEGER"); + + b.Property("CareActivityId") + .HasColumnType("INTEGER"); + + b.Property("EndsAfterOccurrences") + .HasColumnType("INTEGER"); + + b.Property("EndsMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("EndsOn") + .HasColumnType("TEXT"); + + b.Property("EveryDays") + .HasColumnType("INTEGER"); + + b.Property("PlantId") + .HasColumnType("INTEGER"); + + b.Property("RecurrenceMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("RepeatEvery") + .HasColumnType("INTEGER"); + + b.Property("RepeatOnDays") + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("RepeatUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("ScheduledFor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CareActionId"); + + b.HasIndex("CareActivityId"); + + b.HasIndex("PlantId", "CareActionId"); + + b.HasIndex("PlantId", "CareActivityId") + .IsUnique(); + + b.ToTable("PlantCareSchedules"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.PlantFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("PlantFlagDefinitionId") + .HasColumnType("INTEGER"); + + b.Property("PlantId") + .HasColumnType("INTEGER"); + + b.Property("ResolvedOn") + .HasColumnType("TEXT"); + + b.Property("StartedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlantFlagDefinitionId"); + + b.HasIndex("PlantId", "PlantFlagDefinitionId", "ResolvedOn"); + + b.ToTable("PlantFlags"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.PlantFlagDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("PlantFlagDefinitions"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.PlantGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("PlantGroups"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.PlantGroupMembership", b => + { + b.Property("PlantId") + .HasColumnType("INTEGER"); + + b.Property("PlantGroupId") + .HasColumnType("INTEGER"); + + b.HasKey("PlantId", "PlantGroupId"); + + b.HasIndex("PlantGroupId"); + + b.ToTable("PlantGroupMemberships"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.PlantLocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("PlantLocations"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.PlantTaxon", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("CommonName") + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("Cultivar") + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("ExternalSource") + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Family") + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("Genus") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("Species") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("Variety") + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExternalSource", "ExternalId"); + + b.ToTable("PlantTaxa"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.Recipe", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MeasurementMode") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("OutputResourceId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("OutputResourceId") + .IsUnique(); + + b.ToTable("Recipes"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.RecipeComponent", b => + { + b.Property("RecipeId") + .HasColumnType("INTEGER"); + + b.Property("ActionResourceId") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Quantity") + .HasPrecision(10, 2) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Unit") + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.HasKey("RecipeId", "ActionResourceId"); + + b.HasIndex("ActionResourceId"); + + b.HasIndex("RecipeId", "SortOrder") + .IsUnique(); + + b.ToTable("RecipeComponents"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.ActionLog", b => + { + b.HasOne("plant_manager.Data.Models.CareAction", "CareAction") + .WithMany("ActionLogs") + .HasForeignKey("CareActionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("plant_manager.Data.Models.CareActivity", "CareActivity") + .WithMany("ActionLogs") + .HasForeignKey("CareActivityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("plant_manager.Data.Models.Plant", "Plant") + .WithMany("ActionLogs") + .HasForeignKey("PlantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CareAction"); + + b.Navigation("CareActivity"); + + b.Navigation("Plant"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.ActionLogResource", b => + { + b.HasOne("plant_manager.Data.Models.ActionLog", "ActionLog") + .WithMany("Resources") + .HasForeignKey("ActionLogId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("plant_manager.Data.Models.ActionResource", "ActionResource") + .WithMany("ActionLogResources") + .HasForeignKey("ActionResourceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ActionLog"); + + b.Navigation("ActionResource"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.CareActivityAction", b => + { + b.HasOne("plant_manager.Data.Models.CareAction", "CareAction") + .WithMany("CareActivityActions") + .HasForeignKey("CareActionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("plant_manager.Data.Models.CareActivity", "CareActivity") + .WithMany("Actions") + .HasForeignKey("CareActivityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CareAction"); + + b.Navigation("CareActivity"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.CareActivityActionResource", b => + { + b.HasOne("plant_manager.Data.Models.ActionResource", "ActionResource") + .WithMany("CareActivityActionResources") + .HasForeignKey("ActionResourceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("plant_manager.Data.Models.CareActivityAction", "CareActivityAction") + .WithMany("Resources") + .HasForeignKey("CareActivityId", "CareActionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ActionResource"); + + b.Navigation("CareActivityAction"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.Plant", b => + { + b.HasOne("plant_manager.Data.Models.PlantLocation", "Location") + .WithMany("Plants") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("plant_manager.Data.Models.PlantTaxon", "Taxon") + .WithMany() + .HasForeignKey("TaxonId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Location"); + + b.Navigation("Taxon"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.PlantCareSchedule", b => + { + b.HasOne("plant_manager.Data.Models.CareAction", "CareAction") + .WithMany("PlantCareSchedules") + .HasForeignKey("CareActionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("plant_manager.Data.Models.CareActivity", "CareActivity") + .WithMany("PlantCareSchedules") + .HasForeignKey("CareActivityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("plant_manager.Data.Models.Plant", "Plant") + .WithMany("CareSchedules") + .HasForeignKey("PlantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CareAction"); + + b.Navigation("CareActivity"); + + b.Navigation("Plant"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.PlantFlag", b => + { + b.HasOne("plant_manager.Data.Models.PlantFlagDefinition", "Definition") + .WithMany("PlantFlags") + .HasForeignKey("PlantFlagDefinitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("plant_manager.Data.Models.Plant", "Plant") + .WithMany("Flags") + .HasForeignKey("PlantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Definition"); + + b.Navigation("Plant"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.PlantGroupMembership", b => + { + b.HasOne("plant_manager.Data.Models.PlantGroup", "PlantGroup") + .WithMany("Memberships") + .HasForeignKey("PlantGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("plant_manager.Data.Models.Plant", "Plant") + .WithMany("GroupMemberships") + .HasForeignKey("PlantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Plant"); + + b.Navigation("PlantGroup"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.Recipe", b => + { + b.HasOne("plant_manager.Data.Models.ActionResource", "OutputResource") + .WithOne("ProducedByRecipe") + .HasForeignKey("plant_manager.Data.Models.Recipe", "OutputResourceId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("OutputResource"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.RecipeComponent", b => + { + b.HasOne("plant_manager.Data.Models.ActionResource", "ActionResource") + .WithMany("RecipeComponents") + .HasForeignKey("ActionResourceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("plant_manager.Data.Models.Recipe", "Recipe") + .WithMany("Components") + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ActionResource"); + + b.Navigation("Recipe"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.ActionLog", b => + { + b.Navigation("Resources"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.ActionResource", b => + { + b.Navigation("ActionLogResources"); + + b.Navigation("CareActivityActionResources"); + + b.Navigation("ProducedByRecipe"); + + b.Navigation("RecipeComponents"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.CareAction", b => + { + b.Navigation("ActionLogs"); + + b.Navigation("CareActivityActions"); + + b.Navigation("PlantCareSchedules"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.CareActivity", b => + { + b.Navigation("ActionLogs"); + + b.Navigation("Actions"); + + b.Navigation("PlantCareSchedules"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.CareActivityAction", b => + { + b.Navigation("Resources"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.Plant", b => + { + b.Navigation("ActionLogs"); + + b.Navigation("CareSchedules"); + + b.Navigation("Flags"); + + b.Navigation("GroupMemberships"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.PlantFlagDefinition", b => + { + b.Navigation("PlantFlags"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.PlantGroup", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.PlantLocation", b => + { + b.Navigation("Plants"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.Recipe", b => + { + b.Navigation("Components"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/plant-manager/Data/Migrations/20260604231106_AddGbifPlantTaxonMetadata.cs b/plant-manager/Data/Migrations/20260604231106_AddGbifPlantTaxonMetadata.cs new file mode 100644 index 0000000..401ed6f --- /dev/null +++ b/plant-manager/Data/Migrations/20260604231106_AddGbifPlantTaxonMetadata.cs @@ -0,0 +1,71 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace plant_manager.Data.Migrations +{ + /// + public partial class AddGbifPlantTaxonMetadata : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CommonName", + table: "PlantTaxa", + type: "TEXT", + maxLength: 120, + nullable: true); + + migrationBuilder.AddColumn( + name: "ExternalId", + table: "PlantTaxa", + type: "TEXT", + maxLength: 80, + nullable: true); + + migrationBuilder.AddColumn( + name: "ExternalSource", + table: "PlantTaxa", + type: "TEXT", + maxLength: 40, + nullable: true); + + migrationBuilder.AddColumn( + name: "Family", + table: "PlantTaxa", + type: "TEXT", + maxLength: 120, + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_PlantTaxa_ExternalSource_ExternalId", + table: "PlantTaxa", + columns: new[] { "ExternalSource", "ExternalId" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_PlantTaxa_ExternalSource_ExternalId", + table: "PlantTaxa"); + + migrationBuilder.DropColumn( + name: "CommonName", + table: "PlantTaxa"); + + migrationBuilder.DropColumn( + name: "ExternalId", + table: "PlantTaxa"); + + migrationBuilder.DropColumn( + name: "ExternalSource", + table: "PlantTaxa"); + + migrationBuilder.DropColumn( + name: "Family", + table: "PlantTaxa"); + } + } +} diff --git a/plant-manager/Data/Migrations/ApplicationDbContextModelSnapshot.cs b/plant-manager/Data/Migrations/ApplicationDbContextModelSnapshot.cs index d53f6e5..706091a 100644 --- a/plant-manager/Data/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/plant-manager/Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -416,10 +416,26 @@ namespace plant_manager.Data.Migrations .HasMaxLength(120) .HasColumnType("TEXT"); + b.Property("CommonName") + .HasMaxLength(120) + .HasColumnType("TEXT"); + b.Property("Cultivar") .HasMaxLength(120) .HasColumnType("TEXT"); + b.Property("ExternalId") + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("ExternalSource") + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Family") + .HasMaxLength(120) + .HasColumnType("TEXT"); + b.Property("Genus") .IsRequired() .HasMaxLength(120) @@ -441,6 +457,8 @@ namespace plant_manager.Data.Migrations b.HasKey("Id"); + b.HasIndex("ExternalSource", "ExternalId"); + b.ToTable("PlantTaxa"); }); diff --git a/plant-manager/Data/Models/PlantInfoRecord.cs b/plant-manager/Data/Models/PlantInfoRecord.cs new file mode 100644 index 0000000..eb01a92 --- /dev/null +++ b/plant-manager/Data/Models/PlantInfoRecord.cs @@ -0,0 +1,19 @@ +namespace plant_manager.Data.Models +{ + public class PlantInfoRecord + { + public int Id { get; set; } + public string Source { get; set; } = "gbif"; + public string ExternalId { get; set; } = string.Empty; + public string ScientificName { get; set; } = string.Empty; + public string? CanonicalName { get; set; } + public string? Authorship { get; set; } + public string? CommonName { get; set; } + public string? AliasesText { get; set; } + public string? Family { get; set; } + public string? Genus { get; set; } + public string? Species { get; set; } + public string? Rank { get; set; } + public string? Status { get; set; } + } +} diff --git a/plant-manager/Data/Models/PlantTaxon.cs b/plant-manager/Data/Models/PlantTaxon.cs index 69150dc..b09fedd 100644 --- a/plant-manager/Data/Models/PlantTaxon.cs +++ b/plant-manager/Data/Models/PlantTaxon.cs @@ -10,6 +10,10 @@ namespace plant_manager.Data.Models public string? Cultivar { get; set; } public string? Variety { get; set; } - public string? Authority { get; set; } - } -} + public string? Authority { get; set; } + public string? Family { get; set; } + public string? CommonName { get; set; } + public string? ExternalSource { get; set; } + public string? ExternalId { get; set; } + } +} diff --git a/plant-manager/Data/PlantInfoDbContext.cs b/plant-manager/Data/PlantInfoDbContext.cs new file mode 100644 index 0000000..b543a1d --- /dev/null +++ b/plant-manager/Data/PlantInfoDbContext.cs @@ -0,0 +1,85 @@ +using Microsoft.EntityFrameworkCore; +using plant_manager.Data.Models; + +namespace plant_manager.Data +{ + public class PlantInfoDbContext(DbContextOptions options) : DbContext(options) + { + public DbSet PlantInfoRecords { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Id) + .ValueGeneratedOnAdd(); + entity.Property(e => e.Source).HasMaxLength(40).IsRequired(); + entity.Property(e => e.ExternalId).HasMaxLength(80).IsRequired(); + entity.Property(e => e.ScientificName).HasMaxLength(240).IsRequired(); + entity.Property(e => e.CanonicalName).HasMaxLength(180); + entity.Property(e => e.Authorship).HasMaxLength(120); + entity.Property(e => e.CommonName).HasMaxLength(180); + entity.Property(e => e.AliasesText).HasMaxLength(1000); + entity.Property(e => e.Family).HasMaxLength(120); + entity.Property(e => e.Genus).HasMaxLength(120); + entity.Property(e => e.Species).HasMaxLength(120); + entity.Property(e => e.Rank).HasMaxLength(40); + entity.Property(e => e.Status).HasMaxLength(40); + entity.HasIndex(e => new { e.Source, e.ExternalId }).IsUnique(); + entity.HasIndex(e => e.CanonicalName); + entity.HasIndex(e => e.CommonName); + entity.HasIndex(e => e.Genus); + entity.HasIndex(e => e.Family); + }); + } + + public async Task EnsureSearchSchemaAsync() + { + await Database.EnsureCreatedAsync(); + + await Database.ExecuteSqlRawAsync(""" + CREATE VIRTUAL TABLE IF NOT EXISTS PlantInfoSearch USING fts5( + CommonName, + ScientificName, + CanonicalName, + AliasesText, + Family, + Genus, + Species, + content='PlantInfoRecords', + content_rowid='Id' + ); + """); + + await Database.ExecuteSqlRawAsync(""" + CREATE TRIGGER IF NOT EXISTS PlantInfoRecords_ai AFTER INSERT ON PlantInfoRecords BEGIN + INSERT INTO PlantInfoSearch(rowid, CommonName, ScientificName, CanonicalName, AliasesText, Family, Genus, Species) + VALUES (new.Id, new.CommonName, new.ScientificName, new.CanonicalName, new.AliasesText, new.Family, new.Genus, new.Species); + END; + """); + + await Database.ExecuteSqlRawAsync(""" + CREATE TRIGGER IF NOT EXISTS PlantInfoRecords_ad AFTER DELETE ON PlantInfoRecords BEGIN + INSERT INTO PlantInfoSearch(PlantInfoSearch, rowid, CommonName, ScientificName, CanonicalName, AliasesText, Family, Genus, Species) + VALUES ('delete', old.Id, old.CommonName, old.ScientificName, old.CanonicalName, old.AliasesText, old.Family, old.Genus, old.Species); + END; + """); + + await Database.ExecuteSqlRawAsync(""" + CREATE TRIGGER IF NOT EXISTS PlantInfoRecords_au AFTER UPDATE ON PlantInfoRecords BEGIN + INSERT INTO PlantInfoSearch(PlantInfoSearch, rowid, CommonName, ScientificName, CanonicalName, AliasesText, Family, Genus, Species) + VALUES ('delete', old.Id, old.CommonName, old.ScientificName, old.CanonicalName, old.AliasesText, old.Family, old.Genus, old.Species); + INSERT INTO PlantInfoSearch(rowid, CommonName, ScientificName, CanonicalName, AliasesText, Family, Genus, Species) + VALUES (new.Id, new.CommonName, new.ScientificName, new.CanonicalName, new.AliasesText, new.Family, new.Genus, new.Species); + END; + """); + + await Database.ExecuteSqlRawAsync(""" + INSERT INTO PlantInfoSearch(PlantInfoSearch) VALUES ('rebuild'); + """); + } + } +} diff --git a/plant-manager/Endpoints/ActionResourceEndpoints.cs b/plant-manager/Endpoints/ActionResourceEndpoints.cs index 9ab0b9b..42dfb8b 100644 --- a/plant-manager/Endpoints/ActionResourceEndpoints.cs +++ b/plant-manager/Endpoints/ActionResourceEndpoints.cs @@ -21,22 +21,21 @@ namespace plant_manager.Endpoints app.MapPost("/api/action-resources", async (SaveActionResourceRequest request, ApplicationDbContext db) => { - if (string.IsNullOrWhiteSpace(request.Name)) + if (!EndpointHelpers.TryNormalizeRequired(request.Name, "Resource name is required.", out var name, out var error)) { - return Results.BadRequest(new { error = "Resource name is required." }); + return error; } - var name = request.Name.Trim(); - var exists = await db.ActionResources.AnyAsync(resource => resource.Name.ToLower() == name.ToLower()); + var exists = await db.NameExistsAsync(resource => resource.Name.ToLower() == name.ToLower()); if (exists) { - return Results.Conflict(new { error = "A resource with this name already exists." }); + return EndpointHelpers.Conflict("A resource with this name already exists."); } var resource = new ActionResource { Name = name, - Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim() + Notes = EndpointHelpers.NormalizeOptional(request.Notes) }; db.ActionResources.Add(resource); @@ -47,9 +46,9 @@ namespace plant_manager.Endpoints app.MapPut("/api/action-resources/{id:int}", async (int id, SaveActionResourceRequest request, ApplicationDbContext db) => { - if (string.IsNullOrWhiteSpace(request.Name)) + if (!EndpointHelpers.TryNormalizeRequired(request.Name, "Resource name is required.", out var name, out var error)) { - return Results.BadRequest(new { error = "Resource name is required." }); + return error; } var resource = await db.ActionResources.FindAsync(id); @@ -58,16 +57,15 @@ namespace plant_manager.Endpoints return Results.NotFound(); } - var name = request.Name.Trim(); - var exists = await db.ActionResources.AnyAsync(item => + var exists = await db.NameExistsAsync(item => item.Id != id && item.Name.ToLower() == name.ToLower()); if (exists) { - return Results.Conflict(new { error = "A resource with this name already exists." }); + return EndpointHelpers.Conflict("A resource with this name already exists."); } resource.Name = name; - resource.Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(); + resource.Notes = EndpointHelpers.NormalizeOptional(request.Notes); await db.SaveChangesAsync(); @@ -87,7 +85,7 @@ namespace plant_manager.Endpoints || await db.RecipeComponents.AnyAsync(component => component.ActionResourceId == id); if (isInUse) { - return Results.Conflict(new { error = "Resource is in use." }); + return EndpointHelpers.Conflict("Resource is in use."); } db.ActionResources.Remove(resource); diff --git a/plant-manager/Endpoints/CareActionEndpoints.cs b/plant-manager/Endpoints/CareActionEndpoints.cs index ff9ff6a..782f7ff 100644 --- a/plant-manager/Endpoints/CareActionEndpoints.cs +++ b/plant-manager/Endpoints/CareActionEndpoints.cs @@ -20,22 +20,21 @@ namespace plant_manager.Endpoints app.MapPost("/api/care-actions", async (SaveCareActionRequest request, ApplicationDbContext db) => { - if (string.IsNullOrWhiteSpace(request.Name)) + if (!EndpointHelpers.TryNormalizeRequired(request.Name, "Action name is required.", out var name, out var error)) { - return Results.BadRequest(new { error = "Action name is required." }); + return error; } - var name = request.Name.Trim(); - var exists = await db.CareActions.AnyAsync(action => action.Name.ToLower() == name.ToLower()); + var exists = await db.NameExistsAsync(action => action.Name.ToLower() == name.ToLower()); if (exists) { - return Results.Conflict(new { error = "An action with this name already exists." }); + return EndpointHelpers.Conflict("An action with this name already exists."); } var action = new CareAction { Name = name, - Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim() + Description = EndpointHelpers.NormalizeOptional(request.Description) }; db.CareActions.Add(action); @@ -46,9 +45,9 @@ namespace plant_manager.Endpoints app.MapPut("/api/care-actions/{id:int}", async (int id, SaveCareActionRequest request, ApplicationDbContext db) => { - if (string.IsNullOrWhiteSpace(request.Name)) + if (!EndpointHelpers.TryNormalizeRequired(request.Name, "Action name is required.", out var name, out var error)) { - return Results.BadRequest(new { error = "Action name is required." }); + return error; } var action = await db.CareActions.FindAsync(id); @@ -57,16 +56,15 @@ namespace plant_manager.Endpoints return Results.NotFound(); } - var name = request.Name.Trim(); - var exists = await db.CareActions.AnyAsync(item => + var exists = await db.NameExistsAsync(item => item.Id != id && item.Name.ToLower() == name.ToLower()); if (exists) { - return Results.Conflict(new { error = "An action with this name already exists." }); + return EndpointHelpers.Conflict("An action with this name already exists."); } action.Name = name; - action.Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(); + action.Description = EndpointHelpers.NormalizeOptional(request.Description); await db.SaveChangesAsync(); @@ -84,7 +82,7 @@ namespace plant_manager.Endpoints var hasLogs = await db.ActionLogs.AnyAsync(log => log.CareActionId == id); if (hasLogs) { - return Results.Conflict(new { error = "Action has care history." }); + return EndpointHelpers.Conflict("Action has care history."); } db.CareActions.Remove(action); diff --git a/plant-manager/Endpoints/EndpointHelpers.cs b/plant-manager/Endpoints/EndpointHelpers.cs new file mode 100644 index 0000000..d4e6a42 --- /dev/null +++ b/plant-manager/Endpoints/EndpointHelpers.cs @@ -0,0 +1,38 @@ +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore; +using plant_manager.Data; + +namespace plant_manager.Endpoints +{ + internal static class EndpointHelpers + { + public static IResult BadRequest(string error) => + Results.BadRequest(new { error }); + + public static IResult Conflict(string error) => + Results.Conflict(new { error }); + + public static string? NormalizeOptional(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + public static bool TryNormalizeRequired(string? value, string error, out string normalized, out IResult? result) + { + if (string.IsNullOrWhiteSpace(value)) + { + normalized = string.Empty; + result = BadRequest(error); + return false; + } + + normalized = value.Trim(); + result = null; + return true; + } + + public static async Task NameExistsAsync( + this ApplicationDbContext db, + Expression> predicate) + where TEntity : class => + await db.Set().AnyAsync(predicate); + } +} diff --git a/plant-manager/Endpoints/PlantFlagEndpoints.cs b/plant-manager/Endpoints/PlantFlagEndpoints.cs index cb35953..f998a8c 100644 --- a/plant-manager/Endpoints/PlantFlagEndpoints.cs +++ b/plant-manager/Endpoints/PlantFlagEndpoints.cs @@ -20,17 +20,16 @@ namespace plant_manager.Endpoints app.MapPost("/api/plant-flags", async (SavePlantFlagDefinitionRequest request, ApplicationDbContext db) => { - if (string.IsNullOrWhiteSpace(request.Name)) + if (!EndpointHelpers.TryNormalizeRequired(request.Name, "Flag name is required.", out var name, out var error)) { - return Results.BadRequest(new { error = "Flag name is required." }); + return error; } - var name = request.Name.Trim(); - var exists = await db.PlantFlagDefinitions.AnyAsync(definition => + var exists = await db.NameExistsAsync(definition => definition.Name.ToLower() == name.ToLower()); if (exists) { - return Results.Conflict(new { error = "A flag with this name already exists." }); + return EndpointHelpers.Conflict("A flag with this name already exists."); } var definition = new PlantFlagDefinition @@ -47,23 +46,22 @@ namespace plant_manager.Endpoints app.MapPut("/api/plant-flags/{id:int}", async (int id, SavePlantFlagDefinitionRequest request, ApplicationDbContext db) => { - if (string.IsNullOrWhiteSpace(request.Name)) + if (!EndpointHelpers.TryNormalizeRequired(request.Name, "Flag name is required.", out var name, out var error)) { - return Results.BadRequest(new { error = "Flag name is required." }); + return error; } - var name = request.Name.Trim(); var definition = await db.PlantFlagDefinitions.FindAsync(id); if (definition is null) { return Results.NotFound(); } - var exists = await db.PlantFlagDefinitions.AnyAsync(item => + var exists = await db.NameExistsAsync(item => item.Id != id && item.Name.ToLower() == name.ToLower()); if (exists) { - return Results.Conflict(new { error = "A flag with this name already exists." }); + return EndpointHelpers.Conflict("A flag with this name already exists."); } definition.Name = name; @@ -85,7 +83,7 @@ namespace plant_manager.Endpoints var isUsed = await db.PlantFlags.AnyAsync(flag => flag.PlantFlagDefinitionId == id); if (isUsed) { - return Results.Conflict(new { error = "Flag is assigned to plants." }); + return EndpointHelpers.Conflict("Flag is assigned to plants."); } db.PlantFlagDefinitions.Remove(definition); @@ -117,7 +115,7 @@ namespace plant_manager.Endpoints && flag.ResolvedOn == null); if (hasActiveFlag) { - return Results.Conflict(new { error = "This plant already has that active flag." }); + return EndpointHelpers.Conflict("This plant already has that active flag."); } var flag = new PlantFlag @@ -200,6 +198,6 @@ namespace plant_manager.Endpoints string.IsNullOrWhiteSpace(color) ? "#f2f2f2" : color.Trim(); private static string? NormalizeNotes(string? notes) => - string.IsNullOrWhiteSpace(notes) ? null : notes.Trim(); + EndpointHelpers.NormalizeOptional(notes); } } diff --git a/plant-manager/Endpoints/PlantGroupEndpoints.cs b/plant-manager/Endpoints/PlantGroupEndpoints.cs index 48a0068..6f03575 100644 --- a/plant-manager/Endpoints/PlantGroupEndpoints.cs +++ b/plant-manager/Endpoints/PlantGroupEndpoints.cs @@ -25,20 +25,20 @@ namespace plant_manager.Endpoints var validation = await ValidateRequest(request, db); if (validation.Error is not null) { - return Results.BadRequest(new { error = validation.Error }); + return EndpointHelpers.BadRequest(validation.Error); } - var name = request.Name.Trim(); - var exists = await db.PlantGroups.AnyAsync(group => group.Name.ToLower() == name.ToLower()); + var name = validation.Name; + var exists = await db.NameExistsAsync(group => group.Name.ToLower() == name.ToLower()); if (exists) { - return Results.Conflict(new { error = "A plant group with this name already exists." }); + return EndpointHelpers.Conflict("A plant group with this name already exists."); } var group = new PlantGroup { Name = name, - Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(), + Notes = EndpointHelpers.NormalizeOptional(request.Notes), Memberships = validation.PlantIds .Select(plantId => new PlantGroupMembership { PlantId = plantId }) .ToList() @@ -70,19 +70,19 @@ namespace plant_manager.Endpoints var validation = await ValidateRequest(request, db); if (validation.Error is not null) { - return Results.BadRequest(new { error = validation.Error }); + return EndpointHelpers.BadRequest(validation.Error); } - var name = request.Name.Trim(); - var exists = await db.PlantGroups.AnyAsync(item => + var name = validation.Name; + var exists = await db.NameExistsAsync(item => item.Id != id && item.Name.ToLower() == name.ToLower()); if (exists) { - return Results.Conflict(new { error = "A plant group with this name already exists." }); + return EndpointHelpers.Conflict("A plant group with this name already exists."); } group.Name = name; - group.Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(); + group.Notes = EndpointHelpers.NormalizeOptional(request.Notes); db.PlantGroupMemberships.RemoveRange(group.Memberships); group.Memberships = validation.PlantIds .Select(plantId => new PlantGroupMembership @@ -118,13 +118,13 @@ namespace plant_manager.Endpoints }); } - private static async Task<(List PlantIds, string? Error)> ValidateRequest( + private static async Task<(string Name, List PlantIds, string? Error)> ValidateRequest( SavePlantGroupRequest request, ApplicationDbContext db) { - if (string.IsNullOrWhiteSpace(request.Name)) + if (!EndpointHelpers.TryNormalizeRequired(request.Name, "Group name is required.", out var name, out _)) { - return ([], "Group name is required."); + return (string.Empty, [], "Group name is required."); } var plantIds = request.PlantIds? @@ -137,11 +137,11 @@ namespace plant_manager.Endpoints var existingPlantCount = await db.Plants.CountAsync(plant => plantIds.Contains(plant.Id)); if (existingPlantCount != plantIds.Count) { - return ([], "One or more plants were not found."); + return (string.Empty, [], "One or more plants were not found."); } } - return (plantIds, null); + return (name, plantIds, null); } } } diff --git a/plant-manager/Endpoints/PlantInfoEndpoints.cs b/plant-manager/Endpoints/PlantInfoEndpoints.cs new file mode 100644 index 0000000..9e92e28 --- /dev/null +++ b/plant-manager/Endpoints/PlantInfoEndpoints.cs @@ -0,0 +1,24 @@ +using plant_manager.Services; + +namespace plant_manager.Endpoints +{ + public static class PlantInfoEndpoints + { + public static void MapPlantInfoEndpoints(this WebApplication app) + { + app.MapGet("/api/plant-info/search", async ( + string q, + PlantInfoSearchService plantInfo, + CancellationToken cancellationToken) => + { + if (string.IsNullOrWhiteSpace(q) || q.Trim().Length < 2) + { + return EndpointHelpers.BadRequest("Search query must be at least 2 characters."); + } + + var results = await plantInfo.SearchAsync(q, cancellationToken); + return Results.Ok(results); + }); + } + } +} diff --git a/plant-manager/Endpoints/PlantLocationEndpoints.cs b/plant-manager/Endpoints/PlantLocationEndpoints.cs index 1ca81f8..adeadd7 100644 --- a/plant-manager/Endpoints/PlantLocationEndpoints.cs +++ b/plant-manager/Endpoints/PlantLocationEndpoints.cs @@ -20,22 +20,21 @@ namespace plant_manager.Endpoints app.MapPost("/api/plant-locations", async (SavePlantLocationRequest request, ApplicationDbContext db) => { - if (string.IsNullOrWhiteSpace(request.Name)) + if (!EndpointHelpers.TryNormalizeRequired(request.Name, "Location name is required.", out var name, out var error)) { - return Results.BadRequest(new { error = "Location name is required." }); + return error; } - var name = request.Name.Trim(); - var exists = await db.PlantLocations.AnyAsync(location => location.Name.ToLower() == name.ToLower()); + var exists = await db.NameExistsAsync(location => location.Name.ToLower() == name.ToLower()); if (exists) { - return Results.Conflict(new { error = "A location with this name already exists." }); + return EndpointHelpers.Conflict("A location with this name already exists."); } var location = new PlantLocation { Name = name, - Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim() + Notes = EndpointHelpers.NormalizeOptional(request.Notes) }; db.PlantLocations.Add(location); @@ -46,9 +45,9 @@ namespace plant_manager.Endpoints app.MapPut("/api/plant-locations/{id:int}", async (int id, SavePlantLocationRequest request, ApplicationDbContext db) => { - if (string.IsNullOrWhiteSpace(request.Name)) + if (!EndpointHelpers.TryNormalizeRequired(request.Name, "Location name is required.", out var name, out var error)) { - return Results.BadRequest(new { error = "Location name is required." }); + return error; } var location = await db.PlantLocations.FindAsync(id); @@ -57,16 +56,15 @@ namespace plant_manager.Endpoints return Results.NotFound(); } - var name = request.Name.Trim(); - var exists = await db.PlantLocations.AnyAsync(item => + var exists = await db.NameExistsAsync(item => item.Id != id && item.Name.ToLower() == name.ToLower()); if (exists) { - return Results.Conflict(new { error = "A location with this name already exists." }); + return EndpointHelpers.Conflict("A location with this name already exists."); } location.Name = name; - location.Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(); + location.Notes = EndpointHelpers.NormalizeOptional(request.Notes); await db.SaveChangesAsync(); @@ -84,7 +82,7 @@ namespace plant_manager.Endpoints var isInUse = await db.Plants.AnyAsync(plant => plant.LocationId == id); if (isInUse) { - return Results.Conflict(new { error = "Location is assigned to one or more plants." }); + return EndpointHelpers.Conflict("Location is assigned to one or more plants."); } db.PlantLocations.Remove(location); diff --git a/plant-manager/Endpoints/PlantTaxonEndpoints.cs b/plant-manager/Endpoints/PlantTaxonEndpoints.cs index 309936c..f1551a2 100644 --- a/plant-manager/Endpoints/PlantTaxonEndpoints.cs +++ b/plant-manager/Endpoints/PlantTaxonEndpoints.cs @@ -20,21 +20,24 @@ namespace plant_manager.Endpoints app.MapPost("/api/plant-taxa", async (SavePlantTaxonRequest request, ApplicationDbContext db) => { - if (string.IsNullOrWhiteSpace(request.Name) - || string.IsNullOrWhiteSpace(request.Genus) - || string.IsNullOrWhiteSpace(request.Species)) + var validation = ValidateTaxonRequest(request); + if (validation.Error is not null) { - return Results.BadRequest(new { error = "Name, genus, and species are required." }); + return EndpointHelpers.BadRequest(validation.Error); } var taxon = new PlantTaxon { - Name = request.Name.Trim(), - Genus = request.Genus.Trim(), - Species = request.Species.Trim(), - Cultivar = string.IsNullOrWhiteSpace(request.Cultivar) ? null : request.Cultivar.Trim(), - Variety = string.IsNullOrWhiteSpace(request.Variety) ? null : request.Variety.Trim(), - Authority = string.IsNullOrWhiteSpace(request.Authority) ? null : request.Authority.Trim() + Name = validation.Name, + Genus = validation.Genus, + Species = validation.Species, + Cultivar = validation.Cultivar, + Variety = validation.Variety, + Authority = validation.Authority, + Family = validation.Family, + CommonName = validation.CommonName, + ExternalSource = validation.ExternalSource, + ExternalId = validation.ExternalId }; db.PlantTaxa.Add(taxon); @@ -45,11 +48,10 @@ namespace plant_manager.Endpoints app.MapPut("/api/plant-taxa/{id:int}", async (int id, SavePlantTaxonRequest request, ApplicationDbContext db) => { - if (string.IsNullOrWhiteSpace(request.Name) - || string.IsNullOrWhiteSpace(request.Genus) - || string.IsNullOrWhiteSpace(request.Species)) + var validation = ValidateTaxonRequest(request); + if (validation.Error is not null) { - return Results.BadRequest(new { error = "Name, genus, and species are required." }); + return EndpointHelpers.BadRequest(validation.Error); } var taxon = await db.PlantTaxa.FindAsync(id); @@ -58,18 +60,76 @@ namespace plant_manager.Endpoints return Results.NotFound(); } - taxon.Name = request.Name.Trim(); - taxon.Genus = request.Genus.Trim(); - taxon.Species = request.Species.Trim(); - taxon.Cultivar = string.IsNullOrWhiteSpace(request.Cultivar) ? null : request.Cultivar.Trim(); - taxon.Variety = string.IsNullOrWhiteSpace(request.Variety) ? null : request.Variety.Trim(); - taxon.Authority = string.IsNullOrWhiteSpace(request.Authority) ? null : request.Authority.Trim(); + taxon.Name = validation.Name; + taxon.Genus = validation.Genus; + taxon.Species = validation.Species; + taxon.Cultivar = validation.Cultivar; + taxon.Variety = validation.Variety; + taxon.Authority = validation.Authority; + taxon.Family = validation.Family; + taxon.CommonName = validation.CommonName; + taxon.ExternalSource = validation.ExternalSource; + taxon.ExternalId = validation.ExternalId; await db.SaveChangesAsync(); return Results.Ok(PlantTaxonDto.FromTaxon(taxon)); }); + app.MapPost("/api/plant-taxa/import", async (ImportPlantTaxonRequest request, ApplicationDbContext db) => + { + if (!string.Equals(request.Source, "gbif", StringComparison.OrdinalIgnoreCase)) + { + return EndpointHelpers.BadRequest("Only GBIF imports are supported."); + } + + if (!EndpointHelpers.TryNormalizeRequired(request.ExternalId, "External ID is required.", out var externalId, out var error) + || !EndpointHelpers.TryNormalizeRequired(request.ScientificName, "Scientific name is required.", out var scientificName, out error)) + { + return error; + } + + var existingExternalTaxon = await db.PlantTaxa.FirstOrDefaultAsync(taxon => + taxon.ExternalSource == "gbif" && taxon.ExternalId == externalId); + if (existingExternalTaxon is not null) + { + return Results.Ok(PlantTaxonDto.FromTaxon(existingExternalTaxon)); + } + + var genus = EndpointHelpers.NormalizeOptional(request.Genus) ?? FirstScientificNamePart(scientificName); + var species = SpecificEpithet(EndpointHelpers.NormalizeOptional(request.Species)) + ?? SecondScientificNamePart(request.CanonicalName ?? scientificName); + if (string.IsNullOrWhiteSpace(genus) || string.IsNullOrWhiteSpace(species)) + { + return EndpointHelpers.BadRequest("GBIF result needs genus and species before it can be imported."); + } + + var commonName = EndpointHelpers.NormalizeOptional(request.CommonName); + var canonicalName = EndpointHelpers.NormalizeOptional(request.CanonicalName) ?? scientificName; + var name = commonName ?? canonicalName; + var localNameExists = await db.NameExistsAsync(taxon => taxon.Name.ToLower() == name.ToLower()); + if (localNameExists) + { + return EndpointHelpers.Conflict("A taxon with this name already exists."); + } + + var taxon = new PlantTaxon + { + Name = name, + Genus = genus, + Species = species, + Family = EndpointHelpers.NormalizeOptional(request.Family), + CommonName = commonName, + ExternalSource = "gbif", + ExternalId = externalId + }; + + db.PlantTaxa.Add(taxon); + await db.SaveChangesAsync(); + + return Results.Created($"/api/plant-taxa/{taxon.Id}", PlantTaxonDto.FromTaxon(taxon)); + }); + app.MapDelete("/api/plant-taxa/{id:int}", async (int id, ApplicationDbContext db) => { var taxon = await db.PlantTaxa.FindAsync(id); @@ -81,7 +141,7 @@ namespace plant_manager.Endpoints var isUsedByPlant = await db.Plants.AnyAsync(plant => plant.TaxonId == id); if (isUsedByPlant) { - return Results.Conflict(new { error = "Taxon is used by one or more plants." }); + return EndpointHelpers.Conflict("Taxon is used by one or more plants."); } db.PlantTaxa.Remove(taxon); @@ -90,5 +150,55 @@ namespace plant_manager.Endpoints return Results.NoContent(); }); } + + private static ( + string Name, + string Genus, + string Species, + string? Cultivar, + string? Variety, + string? Authority, + string? Family, + string? CommonName, + string? ExternalSource, + string? ExternalId, + string? Error) ValidateTaxonRequest(SavePlantTaxonRequest request) + { + if (!EndpointHelpers.TryNormalizeRequired(request.Name, "Name, genus, and species are required.", out var name, out _) + || !EndpointHelpers.TryNormalizeRequired(request.Genus, "Name, genus, and species are required.", out var genus, out _) + || !EndpointHelpers.TryNormalizeRequired(request.Species, "Name, genus, and species are required.", out var species, out _)) + { + return (string.Empty, string.Empty, string.Empty, null, null, null, null, null, null, null, "Name, genus, and species are required."); + } + + return ( + name, + genus, + species, + EndpointHelpers.NormalizeOptional(request.Cultivar), + EndpointHelpers.NormalizeOptional(request.Variety), + EndpointHelpers.NormalizeOptional(request.Authority), + EndpointHelpers.NormalizeOptional(request.Family), + EndpointHelpers.NormalizeOptional(request.CommonName), + EndpointHelpers.NormalizeOptional(request.ExternalSource), + EndpointHelpers.NormalizeOptional(request.ExternalId), + null); + } + + private static string? FirstScientificNamePart(string scientificName) => + scientificName.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(); + + private static string? SecondScientificNamePart(string scientificName) => + scientificName.Split(' ', StringSplitOptions.RemoveEmptyEntries).Skip(1).FirstOrDefault(); + + private static string? SpecificEpithet(string? species) + { + if (string.IsNullOrWhiteSpace(species)) + { + return null; + } + + return species.Split(' ', StringSplitOptions.RemoveEmptyEntries).Skip(1).FirstOrDefault() ?? species; + } } } diff --git a/plant-manager/Endpoints/RootEndpoints.cs b/plant-manager/Endpoints/RootEndpoints.cs index 37d4daf..00b949b 100644 --- a/plant-manager/Endpoints/RootEndpoints.cs +++ b/plant-manager/Endpoints/RootEndpoints.cs @@ -19,7 +19,8 @@ namespace plant_manager.Endpoints "/api/care-activities", "/api/care-tasks/upcoming", "/api/action-logs", - "/api/export/spreadsheet" + "/api/export/spreadsheet", + "/api/plant-info/search" } })); diff --git a/plant-manager/Program.cs b/plant-manager/Program.cs index 7bc65b0..ea35e7a 100644 --- a/plant-manager/Program.cs +++ b/plant-manager/Program.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using plant_manager.Data; using plant_manager.Endpoints; +using plant_manager.Services; var builder = WebApplication.CreateBuilder(args); @@ -18,11 +19,16 @@ builder.Services.AddCors(options => var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found."); +var plantInfoConnectionString = builder.Configuration.GetConnectionString("PlantInfoConnection") + ?? throw new InvalidOperationException("Connection string 'PlantInfoConnection' not found."); Directory.CreateDirectory(Path.Combine(builder.Environment.ContentRootPath, "App_Data")); builder.Services.AddDbContext(options => options.UseSqlite(connectionString)); +builder.Services.AddDbContext(options => + options.UseSqlite(plantInfoConnectionString)); +builder.Services.AddScoped(); var app = builder.Build(); @@ -37,6 +43,9 @@ using (var scope = app.Services.CreateScope()) var db = scope.ServiceProvider.GetRequiredService(); db.Database.Migrate(); DatabaseSeeder.Seed(db); + + var plantInfoDb = scope.ServiceProvider.GetRequiredService(); + await plantInfoDb.EnsureSearchSchemaAsync(); } app.MapRootEndpoints(); @@ -53,5 +62,6 @@ app.MapCareTaskEndpoints(); app.MapActionLogEndpoints(); app.MapPlantFlagEndpoints(); app.MapExportEndpoints(); +app.MapPlantInfoEndpoints(); app.Run(); diff --git a/plant-manager/Services/PlantInfoSearchService.cs b/plant-manager/Services/PlantInfoSearchService.cs new file mode 100644 index 0000000..b24f1a1 --- /dev/null +++ b/plant-manager/Services/PlantInfoSearchService.cs @@ -0,0 +1,136 @@ +using System.Data; +using System.Text.RegularExpressions; +using Microsoft.EntityFrameworkCore; +using plant_manager.Data; + +namespace plant_manager.Services +{ + public class PlantInfoSearchService(PlantInfoDbContext db) + { + public async Task> SearchAsync( + string query, + CancellationToken cancellationToken) + { + var normalizedText = NormalizeSearchText(query); + var ftsQuery = ToFtsQuery(normalizedText); + if (ftsQuery is null) + { + return []; + } + + var connection = db.Database.GetDbConnection(); + if (connection.State != ConnectionState.Open) + { + await connection.OpenAsync(cancellationToken); + } + + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT + r.Source, + r.ExternalId, + r.ScientificName, + r.CanonicalName, + r.CommonName, + r.Rank, + r.Status, + r.Family, + r.Genus, + r.Species, + CASE + WHEN lower(coalesce(r.CommonName, '')) = $normalized THEN 0 + WHEN lower(coalesce(r.CanonicalName, '')) = $normalized THEN 1 + WHEN lower(coalesce(r.CommonName, '')) LIKE $prefix THEN 2 + WHEN lower(coalesce(r.CanonicalName, '')) LIKE $prefix THEN 3 + WHEN lower(coalesce(r.ScientificName, '')) LIKE $prefix THEN 4 + WHEN lower(coalesce(r.Genus, '')) = $normalized THEN 5 + WHEN lower(coalesce(r.Genus, '')) LIKE $prefix THEN 6 + WHEN lower(coalesce(r.Family, '')) = $normalized THEN 7 + ELSE 20 + END AS RankBucket + FROM PlantInfoSearch search + JOIN PlantInfoRecords r ON r.Id = search.rowid + WHERE PlantInfoSearch MATCH $query + ORDER BY + RankBucket, + CASE WHEN RankBucket < 20 THEN r.CanonicalName END, + CASE WHEN RankBucket = 20 THEN bm25(PlantInfoSearch) END, + r.CanonicalName + LIMIT 20; + """; + + var queryParameter = command.CreateParameter(); + queryParameter.ParameterName = "$query"; + queryParameter.Value = ftsQuery; + command.Parameters.Add(queryParameter); + + var normalizedParameter = command.CreateParameter(); + normalizedParameter.ParameterName = "$normalized"; + normalizedParameter.Value = normalizedText; + command.Parameters.Add(normalizedParameter); + + var prefixParameter = command.CreateParameter(); + prefixParameter.ParameterName = "$prefix"; + prefixParameter.Value = $"{normalizedText}%"; + command.Parameters.Add(prefixParameter); + + var results = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + results.Add(new PlantInfoSearchResultDto( + reader.GetString(0), + reader.GetString(1), + reader.GetString(2), + ReadNullableString(reader, 3), + ReadNullableString(reader, 4), + ReadNullableString(reader, 5), + ReadNullableString(reader, 6), + ReadNullableString(reader, 7), + ReadNullableString(reader, 8), + ReadNullableString(reader, 9))); + } + + return results; + } + + public static string NormalizeSearchText(string value) + { + var normalized = value.Trim().ToLowerInvariant(); + normalized = Regex.Replace(normalized, @"[-'’`]", " "); + normalized = Regex.Replace(normalized, @"[^\p{L}\p{N}\s]", " "); + return Regex.Replace(normalized, @"\s+", " ").Trim(); + } + + private static string? ToFtsQuery(string normalized) + { + if (normalized.Length < 2) + { + return null; + } + + var terms = normalized + .Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Where(term => term.Length > 1) + .Select(term => $"{term}*") + .ToList(); + + return terms.Count == 0 ? null : string.Join(" ", terms); + } + + private static string? ReadNullableString(IDataRecord reader, int ordinal) => + reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal); + } + + public record PlantInfoSearchResultDto( + string Source, + string ExternalId, + string ScientificName, + string? CanonicalName, + string? CommonName, + string? Rank, + string? Status, + string? Family, + string? Genus, + string? Species); +} diff --git a/plant-manager/appsettings.json b/plant-manager/appsettings.json index c39dd6d..d0584c4 100644 --- a/plant-manager/appsettings.json +++ b/plant-manager/appsettings.json @@ -1,6 +1,7 @@ { "ConnectionStrings": { - "DefaultConnection": "Data Source=App_Data/plant-man.db" + "DefaultConnection": "Data Source=App_Data/plant-man.db", + "PlantInfoConnection": "Data Source=App_Data/plant-info.db" }, "Logging": { "LogLevel": { diff --git a/scripts/fetch-gbif-backbone.sh b/scripts/fetch-gbif-backbone.sh new file mode 100755 index 0000000..8317dd5 --- /dev/null +++ b/scripts/fetch-gbif-backbone.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +mkdir -p "$REPO_ROOT/data/raw/gbif" +curl -L \ + --output "$REPO_ROOT/data/raw/gbif/backbone.zip" \ + https://hosted-datasets.gbif.org/datasets/backbone/current/backbone.zip diff --git a/scripts/install-plant-info-db.sh b/scripts/install-plant-info-db.sh new file mode 100755 index 0000000..a89c72f --- /dev/null +++ b/scripts/install-plant-info-db.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +SOURCE_DB="$REPO_ROOT/data/build/plant-info.db" +TARGET_DB="$REPO_ROOT/plant-manager/App_Data/plant-info.db" + +if [[ ! -f "$SOURCE_DB" ]]; then + echo "Missing generated plant info database: $SOURCE_DB" >&2 + echo "Build it first with:" >&2 + echo " dotnet run --project plant-manager-importer -- build-db --source data/raw/gbif/backbone.zip --output data/build/plant-info.db" >&2 + exit 1 +fi + +mkdir -p "$(dirname "$TARGET_DB")" +cp -f "$SOURCE_DB" "$TARGET_DB" + +echo "Installed $SOURCE_DB -> $TARGET_DB"