add offline GBIF plant search
This commit is contained in:
@@ -6,6 +6,7 @@
|
|||||||
# dotenv files
|
# dotenv files
|
||||||
.env
|
.env
|
||||||
.codex/
|
.codex/
|
||||||
|
data/
|
||||||
|
|
||||||
# User-specific files
|
# User-specific files
|
||||||
*.rsuser
|
*.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,
|
Recipe,
|
||||||
RecipePayload,
|
RecipePayload,
|
||||||
Plant,
|
Plant,
|
||||||
|
PlantInfoSearchResult,
|
||||||
AssignPlantFlagPayload,
|
AssignPlantFlagPayload,
|
||||||
PlantPayload,
|
PlantPayload,
|
||||||
PlantFlag,
|
PlantFlag,
|
||||||
@@ -79,6 +80,10 @@ export async function getPlantTaxa() {
|
|||||||
return request<PlantTaxon[]>('/api/plant-taxa');
|
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() {
|
export async function getPlantLocations() {
|
||||||
return request<PlantLocation[]>('/api/plant-locations');
|
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) {
|
export async function updatePlantTaxon(id: number, payload: PlantTaxonPayload) {
|
||||||
return request<PlantTaxon>(`/api/plant-taxa/${id}`, {
|
return request<PlantTaxon>(`/api/plant-taxa/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Edit3, Eye, Plus, Save, Trash2, X } from 'lucide-react';
|
import { Download, Edit3, Eye, Plus, Save, Search, Trash2, X } from 'lucide-react';
|
||||||
import type { PlantTaxon } from '../domain';
|
import type { PlantInfoSearchResult, PlantTaxon } from '../domain';
|
||||||
import type { TaxonFormState } from '../form-state';
|
import type { TaxonFormState } from '../form-state';
|
||||||
import { formatTaxon } from '../form-state';
|
import { formatTaxon } from '../form-state';
|
||||||
|
|
||||||
@@ -7,9 +7,13 @@ type TaxaViewProps = {
|
|||||||
activeTaxonName?: string;
|
activeTaxonName?: string;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
form: TaxonFormState;
|
form: TaxonFormState;
|
||||||
|
hasSearched: boolean;
|
||||||
isEditorOpen: boolean;
|
isEditorOpen: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
|
isSearching: boolean;
|
||||||
|
plantInfoResults: PlantInfoSearchResult[];
|
||||||
|
searchQuery: string;
|
||||||
selectedTaxon?: PlantTaxon;
|
selectedTaxon?: PlantTaxon;
|
||||||
taxa: PlantTaxon[];
|
taxa: PlantTaxon[];
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
@@ -17,18 +21,41 @@ type TaxaViewProps = {
|
|||||||
onDelete: (taxon: PlantTaxon) => void;
|
onDelete: (taxon: PlantTaxon) => void;
|
||||||
onEdit: (taxon: PlantTaxon) => void;
|
onEdit: (taxon: PlantTaxon) => void;
|
||||||
onFieldChange: (field: keyof TaxonFormState, value: string) => void;
|
onFieldChange: (field: keyof TaxonFormState, value: string) => void;
|
||||||
|
onImportResult: (result: PlantInfoSearchResult) => void;
|
||||||
onNew: () => void;
|
onNew: () => void;
|
||||||
onOpenDetail: (taxon: PlantTaxon) => void;
|
onOpenDetail: (taxon: PlantTaxon) => void;
|
||||||
|
onPrefillResult: (result: PlantInfoSearchResult) => void;
|
||||||
onSave: () => 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({
|
export function TaxaView({
|
||||||
activeTaxonName,
|
activeTaxonName,
|
||||||
error,
|
error,
|
||||||
form,
|
form,
|
||||||
|
hasSearched,
|
||||||
isEditorOpen,
|
isEditorOpen,
|
||||||
isLoading,
|
isLoading,
|
||||||
isSaving,
|
isSaving,
|
||||||
|
isSearching,
|
||||||
|
plantInfoResults,
|
||||||
|
searchQuery,
|
||||||
selectedTaxon,
|
selectedTaxon,
|
||||||
taxa,
|
taxa,
|
||||||
onCancel,
|
onCancel,
|
||||||
@@ -36,9 +63,13 @@ export function TaxaView({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onEdit,
|
onEdit,
|
||||||
onFieldChange,
|
onFieldChange,
|
||||||
|
onImportResult,
|
||||||
onNew,
|
onNew,
|
||||||
onOpenDetail,
|
onOpenDetail,
|
||||||
|
onPrefillResult,
|
||||||
onSave,
|
onSave,
|
||||||
|
onSearch,
|
||||||
|
onSearchQueryChange,
|
||||||
}: TaxaViewProps) {
|
}: TaxaViewProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -56,6 +87,96 @@ export function TaxaView({
|
|||||||
</button>
|
</button>
|
||||||
</section>
|
</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 ? (
|
{selectedTaxon && !isEditorOpen ? (
|
||||||
<section className="editor-panel" aria-labelledby="taxon-detail-heading">
|
<section className="editor-panel" aria-labelledby="taxon-detail-heading">
|
||||||
<div className="section-heading">
|
<div className="section-heading">
|
||||||
@@ -97,6 +218,14 @@ export function TaxaView({
|
|||||||
<span>Authority</span>
|
<span>Authority</span>
|
||||||
<strong>{selectedTaxon.authority ?? 'None'}</strong>
|
<strong>{selectedTaxon.authority ?? 'None'}</strong>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -156,6 +285,20 @@ export function TaxaView({
|
|||||||
onChange={(event) => onFieldChange('authority', event.target.value)}
|
onChange={(event) => onFieldChange('authority', event.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</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>
|
||||||
|
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
|
|||||||
@@ -49,6 +49,23 @@ export type PlantTaxon = {
|
|||||||
cultivar: string | null;
|
cultivar: string | null;
|
||||||
variety: string | null;
|
variety: string | null;
|
||||||
authority: 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 = {
|
export type PlantLocation = {
|
||||||
@@ -220,6 +237,10 @@ export type PlantTaxonPayload = {
|
|||||||
cultivar: string | null;
|
cultivar: string | null;
|
||||||
variety: string | null;
|
variety: string | null;
|
||||||
authority: string | null;
|
authority: string | null;
|
||||||
|
family: string | null;
|
||||||
|
commonName: string | null;
|
||||||
|
externalSource: string | null;
|
||||||
|
externalId: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PlantLocationPayload = {
|
export type PlantLocationPayload = {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
Recipe,
|
Recipe,
|
||||||
RecipePayload,
|
RecipePayload,
|
||||||
Plant,
|
Plant,
|
||||||
|
PlantInfoSearchResult,
|
||||||
AssignPlantFlagPayload,
|
AssignPlantFlagPayload,
|
||||||
PlantPayload,
|
PlantPayload,
|
||||||
PlantFlagDefinition,
|
PlantFlagDefinition,
|
||||||
@@ -37,6 +38,10 @@ export const emptyTaxonForm = {
|
|||||||
cultivar: '',
|
cultivar: '',
|
||||||
variety: '',
|
variety: '',
|
||||||
authority: '',
|
authority: '',
|
||||||
|
family: '',
|
||||||
|
commonName: '',
|
||||||
|
externalSource: '',
|
||||||
|
externalId: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const emptyLocationForm = {
|
export const emptyLocationForm = {
|
||||||
@@ -187,6 +192,25 @@ export function toTaxonForm(taxon: PlantTaxon): TaxonFormState {
|
|||||||
cultivar: taxon.cultivar ?? '',
|
cultivar: taxon.cultivar ?? '',
|
||||||
variety: taxon.variety ?? '',
|
variety: taxon.variety ?? '',
|
||||||
authority: taxon.authority ?? '',
|
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,
|
cultivar: form.cultivar.trim() || null,
|
||||||
variety: form.variety.trim() || null,
|
variety: form.variety.trim() || null,
|
||||||
authority: form.authority.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;
|
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,
|
.plant-form label,
|
||||||
|
.search-row label,
|
||||||
.flag-assignment-form label {
|
.flag-assignment-form label {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@@ -762,6 +834,7 @@ dd {
|
|||||||
.plant-form input,
|
.plant-form input,
|
||||||
.plant-form select,
|
.plant-form select,
|
||||||
.plant-form textarea,
|
.plant-form textarea,
|
||||||
|
.search-row input,
|
||||||
.flag-assignment-form input,
|
.flag-assignment-form input,
|
||||||
.flag-assignment-form select {
|
.flag-assignment-form select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -1430,6 +1503,7 @@ dd {
|
|||||||
.detail-row,
|
.detail-row,
|
||||||
.plant-detail-grid,
|
.plant-detail-grid,
|
||||||
.plant-form,
|
.plant-form,
|
||||||
|
.search-row,
|
||||||
.resource-entry,
|
.resource-entry,
|
||||||
.resource-amount,
|
.resource-amount,
|
||||||
.activity-config-row,
|
.activity-config-row,
|
||||||
@@ -1443,6 +1517,10 @@ dd {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plant-info-meta {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.recipe-table-row {
|
.recipe-table-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
align-items: start;
|
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
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
# Visual Studio Version 17
|
# Visual Studio Version 17
|
||||||
VisualStudioVersion = 17.14.36414.22
|
VisualStudioVersion = 17.14.36414.22
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "plant-manager", "plant-manager\plant-manager.csproj", "{9623820E-0AE2-4500-80AD-85239C402D9D}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "plant-manager", "plant-manager\plant-manager.csproj", "{9623820E-0AE2-4500-80AD-85239C402D9D}"
|
||||||
EndProject
|
EndProject
|
||||||
Global
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "plant-manager-importer", "plant-manager-importer\plant-manager-importer.csproj", "{C076A21A-59C8-4423-9DE7-AC6316B37E8D}"
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
EndProject
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Global
|
||||||
Release|Any CPU = Release|Any CPU
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
EndGlobalSection
|
Debug|Any CPU = Debug|Any CPU
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
Release|Any CPU = Release|Any CPU
|
||||||
{9623820E-0AE2-4500-80AD-85239C402D9D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
EndGlobalSection
|
||||||
{9623820E-0AE2-4500-80AD-85239C402D9D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
{9623820E-0AE2-4500-80AD-85239C402D9D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{9623820E-0AE2-4500-80AD-85239C402D9D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{9623820E-0AE2-4500-80AD-85239C402D9D}.Release|Any CPU.Build.0 = Release|Any CPU
|
{9623820E-0AE2-4500-80AD-85239C402D9D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
EndGlobalSection
|
{9623820E-0AE2-4500-80AD-85239C402D9D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
{9623820E-0AE2-4500-80AD-85239C402D9D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
HideSolutionNode = FALSE
|
{C076A21A-59C8-4423-9DE7-AC6316B37E8D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
EndGlobalSection
|
{C076A21A-59C8-4423-9DE7-AC6316B37E8D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
{C076A21A-59C8-4423-9DE7-AC6316B37E8D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
SolutionGuid = {BA0B2435-3E48-42D6-94FE-8B691EA47558}
|
{C076A21A-59C8-4423-9DE7-AC6316B37E8D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
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 Species,
|
||||||
string? Cultivar,
|
string? Cultivar,
|
||||||
string? Variety,
|
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(
|
public record SavePlantLocationRequest(
|
||||||
string Name,
|
string Name,
|
||||||
@@ -178,7 +194,11 @@ namespace plant_manager
|
|||||||
string Species,
|
string Species,
|
||||||
string? Cultivar,
|
string? Cultivar,
|
||||||
string? Variety,
|
string? Variety,
|
||||||
string? Authority)
|
string? Authority,
|
||||||
|
string? Family,
|
||||||
|
string? CommonName,
|
||||||
|
string? ExternalSource,
|
||||||
|
string? ExternalId)
|
||||||
{
|
{
|
||||||
public static PlantTaxonDto FromTaxon(PlantTaxon taxon) =>
|
public static PlantTaxonDto FromTaxon(PlantTaxon taxon) =>
|
||||||
new(
|
new(
|
||||||
@@ -188,7 +208,11 @@ namespace plant_manager
|
|||||||
taxon.Species,
|
taxon.Species,
|
||||||
taxon.Cultivar,
|
taxon.Cultivar,
|
||||||
taxon.Variety,
|
taxon.Variety,
|
||||||
taxon.Authority);
|
taxon.Authority,
|
||||||
|
taxon.Family,
|
||||||
|
taxon.CommonName,
|
||||||
|
taxon.ExternalSource,
|
||||||
|
taxon.ExternalId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public record PlantLocationDto(
|
public record PlantLocationDto(
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ namespace plant_manager.Data
|
|||||||
entity.Property(e => e.Cultivar).HasMaxLength(120);
|
entity.Property(e => e.Cultivar).HasMaxLength(120);
|
||||||
entity.Property(e => e.Variety).HasMaxLength(120);
|
entity.Property(e => e.Variety).HasMaxLength(120);
|
||||||
entity.Property(e => e.Authority).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 =>
|
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)
|
.HasMaxLength(120)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("CommonName")
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("Cultivar")
|
b.Property<string>("Cultivar")
|
||||||
.HasMaxLength(120)
|
.HasMaxLength(120)
|
||||||
.HasColumnType("TEXT");
|
.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")
|
b.Property<string>("Genus")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(120)
|
.HasMaxLength(120)
|
||||||
@@ -441,6 +457,8 @@ namespace plant_manager.Data.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ExternalSource", "ExternalId");
|
||||||
|
|
||||||
b.ToTable("PlantTaxa");
|
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? Cultivar { get; set; }
|
||||||
public string? Variety { 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) =>
|
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.NameExistsAsync<ActionResource>(resource => resource.Name.ToLower() == name.ToLower());
|
||||||
var exists = await db.ActionResources.AnyAsync(resource => resource.Name.ToLower() == name.ToLower());
|
|
||||||
if (exists)
|
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
|
var resource = new ActionResource
|
||||||
{
|
{
|
||||||
Name = name,
|
Name = name,
|
||||||
Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim()
|
Notes = EndpointHelpers.NormalizeOptional(request.Notes)
|
||||||
};
|
};
|
||||||
|
|
||||||
db.ActionResources.Add(resource);
|
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) =>
|
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);
|
var resource = await db.ActionResources.FindAsync(id);
|
||||||
@@ -58,16 +57,15 @@ namespace plant_manager.Endpoints
|
|||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
var name = request.Name.Trim();
|
var exists = await db.NameExistsAsync<ActionResource>(item =>
|
||||||
var exists = await db.ActionResources.AnyAsync(item =>
|
|
||||||
item.Id != id && item.Name.ToLower() == name.ToLower());
|
item.Id != id && item.Name.ToLower() == name.ToLower());
|
||||||
if (exists)
|
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.Name = name;
|
||||||
resource.Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim();
|
resource.Notes = EndpointHelpers.NormalizeOptional(request.Notes);
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
@@ -87,7 +85,7 @@ namespace plant_manager.Endpoints
|
|||||||
|| await db.RecipeComponents.AnyAsync(component => component.ActionResourceId == id);
|
|| await db.RecipeComponents.AnyAsync(component => component.ActionResourceId == id);
|
||||||
if (isInUse)
|
if (isInUse)
|
||||||
{
|
{
|
||||||
return Results.Conflict(new { error = "Resource is in use." });
|
return EndpointHelpers.Conflict("Resource is in use.");
|
||||||
}
|
}
|
||||||
|
|
||||||
db.ActionResources.Remove(resource);
|
db.ActionResources.Remove(resource);
|
||||||
|
|||||||
@@ -20,22 +20,21 @@ namespace plant_manager.Endpoints
|
|||||||
|
|
||||||
app.MapPost("/api/care-actions", async (SaveCareActionRequest request, ApplicationDbContext db) =>
|
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.NameExistsAsync<CareAction>(action => action.Name.ToLower() == name.ToLower());
|
||||||
var exists = await db.CareActions.AnyAsync(action => action.Name.ToLower() == name.ToLower());
|
|
||||||
if (exists)
|
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
|
var action = new CareAction
|
||||||
{
|
{
|
||||||
Name = name,
|
Name = name,
|
||||||
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim()
|
Description = EndpointHelpers.NormalizeOptional(request.Description)
|
||||||
};
|
};
|
||||||
|
|
||||||
db.CareActions.Add(action);
|
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) =>
|
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);
|
var action = await db.CareActions.FindAsync(id);
|
||||||
@@ -57,16 +56,15 @@ namespace plant_manager.Endpoints
|
|||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
var name = request.Name.Trim();
|
var exists = await db.NameExistsAsync<CareAction>(item =>
|
||||||
var exists = await db.CareActions.AnyAsync(item =>
|
|
||||||
item.Id != id && item.Name.ToLower() == name.ToLower());
|
item.Id != id && item.Name.ToLower() == name.ToLower());
|
||||||
if (exists)
|
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.Name = name;
|
||||||
action.Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim();
|
action.Description = EndpointHelpers.NormalizeOptional(request.Description);
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
@@ -84,7 +82,7 @@ namespace plant_manager.Endpoints
|
|||||||
var hasLogs = await db.ActionLogs.AnyAsync(log => log.CareActionId == id);
|
var hasLogs = await db.ActionLogs.AnyAsync(log => log.CareActionId == id);
|
||||||
if (hasLogs)
|
if (hasLogs)
|
||||||
{
|
{
|
||||||
return Results.Conflict(new { error = "Action has care history." });
|
return EndpointHelpers.Conflict("Action has care history.");
|
||||||
}
|
}
|
||||||
|
|
||||||
db.CareActions.Remove(action);
|
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) =>
|
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.NameExistsAsync<PlantFlagDefinition>(definition =>
|
||||||
var exists = await db.PlantFlagDefinitions.AnyAsync(definition =>
|
|
||||||
definition.Name.ToLower() == name.ToLower());
|
definition.Name.ToLower() == name.ToLower());
|
||||||
if (exists)
|
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
|
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) =>
|
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);
|
var definition = await db.PlantFlagDefinitions.FindAsync(id);
|
||||||
if (definition is null)
|
if (definition is null)
|
||||||
{
|
{
|
||||||
return Results.NotFound();
|
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());
|
item.Id != id && item.Name.ToLower() == name.ToLower());
|
||||||
if (exists)
|
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;
|
definition.Name = name;
|
||||||
@@ -85,7 +83,7 @@ namespace plant_manager.Endpoints
|
|||||||
var isUsed = await db.PlantFlags.AnyAsync(flag => flag.PlantFlagDefinitionId == id);
|
var isUsed = await db.PlantFlags.AnyAsync(flag => flag.PlantFlagDefinitionId == id);
|
||||||
if (isUsed)
|
if (isUsed)
|
||||||
{
|
{
|
||||||
return Results.Conflict(new { error = "Flag is assigned to plants." });
|
return EndpointHelpers.Conflict("Flag is assigned to plants.");
|
||||||
}
|
}
|
||||||
|
|
||||||
db.PlantFlagDefinitions.Remove(definition);
|
db.PlantFlagDefinitions.Remove(definition);
|
||||||
@@ -117,7 +115,7 @@ namespace plant_manager.Endpoints
|
|||||||
&& flag.ResolvedOn == null);
|
&& flag.ResolvedOn == null);
|
||||||
if (hasActiveFlag)
|
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
|
var flag = new PlantFlag
|
||||||
@@ -200,6 +198,6 @@ namespace plant_manager.Endpoints
|
|||||||
string.IsNullOrWhiteSpace(color) ? "#f2f2f2" : color.Trim();
|
string.IsNullOrWhiteSpace(color) ? "#f2f2f2" : color.Trim();
|
||||||
|
|
||||||
private static string? NormalizeNotes(string? notes) =>
|
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);
|
var validation = await ValidateRequest(request, db);
|
||||||
if (validation.Error is not null)
|
if (validation.Error is not null)
|
||||||
{
|
{
|
||||||
return Results.BadRequest(new { error = validation.Error });
|
return EndpointHelpers.BadRequest(validation.Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
var name = request.Name.Trim();
|
var name = validation.Name;
|
||||||
var exists = await db.PlantGroups.AnyAsync(group => group.Name.ToLower() == name.ToLower());
|
var exists = await db.NameExistsAsync<PlantGroup>(group => group.Name.ToLower() == name.ToLower());
|
||||||
if (exists)
|
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
|
var group = new PlantGroup
|
||||||
{
|
{
|
||||||
Name = name,
|
Name = name,
|
||||||
Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(),
|
Notes = EndpointHelpers.NormalizeOptional(request.Notes),
|
||||||
Memberships = validation.PlantIds
|
Memberships = validation.PlantIds
|
||||||
.Select(plantId => new PlantGroupMembership { PlantId = plantId })
|
.Select(plantId => new PlantGroupMembership { PlantId = plantId })
|
||||||
.ToList()
|
.ToList()
|
||||||
@@ -70,19 +70,19 @@ namespace plant_manager.Endpoints
|
|||||||
var validation = await ValidateRequest(request, db);
|
var validation = await ValidateRequest(request, db);
|
||||||
if (validation.Error is not null)
|
if (validation.Error is not null)
|
||||||
{
|
{
|
||||||
return Results.BadRequest(new { error = validation.Error });
|
return EndpointHelpers.BadRequest(validation.Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
var name = request.Name.Trim();
|
var name = validation.Name;
|
||||||
var exists = await db.PlantGroups.AnyAsync(item =>
|
var exists = await db.NameExistsAsync<PlantGroup>(item =>
|
||||||
item.Id != id && item.Name.ToLower() == name.ToLower());
|
item.Id != id && item.Name.ToLower() == name.ToLower());
|
||||||
if (exists)
|
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.Name = name;
|
||||||
group.Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim();
|
group.Notes = EndpointHelpers.NormalizeOptional(request.Notes);
|
||||||
db.PlantGroupMemberships.RemoveRange(group.Memberships);
|
db.PlantGroupMemberships.RemoveRange(group.Memberships);
|
||||||
group.Memberships = validation.PlantIds
|
group.Memberships = validation.PlantIds
|
||||||
.Select(plantId => new PlantGroupMembership
|
.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,
|
SavePlantGroupRequest request,
|
||||||
ApplicationDbContext db)
|
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?
|
var plantIds = request.PlantIds?
|
||||||
@@ -137,11 +137,11 @@ namespace plant_manager.Endpoints
|
|||||||
var existingPlantCount = await db.Plants.CountAsync(plant => plantIds.Contains(plant.Id));
|
var existingPlantCount = await db.Plants.CountAsync(plant => plantIds.Contains(plant.Id));
|
||||||
if (existingPlantCount != plantIds.Count)
|
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) =>
|
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.NameExistsAsync<PlantLocation>(location => location.Name.ToLower() == name.ToLower());
|
||||||
var exists = await db.PlantLocations.AnyAsync(location => location.Name.ToLower() == name.ToLower());
|
|
||||||
if (exists)
|
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
|
var location = new PlantLocation
|
||||||
{
|
{
|
||||||
Name = name,
|
Name = name,
|
||||||
Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim()
|
Notes = EndpointHelpers.NormalizeOptional(request.Notes)
|
||||||
};
|
};
|
||||||
|
|
||||||
db.PlantLocations.Add(location);
|
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) =>
|
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);
|
var location = await db.PlantLocations.FindAsync(id);
|
||||||
@@ -57,16 +56,15 @@ namespace plant_manager.Endpoints
|
|||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
var name = request.Name.Trim();
|
var exists = await db.NameExistsAsync<PlantLocation>(item =>
|
||||||
var exists = await db.PlantLocations.AnyAsync(item =>
|
|
||||||
item.Id != id && item.Name.ToLower() == name.ToLower());
|
item.Id != id && item.Name.ToLower() == name.ToLower());
|
||||||
if (exists)
|
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.Name = name;
|
||||||
location.Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim();
|
location.Notes = EndpointHelpers.NormalizeOptional(request.Notes);
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
@@ -84,7 +82,7 @@ namespace plant_manager.Endpoints
|
|||||||
var isInUse = await db.Plants.AnyAsync(plant => plant.LocationId == id);
|
var isInUse = await db.Plants.AnyAsync(plant => plant.LocationId == id);
|
||||||
if (isInUse)
|
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);
|
db.PlantLocations.Remove(location);
|
||||||
|
|||||||
@@ -20,21 +20,24 @@ namespace plant_manager.Endpoints
|
|||||||
|
|
||||||
app.MapPost("/api/plant-taxa", async (SavePlantTaxonRequest request, ApplicationDbContext db) =>
|
app.MapPost("/api/plant-taxa", async (SavePlantTaxonRequest request, ApplicationDbContext db) =>
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(request.Name)
|
var validation = ValidateTaxonRequest(request);
|
||||||
|| string.IsNullOrWhiteSpace(request.Genus)
|
if (validation.Error is not null)
|
||||||
|| string.IsNullOrWhiteSpace(request.Species))
|
|
||||||
{
|
{
|
||||||
return Results.BadRequest(new { error = "Name, genus, and species are required." });
|
return EndpointHelpers.BadRequest(validation.Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
var taxon = new PlantTaxon
|
var taxon = new PlantTaxon
|
||||||
{
|
{
|
||||||
Name = request.Name.Trim(),
|
Name = validation.Name,
|
||||||
Genus = request.Genus.Trim(),
|
Genus = validation.Genus,
|
||||||
Species = request.Species.Trim(),
|
Species = validation.Species,
|
||||||
Cultivar = string.IsNullOrWhiteSpace(request.Cultivar) ? null : request.Cultivar.Trim(),
|
Cultivar = validation.Cultivar,
|
||||||
Variety = string.IsNullOrWhiteSpace(request.Variety) ? null : request.Variety.Trim(),
|
Variety = validation.Variety,
|
||||||
Authority = string.IsNullOrWhiteSpace(request.Authority) ? null : request.Authority.Trim()
|
Authority = validation.Authority,
|
||||||
|
Family = validation.Family,
|
||||||
|
CommonName = validation.CommonName,
|
||||||
|
ExternalSource = validation.ExternalSource,
|
||||||
|
ExternalId = validation.ExternalId
|
||||||
};
|
};
|
||||||
|
|
||||||
db.PlantTaxa.Add(taxon);
|
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) =>
|
app.MapPut("/api/plant-taxa/{id:int}", async (int id, SavePlantTaxonRequest request, ApplicationDbContext db) =>
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(request.Name)
|
var validation = ValidateTaxonRequest(request);
|
||||||
|| string.IsNullOrWhiteSpace(request.Genus)
|
if (validation.Error is not null)
|
||||||
|| string.IsNullOrWhiteSpace(request.Species))
|
|
||||||
{
|
{
|
||||||
return Results.BadRequest(new { error = "Name, genus, and species are required." });
|
return EndpointHelpers.BadRequest(validation.Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
var taxon = await db.PlantTaxa.FindAsync(id);
|
var taxon = await db.PlantTaxa.FindAsync(id);
|
||||||
@@ -58,18 +60,76 @@ namespace plant_manager.Endpoints
|
|||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
taxon.Name = request.Name.Trim();
|
taxon.Name = validation.Name;
|
||||||
taxon.Genus = request.Genus.Trim();
|
taxon.Genus = validation.Genus;
|
||||||
taxon.Species = request.Species.Trim();
|
taxon.Species = validation.Species;
|
||||||
taxon.Cultivar = string.IsNullOrWhiteSpace(request.Cultivar) ? null : request.Cultivar.Trim();
|
taxon.Cultivar = validation.Cultivar;
|
||||||
taxon.Variety = string.IsNullOrWhiteSpace(request.Variety) ? null : request.Variety.Trim();
|
taxon.Variety = validation.Variety;
|
||||||
taxon.Authority = string.IsNullOrWhiteSpace(request.Authority) ? null : request.Authority.Trim();
|
taxon.Authority = validation.Authority;
|
||||||
|
taxon.Family = validation.Family;
|
||||||
|
taxon.CommonName = validation.CommonName;
|
||||||
|
taxon.ExternalSource = validation.ExternalSource;
|
||||||
|
taxon.ExternalId = validation.ExternalId;
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
return Results.Ok(PlantTaxonDto.FromTaxon(taxon));
|
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) =>
|
app.MapDelete("/api/plant-taxa/{id:int}", async (int id, ApplicationDbContext db) =>
|
||||||
{
|
{
|
||||||
var taxon = await db.PlantTaxa.FindAsync(id);
|
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);
|
var isUsedByPlant = await db.Plants.AnyAsync(plant => plant.TaxonId == id);
|
||||||
if (isUsedByPlant)
|
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);
|
db.PlantTaxa.Remove(taxon);
|
||||||
@@ -90,5 +150,55 @@ namespace plant_manager.Endpoints
|
|||||||
return Results.NoContent();
|
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-activities",
|
||||||
"/api/care-tasks/upcoming",
|
"/api/care-tasks/upcoming",
|
||||||
"/api/action-logs",
|
"/api/action-logs",
|
||||||
"/api/export/spreadsheet"
|
"/api/export/spreadsheet",
|
||||||
|
"/api/plant-info/search"
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using plant_manager.Data;
|
using plant_manager.Data;
|
||||||
using plant_manager.Endpoints;
|
using plant_manager.Endpoints;
|
||||||
|
using plant_manager.Services;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@@ -18,11 +19,16 @@ builder.Services.AddCors(options =>
|
|||||||
|
|
||||||
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
|
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
|
||||||
?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
|
?? 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"));
|
Directory.CreateDirectory(Path.Combine(builder.Environment.ContentRootPath, "App_Data"));
|
||||||
|
|
||||||
builder.Services.AddDbContext<ApplicationDbContext>(options =>
|
builder.Services.AddDbContext<ApplicationDbContext>(options =>
|
||||||
options.UseSqlite(connectionString));
|
options.UseSqlite(connectionString));
|
||||||
|
builder.Services.AddDbContext<PlantInfoDbContext>(options =>
|
||||||
|
options.UseSqlite(plantInfoConnectionString));
|
||||||
|
builder.Services.AddScoped<PlantInfoSearchService>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
@@ -37,6 +43,9 @@ using (var scope = app.Services.CreateScope())
|
|||||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
db.Database.Migrate();
|
db.Database.Migrate();
|
||||||
DatabaseSeeder.Seed(db);
|
DatabaseSeeder.Seed(db);
|
||||||
|
|
||||||
|
var plantInfoDb = scope.ServiceProvider.GetRequiredService<PlantInfoDbContext>();
|
||||||
|
await plantInfoDb.EnsureSearchSchemaAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
app.MapRootEndpoints();
|
app.MapRootEndpoints();
|
||||||
@@ -53,5 +62,6 @@ app.MapCareTaskEndpoints();
|
|||||||
app.MapActionLogEndpoints();
|
app.MapActionLogEndpoints();
|
||||||
app.MapPlantFlagEndpoints();
|
app.MapPlantFlagEndpoints();
|
||||||
app.MapExportEndpoints();
|
app.MapExportEndpoints();
|
||||||
|
app.MapPlantInfoEndpoints();
|
||||||
|
|
||||||
app.Run();
|
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": {
|
"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": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"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