add offline GBIF plant search
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
# dotenv files
|
||||
.env
|
||||
.codex/
|
||||
data/
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
|
||||
@@ -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>
|
||||
+188
-1168
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
+807
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
Executable
+10
@@ -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
|
||||
Executable
+19
@@ -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"
|
||||
Reference in New Issue
Block a user