Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ body:
attributes:
label: ALPR Community version
description: Find this under Settings → Release. Include the source revision there when reporting an installation or update problem.
placeholder: v0.1.56
placeholder: v0.1.57
validations:
required: true

Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## 0.1.57 — 2026-10-21

- Enable batch correction and recurring OCR-misread aliases by default for administrators; retain the batch preview and unchanged-plate safeguards. Operators retain single-read correction.
- Keep long tag menus within the available screen space and allow scrolling to every tag in tables and the image detail modal.
- Add an optional State/Country column to Recognition Feed and Plate Database, off by default under Settings → General. Operators and administrators enter values manually or select a previous entry.
- Share manually entered State/Country values across reads of the same effective plate, validate and audit changes, and provide indexed, case-insensitive saved-value suggestions. Existing records start without a value; OCR does not populate it.
- Update User Guide 3.25, release notes, Settings help coverage and current installation/update examples.

## 0.1.56 — 2026-10-21

- Export the original Docker ALPR database from Windows Setup before creating a
Expand Down
35 changes: 35 additions & 0 deletions app/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
updateTagName,
addUnseenPlate,
} from "@/lib/db";
import { savePlateStateCountry, listPlateStateCountries } from "@/lib/plate-state-country.mjs";
import { normalizePlateMatchingSettings } from "@/lib/plate-matching.mjs";
import { normalizeVehicleImageSource, vehicleImageSourceData } from "@/lib/vehicle-image-source.mjs";
import { getPlateReviewRepository } from "@/lib/plate-review-runtime.mjs";
Expand Down Expand Up @@ -2738,6 +2739,7 @@ export async function getPlateViewSettings() {
const config = await getConfig();
return {
plateMatching: normalizePlateMatchingSettings(config.plateMatching),
showStateCountry: config.general?.showStateCountry === true,
blueiris: {
host: config.blueiris?.host || "",
},
Expand Down Expand Up @@ -2793,6 +2795,13 @@ export async function updateSettings(formData) {
};
}

if (updateIfExists("showStateCountry")) {
newConfig.general = {
...newConfig.general,
showStateCountry: formData.get("showStateCountry") === "true",
};
}

if (
updateIfExists("dbHost") ||
updateIfExists("dbName") ||
Expand Down Expand Up @@ -2967,6 +2976,9 @@ export async function updateSettings(formData) {
}

revalidatePath("/settings");
revalidatePath("/settings/general");
revalidatePath("/live_feed");
revalidatePath("/database");
revalidatePath("/settings/integrations");
revalidatePath("/settings/integrations/pushover");
revalidatePath("/settings/integrations/email");
Expand Down Expand Up @@ -4906,3 +4918,26 @@ export async function createVehicleReidV2ProfileCandidateSnapshot() {
);
}
}

export async function updatePlateStateCountry(plateNumber, stateCountry) {
const principal = await requirePermission("plate.review");
try {
const data = await savePlateStateCountry(getPool, { plateNumber, stateCountry, actor: principal });
revalidatePath("/live_feed");
revalidatePath("/database");
return { success: true, data };
} catch (error) {
console.error("Error saving State/Country:", error);
return { success: false, error: error.code === "INVALID_STATE_COUNTRY" || error.code === "PLATE_NOT_FOUND" || error.code === "INVALID_PLATE" ? error.message : "Unable to save State/Country." };
}
}

