add offline GBIF plant search

This commit is contained in:
2026-06-05 21:27:42 +00:00
parent f1396c05d8
commit 7588a23f34
35 changed files with 4053 additions and 1291 deletions
+1
View File
@@ -6,6 +6,7 @@
# dotenv files
.env
.codex/
data/
# User-specific files
*.rsuser
+586
View File
@@ -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<int> BuildDbAsync(Dictionary<string, string> 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<int> VerifyAsync(Dictionary<string, string> 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<PlantInfoImportRecord> 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<PlantInfoImportRecord> 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<PlantInfoImportRecord> 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<PlantInfoImportRecord> ReadGbifDwcaRecords(
DwcaLayout layout,
Func<string, Stream> openFile)
{
var records = new Dictionary<string, GbifRecordBuilder>(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<string, int>(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<string?> 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<long> 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<long> 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<string, string> ParseOptions(string[] args)
{
var options = new Dictionary<string, string>(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 <gbif-zip-or-directory> --output <db-path>
dotnet run --project plant-manager-importer -- verify --database <db-path>
""");
}
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<string, int> 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<string> 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);
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>plant_manager_importer</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.7" />
</ItemGroup>
</Project>
File diff suppressed because it is too large Load Diff
+12
View File
@@ -11,6 +11,7 @@ import type {
Recipe,
RecipePayload,
Plant,
PlantInfoSearchResult,
AssignPlantFlagPayload,
PlantPayload,
PlantFlag,
@@ -79,6 +80,10 @@ export async function getPlantTaxa() {
return request<PlantTaxon[]>('/api/plant-taxa');
}
export async function searchPlantInfo(query: string) {
return request<PlantInfoSearchResult[]>(`/api/plant-info/search?q=${encodeURIComponent(query)}`);
}
export async function getPlantLocations() {
return request<PlantLocation[]>('/api/plant-locations');
}
@@ -134,6 +139,13 @@ export async function createPlantTaxon(payload: PlantTaxonPayload) {
});
}
export async function importPlantTaxon(payload: PlantInfoSearchResult) {
return request<PlantTaxon>('/api/plant-taxa/import', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function updatePlantTaxon(id: number, payload: PlantTaxonPayload) {
return request<PlantTaxon>(`/api/plant-taxa/${id}`, {
method: 'PUT',
+145 -2
View File
@@ -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({
</button>
</section>
<section className="editor-panel" aria-labelledby="taxa-search-heading">
<div className="section-heading">
<div>
<p className="eyebrow">Offline lookup</p>
<h2 id="taxa-search-heading">Search plant info</h2>
</div>
</div>
<div className="search-row">
<label>
Plant name
<input
type="search"
value={searchQuery}
placeholder="ficus"
onChange={(event) => onSearchQueryChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
onSearch();
}
}}
/>
</label>
<button className="primary-action" type="button" disabled={isSearching} onClick={() => onSearch()}>
<Search size={18} />
{isSearching ? 'Searching' : 'Search'}
</button>
</div>
<div className="query-chip-row" aria-label="Example plant info searches">
{plantInfoSearchExamples.map((query) => (
<button
className="query-chip"
type="button"
key={query}
onClick={() => onSearch(query)}
>
{query}
</button>
))}
</div>
{plantInfoResults.length > 0 ? (
<div className="plant-list">
{plantInfoResults.map((result) => (
<article className="plant-row" key={`${result.source}-${result.externalId}`}>
<div className="plant-info-result">
<h3>{result.canonicalName ?? result.scientificName}</h3>
<p>{getPlantInfoSubtitle(result)}</p>
<dl className="plant-info-meta">
<div>
<dt>Family</dt>
<dd>{result.family ?? 'Unknown'}</dd>
</div>
<div>
<dt>Genus</dt>
<dd>{result.genus ?? 'Unknown'}</dd>
</div>
<div>
<dt>Species</dt>
<dd>{result.species ?? 'Unknown'}</dd>
</div>
<div>
<dt>Status</dt>
<dd>{[result.rank, result.status].filter(Boolean).join(' / ') || 'Unknown'}</dd>
</div>
<div>
<dt>Source</dt>
<dd>{`${result.source}:${result.externalId}`}</dd>
</div>
</dl>
</div>
<div className="row-actions">
<button className="small-action" type="button" onClick={() => onPrefillResult(result)}>
<Edit3 size={16} />
Prefill
</button>
<button className="small-action" type="button" disabled={isSaving} onClick={() => onImportResult(result)}>
<Download size={16} />
Import
</button>
</div>
</article>
))}
</div>
) : hasSearched && !isSearching ? (
<p className="empty-state">No plant info matches that search.</p>
) : null}
</section>
{selectedTaxon && !isEditorOpen ? (
<section className="editor-panel" aria-labelledby="taxon-detail-heading">
<div className="section-heading">
@@ -97,6 +218,14 @@ export function TaxaView({
<span>Authority</span>
<strong>{selectedTaxon.authority ?? 'None'}</strong>
</div>
<div>
<span>Family</span>
<strong>{selectedTaxon.family ?? 'None'}</strong>
</div>
<div>
<span>GBIF</span>
<strong>{selectedTaxon.externalSource === 'gbif' ? selectedTaxon.externalId : 'Not linked'}</strong>
</div>
</div>
</section>
) : null}
@@ -156,6 +285,20 @@ export function TaxaView({
onChange={(event) => onFieldChange('authority', event.target.value)}
/>
</label>
<label>
Family
<input
value={form.family}
onChange={(event) => onFieldChange('family', event.target.value)}
/>
</label>
<label>
GBIF ID
<input
value={form.externalId}
onChange={(event) => onFieldChange('externalId', event.target.value)}
/>
</label>
</div>
<div className="form-actions">
+21
View File
@@ -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 = {
+28
View File
@@ -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,
};
}
+78
View File
@@ -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;
+761
View File
@@ -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<typeof useAppEditors>;
type DashboardData = ReturnType<typeof useDashboardData>;
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<PlantInfoSearchResult[]>([]);
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<typeof editors.startEditingTaxon>[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<typeof editors.startEditingLocation>[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<typeof editors.startEditingPlantGroup>[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<typeof editors.startEditingAction>[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<typeof editors.startEditingResource>[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<typeof editors.startEditingRecipe>[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<typeof editors.startEditingActivity>[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<typeof editors.startEditingFlagDefinition>[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);
}
+502
View File
@@ -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<number | null>(null);
const [editingTaxonId, setEditingTaxonId] = useState<number | null>(null);
const [editingLocationId, setEditingLocationId] = useState<number | null>(null);
const [editingPlantGroupId, setEditingPlantGroupId] = useState<number | null>(null);
const [editingActionId, setEditingActionId] = useState<number | null>(null);
const [editingResourceId, setEditingResourceId] = useState<number | null>(null);
const [editingRecipeId, setEditingRecipeId] = useState<number | null>(null);
const [editingActivityId, setEditingActivityId] = useState<number | null>(null);
const [editingFlagDefinitionId, setEditingFlagDefinitionId] = useState<number | null>(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<number | null>(null);
const [selectedTaxonId, setSelectedTaxonId] = useState<number | null>(null);
const [selectedLocationId, setSelectedLocationId] = useState<number | null>(null);
const [selectedActionId, setSelectedActionId] = useState<number | null>(null);
const [selectedResourceId, setSelectedResourceId] = useState<number | null>(null);
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(null);
const [selectedActivityId, setSelectedActivityId] = useState<number | null>(null);
const [selectedFlagDefinitionId, setSelectedFlagDefinitionId] = useState<number | null>(null);
const [form, setForm] = useState<PlantFormState>(emptyPlantForm);
const [taxonForm, setTaxonForm] = useState<TaxonFormState>(emptyTaxonForm);
const [locationForm, setLocationForm] = useState<LocationFormState>(emptyLocationForm);
const [plantGroupForm, setPlantGroupForm] = useState<PlantGroupFormState>(emptyPlantGroupForm);
const [actionForm, setActionForm] = useState<ActionFormState>(emptyActionForm);
const [resourceForm, setResourceForm] = useState<ResourceFormState>(emptyResourceForm);
const [recipeForm, setRecipeForm] = useState<RecipeFormState>(emptyRecipeForm);
const [activityForm, setActivityForm] = useState<ActivityFormState>(emptyActivityForm);
const [flagDefinitionForm, setFlagDefinitionForm] = useState<FlagDefinitionFormState>(emptyFlagDefinitionForm);
const [plantFlagForm, setPlantFlagForm] = useState<PlantFlagFormState>(emptyPlantFlagForm);
const [bulkScheduleForm, setBulkScheduleForm] = useState<BulkScheduleFormState>(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,
};
}
+215
View File
@@ -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<Plant[]>([]);
const [plantTaxa, setPlantTaxa] = useState<PlantTaxon[]>([]);
const [plantLocations, setPlantLocations] = useState<PlantLocation[]>([]);
const [plantGroups, setPlantGroups] = useState<PlantGroup[]>([]);
const [careActions, setCareActions] = useState<CareAction[]>([]);
const [actionResources, setActionResources] = useState<ActionResource[]>([]);
const [recipes, setRecipes] = useState<Recipe[]>([]);
const [careActivities, setCareActivities] = useState<CareActivity[]>([]);
const [plantFlagDefinitions, setPlantFlagDefinitions] = useState<PlantFlagDefinition[]>([]);
const [careTasks, setCareTasks] = useState<CareTask[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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,
};
}
+31 -25
View File
@@ -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
+27 -3
View File
@@ -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(
@@ -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<Plant>(entity =>
@@ -0,0 +1,807 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ActionNameSnapshot")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("TEXT");
b.Property<int>("CareActionId")
.HasColumnType("INTEGER");
b.Property<int>("CareActivityId")
.HasColumnType("INTEGER");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<DateOnly>("PerformedOn")
.HasColumnType("TEXT");
b.Property<int>("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<int>("ActionLogId")
.HasColumnType("INTEGER");
b.Property<int>("ActionResourceId")
.HasColumnType("INTEGER");
b.Property<decimal?>("Quantity")
.HasPrecision(10, 2)
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Description")
.HasMaxLength(400)
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<string>("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<int>("CareActivityId")
.HasColumnType("INTEGER");
b.Property<int>("CareActionId")
.HasColumnType("INTEGER");
b.Property<int>("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<int>("CareActivityId")
.HasColumnType("INTEGER");
b.Property<int>("CareActionId")
.HasColumnType("INTEGER");
b.Property<int>("ActionResourceId")
.HasColumnType("INTEGER");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<decimal?>("Quantity")
.HasPrecision(10, 2)
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateOnly?>("Birthday")
.HasColumnType("TEXT");
b.Property<int?>("LocationId")
.HasColumnType("INTEGER");
b.Property<string>("Nickname")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<int?>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("CareActionId")
.HasColumnType("INTEGER");
b.Property<int>("CareActivityId")
.HasColumnType("INTEGER");
b.Property<int?>("EndsAfterOccurrences")
.HasColumnType("INTEGER");
b.Property<string>("EndsMode")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<DateOnly?>("EndsOn")
.HasColumnType("TEXT");
b.Property<int>("EveryDays")
.HasColumnType("INTEGER");
b.Property<int>("PlantId")
.HasColumnType("INTEGER");
b.Property<string>("RecurrenceMode")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<int>("RepeatEvery")
.HasColumnType("INTEGER");
b.Property<string>("RepeatOnDays")
.HasMaxLength(40)
.HasColumnType("TEXT");
b.Property<string>("RepeatUnit")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<DateOnly?>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<int>("PlantFlagDefinitionId")
.HasColumnType("INTEGER");
b.Property<int>("PlantId")
.HasColumnType("INTEGER");
b.Property<DateOnly?>("ResolvedOn")
.HasColumnType("TEXT");
b.Property<DateOnly>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Color")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<string>("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<int>("PlantId")
.HasColumnType("INTEGER");
b.Property<int>("PlantGroupId")
.HasColumnType("INTEGER");
b.HasKey("PlantId", "PlantGroupId");
b.HasIndex("PlantGroupId");
b.ToTable("PlantGroupMemberships");
});
modelBuilder.Entity("plant_manager.Data.Models.PlantLocation", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Authority")
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<string>("CommonName")
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<string>("Cultivar")
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<string>("ExternalId")
.HasMaxLength(80)
.HasColumnType("TEXT");
b.Property<string>("ExternalSource")
.HasMaxLength(40)
.HasColumnType("TEXT");
b.Property<string>("Family")
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<string>("Genus")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<string>("Species")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("MeasurementMode")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<int?>("OutputResourceId")
.HasColumnType("INTEGER");
b.Property<string>("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<int>("RecipeId")
.HasColumnType("INTEGER");
b.Property<int>("ActionResourceId")
.HasColumnType("INTEGER");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<decimal?>("Quantity")
.HasPrecision(10, 2)
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<string>("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
}
}
}
@@ -0,0 +1,71 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace plant_manager.Data.Migrations
{
/// <inheritdoc />
public partial class AddGbifPlantTaxonMetadata : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "CommonName",
table: "PlantTaxa",
type: "TEXT",
maxLength: 120,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ExternalId",
table: "PlantTaxa",
type: "TEXT",
maxLength: 80,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ExternalSource",
table: "PlantTaxa",
type: "TEXT",
maxLength: 40,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Family",
table: "PlantTaxa",
type: "TEXT",
maxLength: 120,
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_PlantTaxa_ExternalSource_ExternalId",
table: "PlantTaxa",
columns: new[] { "ExternalSource", "ExternalId" });
}
/// <inheritdoc />
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");
}
}
}
@@ -416,10 +416,26 @@ namespace plant_manager.Data.Migrations
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<string>("CommonName")
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<string>("Cultivar")
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<string>("ExternalId")
.HasMaxLength(80)
.HasColumnType("TEXT");
b.Property<string>("ExternalSource")
.HasMaxLength(40)
.HasColumnType("TEXT");
b.Property<string>("Family")
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<string>("Genus")
.IsRequired()
.HasMaxLength(120)
@@ -441,6 +457,8 @@ namespace plant_manager.Data.Migrations
b.HasKey("Id");
b.HasIndex("ExternalSource", "ExternalId");
b.ToTable("PlantTaxa");
});
@@ -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; }
}
}
+7 -3
View File
@@ -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; }
}
}
+85
View File
@@ -0,0 +1,85 @@
using Microsoft.EntityFrameworkCore;
using plant_manager.Data.Models;
namespace plant_manager.Data
{
public class PlantInfoDbContext(DbContextOptions<PlantInfoDbContext> options) : DbContext(options)
{
public DbSet<PlantInfoRecord> PlantInfoRecords { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<PlantInfoRecord>(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');
""");
}
}
}
@@ -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<ActionResource>(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<ActionResource>(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);
+11 -13
View File
@@ -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<CareAction>(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<CareAction>(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);
@@ -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<bool> NameExistsAsync<TEntity>(
this ApplicationDbContext db,
Expression<Func<TEntity, bool>> predicate)
where TEntity : class =>
await db.Set<TEntity>().AnyAsync(predicate);
}
}
+11 -13
View File
@@ -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<PlantFlagDefinition>(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<PlantFlagDefinition>(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);
}
}
+15 -15
View File
@@ -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<PlantGroup>(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<PlantGroup>(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<int> PlantIds, string? Error)> ValidateRequest(
private static async Task<(string Name, List<int> 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);
}
}
}
@@ -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);
});
}
}
}
@@ -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<PlantLocation>(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<PlantLocation>(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);
+131 -21
View File
@@ -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<PlantTaxon>(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;
}
}
}
+2 -1
View File
@@ -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"
}
}));
+10
View File
@@ -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<ApplicationDbContext>(options =>
options.UseSqlite(connectionString));
builder.Services.AddDbContext<PlantInfoDbContext>(options =>
options.UseSqlite(plantInfoConnectionString));
builder.Services.AddScoped<PlantInfoSearchService>();
var app = builder.Build();
@@ -37,6 +43,9 @@ using (var scope = app.Services.CreateScope())
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Database.Migrate();
DatabaseSeeder.Seed(db);
var plantInfoDb = scope.ServiceProvider.GetRequiredService<PlantInfoDbContext>();
await plantInfoDb.EnsureSearchSchemaAsync();
}
app.MapRootEndpoints();
@@ -53,5 +62,6 @@ app.MapCareTaskEndpoints();
app.MapActionLogEndpoints();
app.MapPlantFlagEndpoints();
app.MapExportEndpoints();
app.MapPlantInfoEndpoints();
app.Run();
@@ -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<IReadOnlyList<PlantInfoSearchResultDto>> 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<PlantInfoSearchResultDto>();
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);
}
+2 -1
View File
@@ -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": {
+10
View File
@@ -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
+19
View File
@@ -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"