add spreadsheet import/export
This commit is contained in:
@@ -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':
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,7 @@ app.MapCareTaskEndpoints();
|
||||
app.MapActionLogEndpoints();
|
||||
app.MapPlantFlagEndpoints();
|
||||
app.MapExportEndpoints();
|
||||
app.MapImportEndpoints();
|
||||
app.MapPlantInfoEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
Reference in New Issue
Block a user