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 { FlagsView } from './components/FlagsView';
import { GroupsView } from './components/GroupsView'; import { GroupsView } from './components/GroupsView';
import { HomeView } from './components/HomeView'; import { HomeView } from './components/HomeView';
import { ImportExportView } from './components/ImportExportView';
import { LocationsView } from './components/LocationsView'; import { LocationsView } from './components/LocationsView';
import { PlantManagementView } from './components/PlantManagementView'; import { PlantManagementView } from './components/PlantManagementView';
import { PlantsView } from './components/PlantsView'; import { PlantsView } from './components/PlantsView';
@@ -26,6 +27,7 @@ export function App() {
dueCount, dueCount,
error, error,
isLoading, isLoading,
loadDashboard,
loadCareModel, loadCareModel,
loadFlagsAndPlants, loadFlagsAndPlants,
loadGroupsAndPlants, loadGroupsAndPlants,
@@ -145,10 +147,13 @@ export function App() {
} = editors; } = editors;
const { const {
assignFlagToSelectedPlant, assignFlagToSelectedPlant,
applyCatalogImportFile,
catalogImportResult,
completeBulkTasks, completeBulkTasks,
completeTask, completeTask,
exportSpreadsheet, exportSpreadsheet,
isExporting, isExporting,
isImportingCatalog,
isSearchingPlantInfo, isSearchingPlantInfo,
isSaving, isSaving,
importTaxonFromPlantInfo, importTaxonFromPlantInfo,
@@ -178,11 +183,13 @@ export function App() {
saveResource, saveResource,
saveTaxon, saveTaxon,
searchTaxonInfo, searchTaxonInfo,
previewCatalogImportFile,
setManagedPlantLocation, setManagedPlantLocation,
setManagedPlantTaxon, setManagedPlantTaxon,
setPlantInfoQuery, setPlantInfoQuery,
} = useAppActions({ } = useAppActions({
editors, editors,
loadDashboard,
loadCareModel, loadCareModel,
loadFlagsAndPlants, loadFlagsAndPlants,
loadGroupsAndPlants, loadGroupsAndPlants,
@@ -234,6 +241,13 @@ export function App() {
> >
Plant Management Plant Management
</button> </button>
<button
type="button"
aria-current={view === 'import-export' ? 'page' : undefined}
onClick={() => setView('import-export')}
>
Import / Export
</button>
</div> </div>
<div className="nav-group"> <div className="nav-group">
@@ -334,7 +348,6 @@ export function App() {
error={error} error={error}
form={form} form={form}
isLoading={isLoading} isLoading={isLoading}
isExporting={isExporting}
isPlantEditorOpen={isPlantEditorOpen} isPlantEditorOpen={isPlantEditorOpen}
isSaving={isSaving} isSaving={isSaving}
plants={plants} plants={plants}
@@ -343,7 +356,6 @@ export function App() {
onCloseDetail={() => setSelectedPlantId(null)} onCloseDetail={() => setSelectedPlantId(null)}
onDelete={(plant) => void removePlant(plant)} onDelete={(plant) => void removePlant(plant)}
onEdit={startEditingPlant} onEdit={startEditingPlant}
onExport={() => void exportSpreadsheet()}
onFieldChange={updateForm} onFieldChange={updateForm}
onNew={startAddingPlant} onNew={startAddingPlant}
onOpenDetail={openPlantReadOnlyDetail} onOpenDetail={openPlantReadOnlyDetail}
@@ -369,6 +381,16 @@ export function App() {
onSetLocation={setManagedPlantLocation} onSetLocation={setManagedPlantLocation}
onSetTaxon={setManagedPlantTaxon} 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' ? ( ) : view === 'schedules' ? (
<SchedulesView <SchedulesView
activities={careActivities} activities={careActivities}
@@ -569,6 +591,7 @@ function getViewEyebrow(view: View) {
return 'Operations'; return 'Operations';
case 'plants': case 'plants':
case 'plant-management': case 'plant-management':
case 'import-export':
return 'Operations'; return 'Operations';
case 'schedules': case 'schedules':
return 'Care'; return 'Care';
@@ -593,6 +616,8 @@ function getViewTitle(view: View) {
return 'Plants'; return 'Plants';
case 'plant-management': case 'plant-management':
return 'Plant Management'; return 'Plant Management';
case 'import-export':
return 'Import / Export';
case 'schedules': case 'schedules':
return 'Scheduler'; return 'Scheduler';
case 'taxa': case 'taxa':
+25
View File
@@ -5,6 +5,7 @@ import type {
CareActivityPayload, CareActivityPayload,
CareAction, CareAction,
CareActionPayload, CareActionPayload,
CatalogImportResult,
BulkCompleteCareTasksPayload, BulkCompleteCareTasksPayload,
CareTask, CareTask,
BulkPlantCareSchedulePayload, BulkPlantCareSchedulePayload,
@@ -72,6 +73,30 @@ export async function downloadSpreadsheetExport() {
URL.revokeObjectURL(url); 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() { export async function getPlants() {
return request<Plant[]>('/api/plants'); 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 { Plant } from '../domain';
import type { PlantFormState } from '../form-state'; import type { PlantFormState } from '../form-state';
@@ -7,7 +7,6 @@ type PlantsViewProps = {
error: string | null; error: string | null;
form: PlantFormState; form: PlantFormState;
isLoading: boolean; isLoading: boolean;
isExporting: boolean;
isPlantEditorOpen: boolean; isPlantEditorOpen: boolean;
isSaving: boolean; isSaving: boolean;
plants: Plant[]; plants: Plant[];
@@ -16,7 +15,6 @@ type PlantsViewProps = {
onCloseDetail: () => void; onCloseDetail: () => void;
onDelete: (plant: Plant) => void; onDelete: (plant: Plant) => void;
onEdit: (plant: Plant) => void; onEdit: (plant: Plant) => void;
onExport: () => void;
onFieldChange: (field: keyof PlantFormState, value: string) => void; onFieldChange: (field: keyof PlantFormState, value: string) => void;
onNew: () => void; onNew: () => void;
onOpenDetail: (plant: Plant) => void; onOpenDetail: (plant: Plant) => void;
@@ -28,7 +26,6 @@ export function PlantsView({
error, error,
form, form,
isLoading, isLoading,
isExporting,
isPlantEditorOpen, isPlantEditorOpen,
isSaving, isSaving,
plants, plants,
@@ -37,7 +34,6 @@ export function PlantsView({
onCloseDetail, onCloseDetail,
onDelete, onDelete,
onEdit, onEdit,
onExport,
onFieldChange, onFieldChange,
onNew, onNew,
onOpenDetail, onOpenDetail,
@@ -54,10 +50,6 @@ export function PlantsView({
<p>{error ?? 'Create and maintain plant objects.'}</p> <p>{error ?? 'Create and maintain plant objects.'}</p>
</div> </div>
<div className="summary-actions"> <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}> <button className="primary-action" type="button" onClick={onNew}>
<Plus size={18} /> <Plus size={18} />
New plant New plant
+26
View File
@@ -69,6 +69,32 @@ export type PlantInfoSearchResult = {
commonNames: string[]; 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 = { export type PlantLocation = {
id: number; id: number;
name: string; name: string;
+1 -1
View File
@@ -135,7 +135,7 @@ export type RecipeFormState = typeof emptyRecipeForm;
export type FlagDefinitionFormState = typeof emptyFlagDefinitionForm; export type FlagDefinitionFormState = typeof emptyFlagDefinitionForm;
export type PlantFlagFormState = typeof emptyPlantFlagForm; export type PlantFlagFormState = typeof emptyPlantFlagForm;
export type BulkScheduleFormState = typeof emptyBulkScheduleForm; 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 { export function toPlantForm(plant: Plant): PlantFormState {
return { return {
+22
View File
@@ -833,6 +833,28 @@ dd {
grid-column: 1 / -1; 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 { .preserve-lines {
white-space: pre-wrap; white-space: pre-wrap;
} }
+42 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { import {
applyCatalogImport,
assignPlantFlag, assignPlantFlag,
completeCareTasksBulk, completeCareTasksBulk,
createActionResource, createActionResource,
@@ -22,6 +23,7 @@ import {
deleteRecipe, deleteRecipe,
downloadSpreadsheetExport, downloadSpreadsheetExport,
importPlantTaxon, importPlantTaxon,
previewCatalogImport,
removePlantCareSchedulesBulk, removePlantCareSchedulesBulk,
removePlantFlagAssignment, removePlantFlagAssignment,
resolvePlantFlag, resolvePlantFlag,
@@ -37,7 +39,7 @@ import {
updatePlantTaxon, updatePlantTaxon,
updateRecipe, updateRecipe,
} from './api'; } from './api';
import type { CareTask, Plant, PlantFlag, PlantInfoSearchResult } from './domain'; import type { CareTask, CatalogImportResult, Plant, PlantFlag, PlantInfoSearchResult } from './domain';
import { import {
emptyBulkScheduleForm, emptyBulkScheduleForm,
emptyPlantFlagForm, emptyPlantFlagForm,
@@ -64,6 +66,7 @@ type DashboardData = ReturnType<typeof useDashboardData>;
type AppActionOptions = { type AppActionOptions = {
editors: Editors; editors: Editors;
loadDashboard: DashboardData['loadDashboard'];
loadCareModel: DashboardData['loadCareModel']; loadCareModel: DashboardData['loadCareModel'];
loadFlagsAndPlants: DashboardData['loadFlagsAndPlants']; loadFlagsAndPlants: DashboardData['loadFlagsAndPlants'];
loadGroupsAndPlants: DashboardData['loadGroupsAndPlants']; loadGroupsAndPlants: DashboardData['loadGroupsAndPlants'];
@@ -77,6 +80,7 @@ type AppActionOptions = {
export function useAppActions({ export function useAppActions({
editors, editors,
loadDashboard,
loadCareModel, loadCareModel,
loadFlagsAndPlants, loadFlagsAndPlants,
loadGroupsAndPlants, loadGroupsAndPlants,
@@ -89,6 +93,8 @@ export function useAppActions({
}: AppActionOptions) { }: AppActionOptions) {
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isExporting, setIsExporting] = 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 [isSearchingPlantInfo, setIsSearchingPlantInfo] = useState(false);
const [plantInfoQuery, setPlantInfoQuery] = useState(''); const [plantInfoQuery, setPlantInfoQuery] = useState('');
const [plantInfoResults, setPlantInfoResults] = useState<PlantInfoSearchResult[]>([]); 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() { async function saveTaxon() {
if (!editors.taxonForm.name.trim() || !editors.taxonForm.genus.trim() || !editors.taxonForm.species.trim()) { if (!editors.taxonForm.name.trim() || !editors.taxonForm.genus.trim() || !editors.taxonForm.species.trim()) {
setError('Name, genus, and species are required.'); setError('Name, genus, and species are required.');
@@ -773,9 +810,12 @@ export function useAppActions({
return { return {
assignFlagToSelectedPlant, assignFlagToSelectedPlant,
applyCatalogImportFile,
catalogImportResult,
completeBulkTasks, completeBulkTasks,
completeTask, completeTask,
exportSpreadsheet, exportSpreadsheet,
isImportingCatalog,
hasSearchedPlantInfo, hasSearchedPlantInfo,
isExporting, isExporting,
isSearchingPlantInfo, isSearchingPlantInfo,
@@ -806,6 +846,7 @@ export function useAppActions({
saveResource, saveResource,
saveTaxon, saveTaxon,
searchTaxonInfo, searchTaxonInfo,
previewCatalogImportFile,
setManagedPlantLocation, setManagedPlantLocation,
setManagedPlantTaxon, setManagedPlantTaxon,
setPlantInfoQuery, setPlantInfoQuery,
@@ -196,6 +196,7 @@ export function useDashboardData() {
dueCount, dueCount,
error, error,
isLoading, isLoading,
loadDashboard,
loadCareModel, loadCareModel,
loadFlagsAndPlants, loadFlagsAndPlants,
loadGroupsAndPlants, loadGroupsAndPlants,
+23
View File
@@ -187,6 +187,29 @@ namespace plant_manager
string? Notes, string? Notes,
IReadOnlyList<ActionLogResourceRequest>? Resources); 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( public record PlantTaxonDto(
int Id, int Id,
string Name, string Name,
+30 -2
View File
@@ -18,6 +18,7 @@ namespace plant_manager.Endpoints
await AddCareSchedulesSheet(workbook, db); await AddCareSchedulesSheet(workbook, db);
await AddActionLogsSheet(workbook, db); await AddActionLogsSheet(workbook, db);
await AddFlagsSheet(workbook, db); await AddFlagsSheet(workbook, db);
await AddFlagDefinitionsSheet(workbook, db);
await AddGroupsSheet(workbook, db); await AddGroupsSheet(workbook, db);
await AddTaxaSheet(workbook, db); await AddTaxaSheet(workbook, db);
await AddLocationsSheet(workbook, db); await AddLocationsSheet(workbook, db);
@@ -206,6 +207,25 @@ namespace plant_manager.Endpoints
], rows); ], 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) private static async Task AddGroupsSheet(XLWorkbook workbook, ApplicationDbContext db)
{ {
var groups = await db.PlantGroups var groups = await db.PlantGroups
@@ -247,7 +267,11 @@ namespace plant_manager.Endpoints
taxon.Species, taxon.Species,
taxon.Cultivar ?? "", taxon.Cultivar ?? "",
taxon.Variety ?? "", taxon.Variety ?? "",
taxon.Authority ?? "" taxon.Authority ?? "",
taxon.Family ?? "",
taxon.CommonName ?? "",
taxon.ExternalSource ?? "",
taxon.ExternalId ?? ""
}) })
.ToListAsync(); .ToListAsync();
@@ -258,7 +282,11 @@ namespace plant_manager.Endpoints
"Species", "Species",
"Cultivar", "Cultivar",
"Variety", "Variety",
"Authority" "Authority",
"Family",
"Common Name",
"External Source",
"External ID"
], rows); ], 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.MapActionLogEndpoints();
app.MapPlantFlagEndpoints(); app.MapPlantFlagEndpoints();
app.MapExportEndpoints(); app.MapExportEndpoints();
app.MapImportEndpoints();
app.MapPlantInfoEndpoints(); app.MapPlantInfoEndpoints();
app.Run(); app.Run();