Files
plant-man/plant-manager/Endpoints/ImportEndpoints.cs
T

512 lines
17 KiB
C#

using ClosedXML.Excel;
using Microsoft.EntityFrameworkCore;
using plant_manager.Data;
using plant_manager.Data.Models;
namespace plant_manager.Endpoints
{
public static class ImportEndpoints
{
private const string CareActionsSheet = "Actions";
private const string ResourcesSheet = "Resources";
private const string LocationsSheet = "Locations";
private const string FlagsSheet = "Flags";
private const string FlagDefinitionsSheet = "Flag Definitions";
private const string TaxaSheet = "Taxa";
public static void MapImportEndpoints(this WebApplication app)
{
app.MapPost("/api/import/catalog/preview", async (IFormFile file, ApplicationDbContext db) =>
{
if (file.Length == 0)
{
return EndpointHelpers.BadRequest("Choose a spreadsheet to import.");
}
try
{
var result = await ImportCatalog(file, db, apply: false);
return Results.Ok(result);
}
catch (Exception)
{
return EndpointHelpers.BadRequest("Could not read the spreadsheet.");
}
}).DisableAntiforgery();
app.MapPost("/api/import/catalog/apply", async (IFormFile file, ApplicationDbContext db) =>
{
if (file.Length == 0)
{
return EndpointHelpers.BadRequest("Choose a spreadsheet to import.");
}
try
{
var result = await ImportCatalog(file, db, apply: true);
return Results.Ok(result);
}
catch (Exception)
{
return EndpointHelpers.BadRequest("Could not read the spreadsheet.");
}
}).DisableAntiforgery();
}
private static async Task<CatalogImportResult> ImportCatalog(IFormFile file, ApplicationDbContext db, bool apply)
{
using var workbook = new XLWorkbook(file.OpenReadStream());
var context = new ImportContext();
await ImportCareActions(workbook, db, context, apply);
await ImportResources(workbook, db, context, apply);
await ImportLocations(workbook, db, context, apply);
if (TryGetWorksheet(workbook, FlagDefinitionsSheet, out _))
{
await ImportFlags(workbook, db, context, apply, FlagDefinitionsSheet, "Name");
}
else
{
await ImportFlags(workbook, db, context, apply, FlagsSheet, "Flag");
}
ImportTaxa(workbook, context);
if (context.Sheets.Count == 0)
{
context.Issues.Add(new CatalogImportIssue(
"Workbook",
0,
"Sheets",
"No supported catalog sheets were found.",
"error"));
}
var canApply = context.Issues.All(issue => issue.Severity != "error");
if (apply && canApply)
{
await db.SaveChangesAsync();
}
return new CatalogImportResult(
Applied: apply && canApply,
CanApply: canApply,
Created: context.Sheets.Sum(sheet => sheet.Creates),
Updated: context.Sheets.Sum(sheet => sheet.Updates),
Skipped: context.Sheets.Sum(sheet => sheet.Skips),
Sheets: context.Sheets,
Issues: context.Issues);
}
private static async Task ImportCareActions(
XLWorkbook workbook,
ApplicationDbContext db,
ImportContext context,
bool apply)
{
if (!TryGetWorksheet(workbook, CareActionsSheet, out var worksheet))
{
return;
}
var rows = ReadRows(worksheet, CareActionsSheet, context, requiredHeaders: ["Name"]);
var existing = await db.CareActions.ToListAsync();
var byName = existing.ToDictionary(action => Key(action.Name), StringComparer.OrdinalIgnoreCase);
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var summary = new MutableSummary(CareActionsSheet);
foreach (var row in rows)
{
var name = Required(row, "Name", context);
if (name is null)
{
summary.Skips++;
continue;
}
var key = Key(name);
if (!seen.Add(key))
{
Duplicate(row, "Name", context);
summary.Skips++;
continue;
}
var description = Optional(row, "Description");
if (byName.TryGetValue(key, out var action))
{
summary.Updates++;
if (apply)
{
action.Name = name;
action.Description = description;
}
}
else
{
summary.Creates++;
if (apply)
{
db.CareActions.Add(new CareAction
{
Name = name,
Description = description
});
}
}
}
AddSummary(context, summary);
}
private static async Task ImportResources(
XLWorkbook workbook,
ApplicationDbContext db,
ImportContext context,
bool apply)
{
if (!TryGetWorksheet(workbook, ResourcesSheet, out var worksheet))
{
return;
}
var rows = ReadRows(worksheet, ResourcesSheet, context, requiredHeaders: ["Name"]);
var existing = await db.ActionResources.ToListAsync();
var byName = existing.ToDictionary(resource => Key(resource.Name), StringComparer.OrdinalIgnoreCase);
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var summary = new MutableSummary(ResourcesSheet);
foreach (var row in rows)
{
var name = Required(row, "Name", context);
if (name is null)
{
summary.Skips++;
continue;
}
var key = Key(name);
if (!seen.Add(key))
{
Duplicate(row, "Name", context);
summary.Skips++;
continue;
}
var notes = Optional(row, "Notes");
if (byName.TryGetValue(key, out var resource))
{
summary.Updates++;
if (apply)
{
resource.Name = name;
resource.Notes = notes;
}
}
else
{
summary.Creates++;
if (apply)
{
db.ActionResources.Add(new ActionResource
{
Name = name,
Notes = notes
});
}
}
}
AddSummary(context, summary);
}
private static async Task ImportLocations(
XLWorkbook workbook,
ApplicationDbContext db,
ImportContext context,
bool apply)
{
if (!TryGetWorksheet(workbook, LocationsSheet, out var worksheet))
{
return;
}
var rows = ReadRows(worksheet, LocationsSheet, context, requiredHeaders: ["Name"]);
var existing = await db.PlantLocations.ToListAsync();
var byName = existing.ToDictionary(location => Key(location.Name), StringComparer.OrdinalIgnoreCase);
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var summary = new MutableSummary(LocationsSheet);
foreach (var row in rows)
{
var name = Required(row, "Name", context);
if (name is null)
{
summary.Skips++;
continue;
}
var key = Key(name);
if (!seen.Add(key))
{
Duplicate(row, "Name", context);
summary.Skips++;
continue;
}
var notes = Optional(row, "Notes");
if (byName.TryGetValue(key, out var location))
{
summary.Updates++;
if (apply)
{
location.Name = name;
location.Notes = notes;
}
}
else
{
summary.Creates++;
if (apply)
{
db.PlantLocations.Add(new PlantLocation
{
Name = name,
Notes = notes
});
}
}
}
AddSummary(context, summary);
}
private static async Task ImportFlags(
XLWorkbook workbook,
ApplicationDbContext db,
ImportContext context,
bool apply,
string sheetName,
string nameHeader)
{
if (!TryGetWorksheet(workbook, sheetName, out var worksheet))
{
return;
}
var rows = ReadRows(worksheet, sheetName, context, requiredHeaders: [nameHeader]);
var existing = await db.PlantFlagDefinitions.ToListAsync();
var byName = existing.ToDictionary(definition => Key(definition.Name), StringComparer.OrdinalIgnoreCase);
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var summary = new MutableSummary(sheetName);
foreach (var row in rows)
{
var name = Required(row, nameHeader, context);
if (name is null)
{
summary.Skips++;
continue;
}
var key = Key(name);
if (!seen.Add(key))
{
Duplicate(row, nameHeader, context);
summary.Skips++;
continue;
}
var color = Optional(row, "Color") ?? "#f2f2f2";
if (byName.TryGetValue(key, out var definition))
{
summary.Updates++;
if (apply)
{
definition.Name = name;
definition.Color = color;
}
}
else
{
summary.Creates++;
if (apply)
{
db.PlantFlagDefinitions.Add(new PlantFlagDefinition
{
Name = name,
Color = color
});
}
}
}
AddSummary(context, summary);
}
private static void ImportTaxa(
XLWorkbook workbook,
ImportContext context)
{
if (!TryGetWorksheet(workbook, TaxaSheet, out var worksheet))
{
return;
}
var rows = ReadRows(worksheet, TaxaSheet, context, requiredHeaders: []);
var summary = new MutableSummary(TaxaSheet);
foreach (var row in rows)
{
context.Issues.Add(new CatalogImportIssue(
TaxaSheet,
row.Row.RowNumber(),
"Taxa",
"Taxa must be imported from GBIF search results, not spreadsheet rows.",
"error"));
summary.Skips++;
}
AddSummary(context, summary);
}
private static IReadOnlyList<ImportRow> ReadRows(
IXLWorksheet worksheet,
string sheetName,
ImportContext context,
IReadOnlyList<string> requiredHeaders)
{
var headerRow = worksheet.FirstRowUsed();
if (headerRow is null)
{
return [];
}
var columns = headerRow.CellsUsed()
.Select(cell => new
{
Header = NormalizeHeader(cell.GetString()),
Column = cell.Address.ColumnNumber
})
.Where(header => header.Header.Length > 0)
.GroupBy(header => header.Header)
.ToDictionary(group => group.Key, group => group.First().Column);
foreach (var requiredHeader in requiredHeaders)
{
if (!columns.ContainsKey(NormalizeHeader(requiredHeader)))
{
context.Issues.Add(new CatalogImportIssue(
sheetName,
headerRow.RowNumber(),
requiredHeader,
$"Missing required column '{requiredHeader}'.",
"error"));
}
}
var rows = new List<ImportRow>();
var lastRow = worksheet.LastRowUsed()?.RowNumber() ?? headerRow.RowNumber();
for (var rowNumber = headerRow.RowNumber() + 1; rowNumber <= lastRow; rowNumber++)
{
var row = worksheet.Row(rowNumber);
if (row.Cells().All(cell => string.IsNullOrWhiteSpace(cell.GetFormattedString())))
{
continue;
}
rows.Add(new ImportRow(sheetName, rowNumber, row, columns));
}
return rows;
}
private static string? Required(ImportRow row, string field, ImportContext context)
{
var value = Optional(row, field);
if (value is not null)
{
return value;
}
context.Issues.Add(new CatalogImportIssue(
row.Sheet,
row.RowNumber,
field,
$"{field} is required.",
"error"));
return null;
}
private static string? Optional(ImportRow row, params string[] fields)
{
foreach (var field in fields)
{
if (!row.Columns.TryGetValue(NormalizeHeader(field), out var column))
{
continue;
}
var value = row.Row.Cell(column).GetFormattedString().Trim();
if (!string.IsNullOrWhiteSpace(value))
{
return value;
}
}
return null;
}
private static void Duplicate(ImportRow row, string field, ImportContext context)
{
context.Issues.Add(new CatalogImportIssue(
row.Sheet,
row.RowNumber,
field,
$"Duplicate {field.ToLowerInvariant()} in this spreadsheet.",
"error"));
}
private static bool TryGetWorksheet(XLWorkbook workbook, string sheetName, out IXLWorksheet worksheet) =>
workbook.Worksheets.TryGetWorksheet(sheetName, out worksheet!);
private static string Key(string value) =>
value.Trim();
private static string NormalizeHeader(string header) =>
new(header
.Where(char.IsLetterOrDigit)
.Select(char.ToLowerInvariant)
.ToArray());
private static void AddSummary(ImportContext context, MutableSummary summary)
{
context.Sheets.Add(new CatalogImportSheetSummary(
summary.Sheet,
summary.Creates + summary.Updates + summary.Skips,
summary.Creates,
summary.Updates,
summary.Skips));
}
private sealed class ImportContext
{
public List<CatalogImportSheetSummary> Sheets { get; } = [];
public List<CatalogImportIssue> Issues { get; } = [];
}
private sealed record ImportRow(
string Sheet,
int RowNumber,
IXLRow Row,
IReadOnlyDictionary<string, int> Columns);
private sealed class MutableSummary(string sheet)
{
public string Sheet { get; } = sheet;
public int Creates { get; set; }
public int Updates { get; set; }
public int Skips { get; set; }
}
}
}