export async function getPlateStateCountries() {
await requirePermission("plate.read");
try {
return { success: true, data: await listPlateStateCountries(getPool) };
} catch (error) {
console.error("Error loading State/Country entries:", error);
return { success: false, data: [], error: "Unable to load previous entries." };
}
}
1 change: 1 addition & 0 deletions app/database/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export default async function Database() {
<PlateDbTable
initialData={plateReads}
matchingSettings={settings.plateMatching}
showStateCountry={settings.showStateCountry}
/>
</TitleNavbar>
</DashboardLayout>
Expand Down
1 change: 1 addition & 0 deletions app/live_feed/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ export default async function LivePlates(props) {
timeFormat={timeFormat}
biHost={config?.blueiris?.host}
matchingSettings={config?.plateMatching}
showStateCountry={config?.showStateCountry}
dashboardTimeFrame={dashboardTimeFrame}
dashboardMetric={dashboardMetric}
defaultMatchMode={defaultMatchMode}
Expand Down
10 changes: 10 additions & 0 deletions app/settings/SettingsForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ export default function SettingsForm({
newFormData.append("retention", formData.get("retention"));
newFormData.append("ignoreNonPlate", formData.get("ignoreNonPlate"));
newFormData.append("timeFormat", Number(formData.get("timeFormat")));
newFormData.append("showStateCountry", formData.get("showStateCountry") === "on");
break;
case "database":
["dbHost", "dbName", "dbUser", "dbPassword"].forEach(appendIfPresent);
Expand Down Expand Up @@ -335,6 +336,15 @@ export default function SettingsForm({
Used to estimate retention-eligible reads. Community does not automatically delete plate reads or source images.
</p>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Switch id="showStateCountry" name="showStateCountry" defaultChecked={initialSettings.general.showStateCountry === true} />
<Label htmlFor="showStateCountry">Show State/Country column</Label>
</div>
<p className="text-xs text-muted-foreground">
Display a State/Country column in the recognition feed and plate database. Enter values manually or choose a previous entry. Off by default.
</p>
</div>
<div className="space-y-2 w-fit">
<Label htmlFor="timeFormat" className="text-sm font-medium">
Time Format
Expand Down
92 changes: 92 additions & 0 deletions components/PlateStateCountryField.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"use client";

import { useEffect, useId, useState } from "react";
import { Pencil } from "lucide-react";
import { useAccess } from "@/components/auth/AccessProvider";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { getPlateStateCountries, updatePlateStateCountry } from "@/app/actions";

export default function PlateStateCountryField({ plateNumber, value, onSaved }) {
const { can } = useAccess();
const inputId = useId();
const optionsId = useId();
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState("");
const [savedValue, setSavedValue] = useState(value || "");
const [options, setOptions] = useState([]);
const [error, setError] = useState("");
const [optionsError, setOptionsError] = useState("");
const [saving, setSaving] = useState(false);

useEffect(() => setSavedValue(value || ""), [plateNumber, value]);
useEffect(() => {
if (!open) return;
let active = true;
setOptionsError("");
getPlateStateCountries().then((result) => {
if (!active) return;
setOptions(result.data || []);
if (!result.success) setOptionsError(result.error);
}).catch(() => {
if (active) setOptionsError("Unable to load previous entries. You can still enter a value.");
});
return () => { active = false; };
}, [open]);

if (!can("plate.review")) return <span className="whitespace-pre-wrap break-words">{savedValue || "—"}</span>;

const save = async (event) => {
event.preventDefault();
if (saving) return;
setSaving(true);
setError("");
try {
const result = await updatePlateStateCountry(plateNumber, draft);
if (!result.success) { setError(result.error); return; }
setSavedValue(result.data.state_country || "");
onSaved?.(result.data);
setOpen(false);
} catch {
setError("Unable to save State/Country. Please try again.");
} finally {
setSaving(false);
}
};

return <>
<Button type="button" variant="ghost" size="sm" className="h-auto max-w-48 justify-start gap-2 whitespace-normal px-1 py-1 text-left font-normal" aria-label={"Edit State/Country for " + plateNumber} onClick={() => { setDraft(savedValue); setError(""); setOpen(true); }}>
<span className="min-w-0 break-words">{savedValue || "Add State/Country"}</span><Pencil className="h-3 w-3 shrink-0" />
</Button>
<Dialog open={open} onOpenChange={(next) => { if (!saving) setOpen(next); }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>State/Country for {plateNumber}</DialogTitle>
<DialogDescription>Enter a value or choose a previous entry. This applies to all reads of this plate. Leave it blank to clear it.</DialogDescription>
</DialogHeader>
<form onSubmit={save} className="space-y-4">
<div className="space-y-2">
<Label htmlFor={inputId}>State/Country</Label>
<Input id={inputId} value={draft} onChange={(event) => setDraft(event.target.value)} maxLength={100} placeholder="e.g. Colorado, USA" autoComplete="off" disabled={saving} />
{options.length > 0 && <div className="space-y-2 pt-2">
<Label htmlFor={optionsId}>Previous entries</Label>
<Select value={options.includes(draft) ? draft : ""} onValueChange={setDraft} disabled={saving}>
<SelectTrigger id={optionsId}><SelectValue placeholder="Choose a previous entry" /></SelectTrigger>
<SelectContent>{options.map((option) => <SelectItem key={option} value={option}>{option}</SelectItem>)}</SelectContent>
</Select>
</div>}
{optionsError && <p className="text-xs text-muted-foreground" role="status">{optionsError}</p>}
</div>
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button type="button" variant="outline" disabled={saving} onClick={() => setOpen(false)}>Cancel</Button>
<Button type="submit" disabled={saving}>{saving ? "Saving…" : "Save"}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>;
}
33 changes: 25 additions & 8 deletions components/PlateTable.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ import { Switch } from "@/components/ui/switch";
import { useRouter } from "next/navigation";
import PlateMatchModeSelect from "@/components/PlateMatchModeSelect";
import PlateImage from "@/components/PlateImage";
import PlateStateCountryField from "@/components/PlateStateCountryField";
import { plateCorrectionDefaults, isAliasOnlyCorrection } from "@/lib/plate-correction-defaults.mjs";
import MultiSelectFilter from "@/components/MultiSelectFilter";
import LiveFeedDateRangeFilter from "@/components/LiveFeedDateRangeFilter";
import FilterHourRange from "@/components/FilterHourRange";
Expand Down Expand Up @@ -213,6 +215,8 @@ function correctionImageFromRead(plate) {
}

function correctionDraft({
canBatchReview,
canManageAliases,
id,
plateNumber,
observedPlate,
Expand All @@ -226,10 +230,9 @@ function correctionDraft({
cameraName: cameraName || "",
image: image || null,
newPlateNumber: plateNumber,
correctAll: false,
...plateCorrectionDefaults({ canBatchReview, canManageAliases }),
unreviewedOnly: true,
batchCameraOnly: false,
rememberAlias: false,
aliasScope: "all",
reason: "ocr_character_error",
notes: "",
Expand Down Expand Up @@ -355,6 +358,8 @@ export default function PlateTable({
sort = { field: "", direction: "" },
onSort = () => {},
matchingSettings,
showStateCountry = false,
onStateCountrySaved,
biHost,
isLive = true,
onLiveChange = () => {},
Expand Down Expand Up @@ -1764,9 +1769,7 @@ export default function PlateTable({
correction.newPlateNumber.trim().toUpperCase() !==
String(correction.plateNumber || "").trim().toUpperCase()
);
const aliasOnlyCorrection = Boolean(
correction?.rememberAlias && correction?.newPlateNumber && !correctionChangesPlate
);
const aliasOnlyCorrection = isAliasOnlyCorrection(correction);

const selectedVehicleImageAttemptLimit = selectedImage?.vehicleImageQueueKind === "overview" ? 2 : 3;
const selectedVehicleImageManualRetryEligible = Boolean(selectedImage?.vehicleImageRetryable)
Expand Down Expand Up @@ -2134,6 +2137,7 @@ export default function PlateTable({
onSort={onSort}
/>
</TableHead>
{showStateCountry && <TableHead className="w-40">State/Country</TableHead>}
<TableHead className="w-28 hidden sm:table-cell">
<SortButton
label="%"
Expand Down Expand Up @@ -2190,13 +2194,13 @@ export default function PlateTable({
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={10} className="text-center py-4">
<TableCell colSpan={showStateCountry ? 10 : 9} className="text-center py-4">
Loading...
</TableCell>
</TableRow>
) : data.length === 0 ? (
<TableRow>
<TableCell colSpan={10} className="text-center py-4">
<TableCell colSpan={showStateCountry ? 10 : 9} className="text-center py-4">
No results found
</TableCell>
</TableRow>
Expand All @@ -2218,6 +2222,9 @@ export default function PlateTable({
>
<PlateIdentity plate={plate} />
</TableCell>
{showStateCountry && <TableCell>
<PlateStateCountryField plateNumber={plate.plate_number} value={plate.state_country} onSaved={onStateCountrySaved} />
</TableCell>}
<TableCell className="hidden sm:table-cell">
{formatConfidence(plate.confidence)}
</TableCell>
Expand Down Expand Up @@ -2346,6 +2353,8 @@ export default function PlateTable({
aria-label={`Correct plate ${plate.plate_number}`}
onClick={() => {
setCorrection(correctionDraft({
canBatchReview,
canManageAliases,
id: plate.id,
plateNumber: plate.plate_number,
observedPlate: plate.observed_plate || plate.plate_number,
Expand Down Expand Up @@ -2506,6 +2515,10 @@ export default function PlateTable({
}`}
>
<PlateIdentity plate={plate} compact />
{showStateCountry && <div className="mt-2 font-sans font-normal">
<div className="text-xs text-muted-foreground">State/Country</div>
<PlateStateCountryField plateNumber={plate.plate_number} value={plate.state_country} onSaved={onStateCountrySaved} />
</div>}
</div>
</div>

Expand Down Expand Up @@ -2539,6 +2552,8 @@ export default function PlateTable({
{canReview && <DropdownMenuItem
onClick={() => {
setCorrection(correctionDraft({
canBatchReview,
canManageAliases,
id: plate.id,
plateNumber: plate.plate_number,
observedPlate: plate.observed_plate || plate.plate_number,
Expand Down Expand Up @@ -3090,6 +3105,8 @@ export default function PlateTable({
title="Correct detected plate"
onClick={() => {
setCorrection(correctionDraft({
canBatchReview,
canManageAliases,
id: selectedImage.id,
plateNumber: selectedImage.plateNumber,
observedPlate: selectedImage.observedPlate || selectedImage.plateNumber,
Expand Down Expand Up @@ -3641,7 +3658,7 @@ export default function PlateTable({
onClick={() => handleCorrectSubmit()}
disabled={
!correction?.newPlateNumber ||
(!correctionChangesPlate && !correction?.rememberAlias) ||
(!correctionChangesPlate && !aliasOnlyCorrection) ||
(correctionChangesPlate && correction.correctAll && !correctionPreview)
}
>
Expand Down
9 changes: 9 additions & 0 deletions components/PlateTableWrapper.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export default function PlateTableWrapper({
timeFormat,
biHost,
matchingSettings,
showStateCountry = false,
dashboardTimeFrame,
dashboardMetric,
defaultMatchMode = "balanced",
Expand Down Expand Up @@ -632,6 +633,14 @@ export default function PlateTableWrapper({
direction: displayedParams.get("sortDirection") || "desc",
}}
matchingSettings={matchingSettings}
showStateCountry={showStateCountry}
onStateCountrySaved={({ plate_number, state_country }) => {
setLiveData((current) => {
const next = current.map((plate) => plate.plate_number === plate_number ? { ...plate, state_country } : plate);
liveDataRef.current = next;
return next;
});
}}
onSort={handleSort}
onUpdateFilters={handleUpdateFilters}
onAddTag={handleAddTag}
Expand Down
Loading
Loading