add spreadsheet import/export

This commit is contained in:
2026-06-12 17:56:32 +00:00
parent a0348f701e
commit fadad33648
13 changed files with 919 additions and 15 deletions
+27 -2
View File
@@ -4,6 +4,7 @@ import { ActivitiesView } from './components/ActivitiesView';
import { FlagsView } from './components/FlagsView';
import { GroupsView } from './components/GroupsView';
import { HomeView } from './components/HomeView';
import { ImportExportView } from './components/ImportExportView';
import { LocationsView } from './components/LocationsView';
import { PlantManagementView } from './components/PlantManagementView';
import { PlantsView } from './components/PlantsView';
@@ -26,6 +27,7 @@ export function App() {
dueCount,
error,
isLoading,
loadDashboard,
loadCareModel,
loadFlagsAndPlants,
loadGroupsAndPlants,
@@ -145,10 +147,13 @@ export function App() {
} = editors;
const {
assignFlagToSelectedPlant,
applyCatalogImportFile,
catalogImportResult,
completeBulkTasks,
completeTask,
exportSpreadsheet,
isExporting,
isImportingCatalog,
isSearchingPlantInfo,
isSaving,
importTaxonFromPlantInfo,
@@ -178,11 +183,13 @@ export function App() {
saveResource,
saveTaxon,
searchTaxonInfo,
previewCatalogImportFile,
setManagedPlantLocation,
setManagedPlantTaxon,
setPlantInfoQuery,
} = useAppActions({
editors,
loadDashboard,
loadCareModel,
loadFlagsAndPlants,
loadGroupsAndPlants,
@@ -234,6 +241,13 @@ export function App() {
>
Plant Management
</button>
<button
type="button"
aria-current={view === 'import-export' ? 'page' : undefined}
onClick={() => setView('import-export')}
>
Import / Export
</button>
</div>
<div className="nav-group">
@@ -334,7 +348,6 @@ export function App() {
error={error}
form={form}
isLoading={isLoading}
isExporting={isExporting}
isPlantEditorOpen={isPlantEditorOpen}
isSaving={isSaving}
plants={plants}
@@ -343,7 +356,6 @@ export function App() {
onCloseDetail={() => setSelectedPlantId(null)}
onDelete={(plant) => void removePlant(plant)}
onEdit={startEditingPlant}
onExport={() => void exportSpreadsheet()}
onFieldChange={updateForm}
onNew={startAddingPlant}
onOpenDetail={openPlantReadOnlyDetail}
@@ -369,6 +381,16 @@ export function App() {
onSetLocation={setManagedPlantLocation}
onSetTaxon={setManagedPlantTaxon}
/>
) : view === 'import-export' ? (
<ImportExportView
catalogImportResult={catalogImportResult}
error={error}
isExporting={isExporting}
isImportingCatalog={isImportingCatalog}
onApplyCatalogImport={(file) => void applyCatalogImportFile(file)}
onExport={() => void exportSpreadsheet()}
onPreviewCatalogImport={(file) => void previewCatalogImportFile(file)}
/>
) : view === 'schedules' ? (
<SchedulesView
activities={careActivities}
@@ -569,6 +591,7 @@ function getViewEyebrow(view: View) {
return 'Operations';
case 'plants':
case 'plant-management':
case 'import-export':
return 'Operations';
case 'schedules':
return 'Care';
@@ -593,6 +616,8 @@ function getViewTitle(view: View) {
return 'Plants';
case 'plant-management':
return 'Plant Management';
case 'import-export':
return 'Import / Export';
case 'schedules':
return 'Scheduler';
case 'taxa':
+25
View File
@@ -5,6 +5,7 @@ import type {
CareActivityPayload,
CareAction,
CareActionPayload,
CatalogImportResult,
BulkCompleteCareTasksPayload,
CareTask,
BulkPlantCareSchedulePayload,
@@ -72,6 +73,30 @@ export async function downloadSpreadsheetExport() {
URL.revokeObjectURL(url);
}
async function uploadCatalogImport(path: string, file: File) {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${apiBaseUrl}${path}`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
return response.json() as Promise<CatalogImportResult>;
}
export async function previewCatalogImport(file: File) {
return uploadCatalogImport('/api/import/catalog/preview', file);
}
export async function applyCatalogImport(file: File) {
return uploadCatalogImport('/api/import/catalog/apply', file);
}
export async function getPlants() {
return request<Plant[]>('/api/plants');
}
@@ -0,0 +1,149 @@
import { Download, FileSearch, Upload } from 'lucide-react';
import { useState } from 'react';
import type { CatalogImportResult } from '../domain';
type ImportExportViewProps = {
catalogImportResult: CatalogImportResult | null;
error: string | null;
isExporting: boolean;
isImportingCatalog: boolean;
onApplyCatalogImport: (file: File) => void;
onExport: () => void;
onPreviewCatalogImport: (file: File) => void;
};
export function ImportExportView({
catalogImportResult,
error,
isExporting,
isImportingCatalog,
onApplyCatalogImport,
onExport,
onPreviewCatalogImport,
}: ImportExportViewProps) {
const [catalogImportFile, setCatalogImportFile] = useState<File | null>(null);
const [previewedCatalogImportFile, setPreviewedCatalogImportFile] = useState<File | null>(null);
const canApplyPreviewedFile = catalogImportFile !== null
&& previewedCatalogImportFile === catalogImportFile
&& catalogImportResult?.canApply === true;
return (
<>
<section className="summary-panel" aria-labelledby="import-export-summary-heading">
<div>
<p className="eyebrow">Data movement</p>
<h2 id="import-export-summary-heading">Import / Export</h2>
<p>{error ?? 'Move catalog data in and out of Plant-Man spreadsheets.'}</p>
</div>
<div className="summary-actions">
<button className="primary-action" type="button" disabled={isExporting} onClick={onExport}>
<Download size={18} />
{isExporting ? 'Exporting' : 'Export spreadsheet'}
</button>
</div>
</section>
<section className="editor-panel catalog-import-panel" aria-labelledby="catalog-import-heading">
<div className="section-heading">
<div>
<p className="eyebrow">Catalogs</p>
<h2 id="catalog-import-heading">Catalog Import</h2>
</div>
<div className="row-actions">
<button
className="small-action"
type="button"
disabled={isImportingCatalog || catalogImportFile === null}
onClick={() => {
if (catalogImportFile) {
setPreviewedCatalogImportFile(catalogImportFile);
onPreviewCatalogImport(catalogImportFile);
}
}}
>
<FileSearch size={16} />
Preview
</button>
<button
className="small-action"
type="button"
disabled={isImportingCatalog || !canApplyPreviewedFile}
onClick={() => catalogImportFile ? onApplyCatalogImport(catalogImportFile) : undefined}
>
<Upload size={16} />
Apply
</button>
</div>
</div>
<div className="plant-form import-file-row">
<label className="form-wide">
Workbook
<input
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
type="file"
onChange={(event) => {
setCatalogImportFile(event.target.files?.[0] ?? null);
setPreviewedCatalogImportFile(null);
}}
/>
</label>
</div>
{catalogImportResult ? (
<div className="import-preview">
<div className="plant-detail-meta compact-meta">
<div>
<span>Create</span>
<strong>{catalogImportResult.created}</strong>
</div>
<div>
<span>Update</span>
<strong>{catalogImportResult.updated}</strong>
</div>
<div>
<span>Skipped</span>
<strong>{catalogImportResult.skipped}</strong>
</div>
<div>
<span>Status</span>
<strong>{catalogImportResult.applied ? 'Applied' : catalogImportResult.canApply ? 'Ready' : 'Blocked'}</strong>
</div>
</div>
{catalogImportResult.sheets.length > 0 ? (
<div className="detail-list import-sheet-list">
{catalogImportResult.sheets.map((sheet) => (
<div className="detail-row import-row" key={sheet.sheet}>
<div>
<h4>{sheet.sheet}</h4>
<p>
{sheet.rows} rows - {sheet.creates} create - {sheet.updates} update - {sheet.skips} skipped
</p>
</div>
</div>
))}
</div>
) : null}
{catalogImportResult.issues.length > 0 ? (
<>
<p className="list-label">Issues</p>
<div className="detail-list import-issue-list">
{catalogImportResult.issues.slice(0, 12).map((issue, index) => (
<div className="detail-row import-row" key={`${issue.sheet}-${issue.row}-${issue.field}-${index}`}>
<div>
<h4>{issue.sheet}{issue.row > 0 ? ` row ${issue.row}` : ''}</h4>
<p>{issue.field}: {issue.message}</p>
</div>
</div>
))}
</div>
</>
) : null}
</div>
) : null}
</section>
</>
);
}
@@ -1,4 +1,4 @@
import { Download, Edit3, Eye, Plus, Save, Trash2, X } from 'lucide-react';
import { Edit3, Eye, Plus, Save, Trash2, X } from 'lucide-react';
import type { Plant } from '../domain';
import type { PlantFormState } from '../form-state';
@@ -7,7 +7,6 @@ type PlantsViewProps = {
error: string | null;
form: PlantFormState;
isLoading: boolean;
isExporting: boolean;
isPlantEditorOpen: boolean;
isSaving: boolean;
plants: Plant[];
@@ -16,7 +15,6 @@ type PlantsViewProps = {
onCloseDetail: () => void;
onDelete: (plant: Plant) => void;
onEdit: (plant: Plant) => void;
onExport: () => void;
onFieldChange: (field: keyof PlantFormState, value: string) => void;
onNew: () => void;
onOpenDetail: (plant: Plant) => void;
@@ -28,7 +26,6 @@ export function PlantsView({
error,
form,
isLoading,
isExporting,
isPlantEditorOpen,
isSaving,
plants,
@@ -37,7 +34,6 @@ export function PlantsView({
onCloseDetail,
onDelete,
onEdit,
onExport,
onFieldChange,
onNew,
onOpenDetail,
@@ -54,10 +50,6 @@ export function PlantsView({
<p>{error ?? 'Create and maintain plant objects.'}</p>
</div>
<div className="summary-actions">
<button className="primary-action" type="button" disabled={isExporting} onClick={onExport}>
<Download size={18} />
{isExporting ? 'Exporting' : 'Export'}
</button>
<button className="primary-action" type="button" onClick={onNew}>
<Plus size={18} />
New plant
+26
View File
@@ -69,6 +69,32 @@ export type PlantInfoSearchResult = {
commonNames: string[];
};
export type CatalogImportIssue = {
sheet: string;
row: number;
field: string;
message: string;
severity: string;
};
export type CatalogImportSheetSummary = {
sheet: string;
rows: number;
creates: number;
updates: number;
skips: number;
};
export type CatalogImportResult = {
applied: boolean;
canApply: boolean;
created: number;
updated: number;
skipped: number;
sheets: CatalogImportSheetSummary[];
issues: CatalogImportIssue[];
};
export type PlantLocation = {
id: number;
name: string;
+1 -1
View File
@@ -135,7 +135,7 @@ export type RecipeFormState = typeof emptyRecipeForm;
export type FlagDefinitionFormState = typeof emptyFlagDefinitionForm;
export type PlantFlagFormState = typeof emptyPlantFlagForm;
export type BulkScheduleFormState = typeof emptyBulkScheduleForm;
export type View = 'home' | 'plants' | 'plant-management' | 'schedules' | 'taxa' | 'locations' | 'groups' | 'actions' | 'resources' | 'recipes' | 'activities' | 'flags';
export type View = 'home' | 'plants' | 'plant-management' | 'schedules' | 'taxa' | 'locations' | 'groups' | 'actions' | 'resources' | 'recipes' | 'activities' | 'flags' | 'import-export';
export function toPlantForm(plant: Plant): PlantFormState {
return {
+22
View File
@@ -833,6 +833,28 @@ dd {
grid-column: 1 / -1;
}
.catalog-import-panel {
gap: 12px;
}
.import-file-row {
grid-template-columns: minmax(0, 1fr);
}
.import-preview {
display: grid;
gap: 10px;
}
.import-sheet-list,
.import-issue-list {
border-top: 1px solid var(--border);
}
.import-row {
grid-template-columns: minmax(0, 1fr);
}
.preserve-lines {
white-space: pre-wrap;
}
+42 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import {
applyCatalogImport,
assignPlantFlag,
completeCareTasksBulk,
createActionResource,
@@ -22,6 +23,7 @@ import {
deleteRecipe,
downloadSpreadsheetExport,
importPlantTaxon,
previewCatalogImport,
removePlantCareSchedulesBulk,
removePlantFlagAssignment,
resolvePlantFlag,
@@ -37,7 +39,7 @@ import {
updatePlantTaxon,
updateRecipe,
} from './api';
import type { CareTask, Plant, PlantFlag, PlantInfoSearchResult } from './domain';
import type { CareTask, CatalogImportResult, Plant, PlantFlag, PlantInfoSearchResult } from './domain';
import {
emptyBulkScheduleForm,
emptyPlantFlagForm,
@@ -64,6 +66,7 @@ type DashboardData = ReturnType<typeof useDashboardData>;
type AppActionOptions = {
editors: Editors;
loadDashboard: DashboardData['loadDashboard'];
loadCareModel: DashboardData['loadCareModel'];
loadFlagsAndPlants: DashboardData['loadFlagsAndPlants'];
loadGroupsAndPlants: DashboardData['loadGroupsAndPlants'];
@@ -77,6 +80,7 @@ type AppActionOptions = {
export function useAppActions({
editors,
loadDashboard,
loadCareModel,
loadFlagsAndPlants,
loadGroupsAndPlants,
@@ -89,6 +93,8 @@ export function useAppActions({
}: AppActionOptions) {
const [isSaving, setIsSaving] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [isImportingCatalog, setIsImportingCatalog] = useState(false);
const [catalogImportResult, setCatalogImportResult] = useState<CatalogImportResult | null>(null);
const [isSearchingPlantInfo, setIsSearchingPlantInfo] = useState(false);
const [plantInfoQuery, setPlantInfoQuery] = useState('');
const [plantInfoResults, setPlantInfoResults] = useState<PlantInfoSearchResult[]>([]);
@@ -224,6 +230,37 @@ export function useAppActions({
}
}
async function previewCatalogImportFile(file: File) {
setIsImportingCatalog(true);
try {
setError(null);
const result = await previewCatalogImport(file);
setCatalogImportResult(result);
} catch {
setError('Could not preview the catalog import.');
} finally {
setIsImportingCatalog(false);
}
}
async function applyCatalogImportFile(file: File) {
setIsImportingCatalog(true);
try {
setError(null);
const result = await applyCatalogImport(file);
setCatalogImportResult(result);
if (result.applied) {
await loadDashboard();
} else {
setError('Fix the spreadsheet issues before applying the import.');
}
} catch {
setError('Could not apply the catalog import.');
} finally {
setIsImportingCatalog(false);
}
}
async function saveTaxon() {
if (!editors.taxonForm.name.trim() || !editors.taxonForm.genus.trim() || !editors.taxonForm.species.trim()) {
setError('Name, genus, and species are required.');
@@ -773,9 +810,12 @@ export function useAppActions({
return {
assignFlagToSelectedPlant,
applyCatalogImportFile,
catalogImportResult,
completeBulkTasks,
completeTask,
exportSpreadsheet,
isImportingCatalog,
hasSearchedPlantInfo,
isExporting,
isSearchingPlantInfo,
@@ -806,6 +846,7 @@ export function useAppActions({
saveResource,
saveTaxon,
searchTaxonInfo,
previewCatalogImportFile,
setManagedPlantLocation,
setManagedPlantTaxon,
setPlantInfoQuery,
@@ -196,6 +196,7 @@ export function useDashboardData() {
dueCount,
error,
isLoading,
loadDashboard,
loadCareModel,
loadFlagsAndPlants,
loadGroupsAndPlants,
+23
View File
@@ -187,6 +187,29 @@ namespace plant_manager
string? Notes,
IReadOnlyList<ActionLogResourceRequest>? Resources);
public record CatalogImportIssue(
string Sheet,
int Row,
string Field,
string Message,
string Severity);
public record CatalogImportSheetSummary(
string Sheet,
int Rows,
int Creates,
int Updates,
int Skips);
public record CatalogImportResult(
bool Applied,
bool CanApply,
int Created,
int Updated,
int Skipped,
IReadOnlyList<CatalogImportSheetSummary> Sheets,
IReadOnlyList<CatalogImportIssue> Issues);
public record PlantTaxonDto(
int Id,
string Name,
+30 -2
View File
@@ -18,6 +18,7 @@ namespace plant_manager.Endpoints
await AddCareSchedulesSheet(workbook, db);
await AddActionLogsSheet(workbook, db);
await AddFlagsSheet(workbook, db);
await AddFlagDefinitionsSheet(workbook, db);
await AddGroupsSheet(workbook, db);
await AddTaxaSheet(workbook, db);
await AddLocationsSheet(workbook, db);
@@ -206,6 +207,25 @@ namespace plant_manager.Endpoints
], rows);
}
private static async Task AddFlagDefinitionsSheet(XLWorkbook workbook, ApplicationDbContext db)
{
var rows = await db.PlantFlagDefinitions
.OrderBy(definition => definition.Name)
.Select(definition => new object?[]
{
definition.Id,
definition.Name,
definition.Color
})
.ToListAsync();
AddSheet(workbook, "Flag Definitions", [
"ID",
"Name",
"Color"
], rows);
}
private static async Task AddGroupsSheet(XLWorkbook workbook, ApplicationDbContext db)
{
var groups = await db.PlantGroups
@@ -247,7 +267,11 @@ namespace plant_manager.Endpoints
taxon.Species,
taxon.Cultivar ?? "",
taxon.Variety ?? "",
taxon.Authority ?? ""
taxon.Authority ?? "",
taxon.Family ?? "",
taxon.CommonName ?? "",
taxon.ExternalSource ?? "",
taxon.ExternalId ?? ""
})
.ToListAsync();
@@ -258,7 +282,11 @@ namespace plant_manager.Endpoints
"Species",
"Cultivar",
"Variety",
"Authority"
"Authority",
"Family",
"Common Name",
"External Source",
"External ID"
], rows);
}
+571
View File
@@ -0,0 +1,571 @@
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");
}
await ImportTaxa(workbook, db, context, apply);
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 async Task ImportTaxa(
XLWorkbook workbook,
ApplicationDbContext db,
ImportContext context,
bool apply)
{
if (!TryGetWorksheet(workbook, TaxaSheet, out var worksheet))
{
return;
}
var rows = ReadRows(worksheet, TaxaSheet, context, requiredHeaders: ["Name", "Genus", "Species"]);
var existing = await db.PlantTaxa.ToListAsync();
var byName = existing.ToDictionary(taxon => Key(taxon.Name), StringComparer.OrdinalIgnoreCase);
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var summary = new MutableSummary(TaxaSheet);
foreach (var row in rows)
{
var name = Required(row, "Name", context);
var genus = Required(row, "Genus", context);
var species = Required(row, "Species", context);
if (name is null || genus is null || species is null)
{
summary.Skips++;
continue;
}
var key = Key(name);
if (!seen.Add(key))
{
Duplicate(row, "Name", context);
summary.Skips++;
continue;
}
var cultivar = Optional(row, "Cultivar");
var variety = Optional(row, "Variety");
var authority = Optional(row, "Authority");
var family = Optional(row, "Family");
var commonName = Optional(row, "Common Name", "CommonName");
var externalSource = Optional(row, "External Source", "Source");
var externalId = Optional(row, "External ID", "External Id", "GBIF ID");
if (byName.TryGetValue(key, out var taxon))
{
summary.Updates++;
if (apply)
{
taxon.Name = name;
taxon.Genus = genus;
taxon.Species = species;
taxon.Cultivar = cultivar;
taxon.Variety = variety;
taxon.Authority = authority;
taxon.Family = family;
taxon.CommonName = commonName;
taxon.ExternalSource = externalSource;
taxon.ExternalId = externalId;
}
}
else
{
summary.Creates++;
if (apply)
{
db.PlantTaxa.Add(new PlantTaxon
{
Name = name,
Genus = genus,
Species = species,
Cultivar = cultivar,
Variety = variety,
Authority = authority,
Family = family,
CommonName = commonName,
ExternalSource = externalSource,
ExternalId = externalId
});
}
}
}
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; }
}
}
}
+1
View File
@@ -61,6 +61,7 @@ app.MapCareTaskEndpoints();
app.MapActionLogEndpoints();
app.MapPlantFlagEndpoints();
app.MapExportEndpoints();
app.MapImportEndpoints();
app.MapPlantInfoEndpoints();
app.Run();