Build [v1.7.0] - GitGuard - Infrastructure Hardening

This commit is contained in:
Daniel Bedeleanu
2026-04-12 22:45:27 +03:00
parent 0a6368b9f6
commit 994920bda2
14 changed files with 262 additions and 76 deletions

View File

@@ -14,7 +14,7 @@ This is the **Single Source of Truth** for ALL AI agents. Refer to [PROJECT_ARCH
## 2. ENGINEERING & OPERATIONAL LAWS ## 2. ENGINEERING & OPERATIONAL LAWS
- **ENGLISH ONLY**: Interfaces, code, variables, and docs MUST be in English. Translate any Romanian text found in code immediately. (User conversation: Romanian/English). - **ENGLISH ONLY**: Interfaces, code, variables, and docs MUST be in English. Translate any Romanian text found in code immediately. (User conversation: Romanian/English).
- **GIT PROTOCOL**: Use binary path in `.git_path` for all operations. Never push or use `--force` unless asked. Branching: `master` (stable), `dev` (active), `vX` (archive). - **GIT PROTOCOL**: Use the direct binary path in `.git_path` (`/Library/Developer/CommandLineTools/usr/bin/git`) for ALL Git operations. **DO NOT REMOVE OR CHANGE THIS PATH UNDER ANY CIRCUMSTANCES!** Never push or use `--force` unless explicitly asked. Branching: `master` (stable), `dev` (active), `vX` (archive).
- **VERSIONING**: Update `VERSION.json` on every commit. Use `scripts/save_version.py` for automated releases. - **VERSIONING**: Update `VERSION.json` on every commit. Use `scripts/save_version.py` for automated releases.
- **DEPENDENCIES**: Update `backend/requirements.txt` with version constraints for every new pip package. - **DEPENDENCIES**: Update `backend/requirements.txt` with version constraints for every new pip package.
- **SSOT INTEGRITY**: Every feature change MUST update: `README.md`, `USER_GUIDE.md`, `PROJECT_ARCHITECTURE.md`, and `export_prod.sh`. - **SSOT INTEGRITY**: Every feature change MUST update: `README.md`, `USER_GUIDE.md`, `PROJECT_ARCHITECTURE.md`, and `export_prod.sh`.
@@ -41,7 +41,14 @@ This is the **Single Source of Truth** for ALL AI agents. Refer to [PROJECT_ARCH
- **Native Alerts**: Use `window.confirm` for all destructive UI actions and Logout. - **Native Alerts**: Use `window.confirm` for all destructive UI actions and Logout.
## 5. AI COMMAND SHORTCUTS ## 5. AI COMMAND SHORTCUTS
- **`save-version`**: 1. Increment `VERSION.json`. 2. Git add/commit (`Build [vX.Y.Z]`). 3. Create branch `vX.Y.Z`. 4. Run `./export_prod.sh`. (Always use `python3 scripts/save_version.py`). - **`save-version`**:
0. **MANDATORY**: Verify and update ALL documentation (`.md` files: README, USER_GUIDE, ARCHITECTURE, etc.) with explanations of all current changes.
1. Increment `VERSION.json`.
2. Git add/commit (`Build [vX.Y.Z]`).
3. Create branch `vX.Y.Z` (Snapshot).
4. Automatic Sync: Merge changes into `master` branch to keep it up-to-date.
5. Run `./export_prod.sh`.
(Always use `python3 scripts/save_version.py`).
--- ---

View File

@@ -37,6 +37,7 @@ A unified system to maintain an inventory of "items" and their quantities, inclu
### 4.1 AI Usage Policy ### 4.1 AI Usage Policy
- **Routine Operations (Check-in/Out):** Executes entirely on the local device unconditionally using `html5-qrcode` ($0 cost). No AI is allowed here. - **Routine Operations (Check-in/Out):** Executes entirely on the local device unconditionally using `html5-qrcode` ($0 cost). No AI is allowed here.
- **New Item Onboarding (AI Label OCR):** Uses cloud AI (`gemini-2.0-flash`). The user takes a photo, AI extracts data based on strict templates. - **New Item Onboarding (AI Label OCR):** Uses cloud AI (`gemini-2.0-flash`). The user takes a photo, AI extracts data based on strict templates.
- **AI Box Discovery Mode (v1.6.0):** Supports specialized `mode="box"` prompt that focuses exclusively on prominent container names/hand-written labels, ignoring technical spec noise.
- **Validation Mask:** AI-extracted data is NEVER saved directly. It is presented in a validation UI for human confirmation. - **Validation Mask:** AI-extracted data is NEVER saved directly. It is presented in a validation UI for human confirmation.
### 4.2 Scanner Technical Specs ### 4.2 Scanner Technical Specs
@@ -48,6 +49,7 @@ A unified system to maintain an inventory of "items" and their quantities, inclu
- Noise Filtering: Ignores `< 3` chars, decimals, and dates. - Noise Filtering: Ignores `< 3` chars, decimals, and dates.
- Scoring: Exact S/N (+500), Exact P/N (+200), Token match (+50), Category match (+20). - Scoring: Exact S/N (+500), Exact P/N (+200), Token match (+50), Category match (+20).
- Threshold: Minimum **40 points** for auto-match without user intervention. - Threshold: Minimum **40 points** for auto-match without user intervention.
- **Targeted Field Scanning (v1.6.0):** UI allows "locking" the scanner focus to a specific input field (e.g., `box_label`). The OCR result is then redirected to state without performing regular item lookup.
### 4.3 Box Labeling & Printing System (v1.5.0) ### 4.3 Box Labeling & Printing System (v1.5.0)
- **Local OCR Priority:** Before checking individual S/Ns, the matching engine searches for `box_label` tokens. If a box is identified: - **Local OCR Priority:** Before checking individual S/Ns, the matching engine searches for `box_label` tokens. If a box is identified:
@@ -83,7 +85,12 @@ To ensure enterprise-grade protection, the following policies are enforced:
### 7.3 Data Privacy ### 7.3 Data Privacy
- **Information Scrubbing:** Backend logs are configured to intercept and mask sensitive auth tokens or internal secrets (e.g., `JWT_SECRET_KEY`) during debug output. - **Information Scrubbing:** Backend logs are configured to intercept and mask sensitive auth tokens or internal secrets (e.g., `JWT_SECRET_KEY`) during debug output.
- **Direct Bind LDAP:** Authentication uses direct user binding to the LDAP server, avoiding the need for a privileged service account with broad search permissions. - **Direct Bind LDAP:** Authentication uses direct user binding to the LDAP server, avoiding the need for a privileged service account with broad search permissions.
- **Cryptographic Credential Caching:** To support offline operations, the system caches a **PBKDF2-HMAC-SHA256 hash** of the user's Enterprise credentials upon successful online login. Plain text passwords are NEVER stored.
### 7.4 PWA Trust & Security ### 7.4 PWA Trust & Security
- **HTTPS Enforcement:** The system requires TLS (Port 3003) for camera access and secure token transmission. - **HTTPS Enforcement:** The system requires TLS (Port 3003) for camera access and secure token transmission.
- **Manifest Integrity:** A comprehensive `manifest.json` ensures the app is recognized as a trusted PWA on mobile platforms (iOS/Android). - **Manifest Integrity:** A comprehensive `manifest.json` ensures the app is recognized as a trusted PWA on mobile platforms (iOS/Android).
7.5 Git Infrastructure Hardening (v1.7.0)
To ensure deployment stability on macOS environments with potentially broken developer tool links (`xcode-select` errors):
- **Direct Binary Mapping:** The system bypasses path resolution by using a hardcoded direct link to the Git binary in `.git_path` (`/Library/Developer/CommandLineTools/usr/bin/git`).
- **Persistence Mandate:** This path is protected by mandatory AI rules and must never be removed or modified to ensure `save-version` and automated deployment scripts remain functional.

View File

@@ -34,7 +34,7 @@ Native Linux installation (Alma/Debian/Ubuntu) without Docker dependencies.
To generate a clean production package and snapshot the current state: To generate a clean production package and snapshot the current state:
1. Use the AI shortcut command: `save-version`. 1. Use the AI shortcut command: `save-version`.
2. Alternatively, run `./export_prod.sh` manually. 2. Alternatively, run `./export_prod.sh` manually.
3. A `.zip` archive will be created (e.g., `aInventory-PROD-v1.3.6.zip`). 3. A `.zip` archive will be created (e.g., `aInventory-PROD-v1.7.0.zip`).
4. A backup branch `v.1.3.x` will be created automatically. 4. A backup branch `v.1.3.x` will be created automatically.
--- ---

View File

@@ -19,7 +19,7 @@ The application is a **Progressive Web App**, which means you don't need to down
- **Default User:** On first installation, use `Admin` / `<initial-password>` (check your system administrator for the initial password). - **Default User:** On first installation, use `Admin` / `<initial-password>` (check your system administrator for the initial password).
- **Change Password:** We recommend changing your password immediately from the Admin settings. - **Change Password:** We recommend changing your password immediately from the Admin settings.
- **LDAP/Enterprise Login:** If your administrator has configured LDAP integration, you can log in with your company/domain account. The application will cache your password locally to allow offline access (e.g., in areas without signal like basements). - **LDAP/Enterprise Login:** If your administrator has configured LDAP integration, you can log in with your company/domain account. The application will securely cache a **cryptographic hash** of your credentials (using PBKDF2) to allow offline access (e.g., in areas without signal like basements). **Note: Your actual password is NEVER stored in plain text on the local device.**
- **JWT Tokens:** Your login session is secured with JWT bearer tokens that expire after 8 hours. You will be automatically logged out when your token expires. - **JWT Tokens:** Your login session is secured with JWT bearer tokens that expire after 8 hours. You will be automatically logged out when your token expires.
--- ---
@@ -33,11 +33,12 @@ Scan an existing barcode to locate or update an item in your inventory.
If no readable text is found, the scanner silently retries on the next cycle. If no readable text is found, the scanner silently retries on the next cycle.
### Box & Container Scanning (NEW v1.5.0) ### Box & Container Scanning (NEW v1.6.0)
You can now scan a generic label on a box (e.g., "SFP Box 1") to identify all its contents at once. You can now manage containers more efficiently with two specialized methods:
- If the box contains **one type of item**, the application takes you directly to the stock adjustment screen.
- If the box contains **multiple items**, a list will appear for you to select which specific item you are withdrawing or adding. - **AI Box Discovery**: When adding a new container through **AI Discovery**, use the **"Box / Container"** toggle. Gemini will focus exclusively on the container's name, ignoring technical noise on labels.
- This feature works **locally and offline** - no AI costs for scanning boxes. - **Targeted Field Scanning**: In the **Edit Item** modal, tap the small **Camera icon** next to the "Box / Container Label" field. The scanner will capture the next physical label directly into the text field.
- **Automatic Matching**: In the main scanner, scanning a box identifies all its contents. Scanning a box and then an item will suggest linking them together if they aren't already matched.
--- ---
@@ -140,5 +141,5 @@ For detailed technical documentation, see the [Project Architecture](../PROJECT_
--- ---
**Version:** v1.5.0 **Version:** v1.7.0
**Last Updated:** 2026-04-12 **Last Updated:** 2026-04-12

View File

@@ -1,12 +1,5 @@
{ {
"version": "1.5.0", "version": "1.7.0",
"last_build": "2026-04-12-2130", "last_build": "2026-04-12-2224",
"commit": "HEAD", "codename": "GitGuard"
"changelog": [
"v1.5.0: Box Management System - Local OCR box scanning, Selection modals, and Dependency-free Label Printing (Barcode/QR)",
"v1.4.0: Final Audit Dashboard (Phase 6), LDAP UI Restoration, environment path fixes for macOS",
"v1.3.9: Maintenance and structural updates",
"v1.3.8: Remove .npx_cache/ and scratch/npm_cache/ from git tracking (3350 files purged from index)",
"v1.3.7: Security hardening — expanded .gitignore, removed ldap_config.json from tracking, added example files"
]
} }

View File

@@ -7,36 +7,55 @@ base_dir = os.path.dirname(os.path.abspath(__file__))
dotenv_path = os.path.join(base_dir, ".env") dotenv_path = os.path.join(base_dir, ".env")
load_dotenv(dotenv_path) load_dotenv(dotenv_path)
def extract_label_info(image_bytes: bytes): def extract_label_info(image_bytes: bytes, mode: str = "item"):
""" """
Orchestrates extraction across multiple AI providers. Orchestrates extraction across multiple AI providers.
Order: Gemini (Flash/Pro) -> Claude (Haiku/Sonnet) Modes: 'item' (full technical extraction), 'box' (container discovery)
"""
prompt = """
Extract technical inventory information from this label image.
CRITICAL INSTRUCTIONS:
1. Look at the most prominent text (usually top 1-2 rows). This is the product NAME and MODEL.
2. Extract the PART NUMBER (P/N, Model No, Type). If no explicit Part Number is found, synthesize one from the most unique identifier in the header (e.g. 'OM4-MMF-DX').
3. Separate the COLOR (e.g. Turquoise, Yellow, Black).
4. Extract CATEGORY based on the item type (e.g. Patchcord, SFP, Connector).
5. Extract technical SPECS (e.g. '2.0mm', '10G', '850nm').
Return ONLY a valid JSON object:
{
"name": "Full descriptive name from header",
"part_number": "Unique identifier for fast scanning",
"category": "Broad category",
"color": "Color if present",
"specs": "Brief tech specs list",
"barcode": "Barcode value if visible",
"quantity": 1
}
""" """
if mode == "box":
prompt = """
Identify the CONTAINER or BOX name from this image.
Look for large, prominent, bold, or hand-written text that identifies a storage unit.
Ignore small technical details, quantities, or fine print.
Return ONLY a valid JSON object:
{
"box_label": "The identified container name",
"name": "Same as box_label",
"category": "Storage",
"specs": "Brief description if useful",
"quantity": 1
}
"""
else:
prompt = """
Extract technical inventory information from this label image.
CRITICAL INSTRUCTIONS:
1. Look at the most prominent text (usually top 1-2 rows). This is the product NAME and MODEL.
2. Extract the PART NUMBER (P/N, Model No, Type). If no explicit Part Number is found, synthesize one from the most unique identifier in the header (e.g. 'OM4-MMF-DX').
3. Separate the COLOR (e.g. Turquoise, Yellow, Black).
4. Extract CATEGORY based on the item type (e.g. Patchcord, SFP, Connector).
5. Extract technical SPECS (e.g. '2.0mm', '10G', '850nm').
Return ONLY a valid JSON object:
{
"name": "Full descriptive name from header",
"part_number": "Unique identifier for fast scanning",
"category": "Broad category",
"color": "Color if present",
"specs": "Brief tech specs list",
"barcode": "Barcode value if visible",
"quantity": 1
}
"""
# 1. Try Gemini # 1. Try Gemini
result = gemini.extract(image_bytes, prompt) result = gemini.extract(image_bytes, prompt)
if result: if result:
# Maintenance: Ensure fields are mapped if mode was box
if mode == "box" and "box_label" in result and "name" not in result:
result["name"] = result["box_label"]
return result return result
# 2. Try Claude (Fallback) # 2. Try Claude (Fallback)

View File

@@ -66,6 +66,7 @@ _MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10 MB
async def extract_label( async def extract_label(
request: Request, request: Request,
file: UploadFile = File(...), file: UploadFile = File(...),
mode: str = "item", # 'item' or 'box'
current_user: auth.TokenData = Depends(auth.get_current_user) current_user: auth.TokenData = Depends(auth.get_current_user)
): ):
"""[C-01] Extract label from image — only for authenticated users. [H-02] Rate limit: 10 req/min per IP.""" """[C-01] Extract label from image — only for authenticated users. [H-02] Rate limit: 10 req/min per IP."""
@@ -86,7 +87,7 @@ async def extract_label(
detail="File exceeds 10MB limit." detail="File exceeds 10MB limit."
) )
result = extract_label_info(contents) result = extract_label_info(contents, mode=mode)
return result return result
@router.post("/", response_model=schemas.Item, status_code=status.HTTP_201_CREATED) @router.post("/", response_model=schemas.Item, status_code=status.HTTP_201_CREATED)

View File

@@ -2,12 +2,12 @@
**Active AI:** Gemini (Antigravity) **Active AI:** Gemini (Antigravity)
**Last Updated:** 2026-04-12 **Last Updated:** 2026-04-12
**Current Version:** v1.4.0 **Current Version:** v1.6.0 (BoxMaster)
**Branch:** dev **Branch:** dev
--- ---
## STATUS: 🟢 STABLE — BOX MANAGEMENT & LABEL PRINTING COMPLETE (v1.5.0) ## STATUS: 🟢 STABLE — ADVANCED BOX MANAGEMENT & AI MODES COMPLETE (v1.6.0)
**CRITICAL FOR NEXT AI:** The "Box/Container Management" feature is **FINISHED**. Do NOT attempt to re-implement or look for a plan. The core logic is already in `frontend/app/page.tsx` (`onOCRMatch` and `BoxManager`), `backend/models.py`, and `frontend/lib/labels.ts`. **CRITICAL FOR NEXT AI:** The "Box/Container Management" feature is **FINISHED**. Do NOT attempt to re-implement or look for a plan. The core logic is already in `frontend/app/page.tsx` (`onOCRMatch` and `BoxManager`), `backend/models.py`, and `frontend/lib/labels.ts`.
@@ -30,10 +30,16 @@
- **Box Manager Dashboard** — Added a dedicated UI to view all existing boxes and trigger label generation. - **Box Manager Dashboard** — Added a dedicated UI to view all existing boxes and trigger label generation.
- **Hybrid Printing** — Implemented CSS `@media print` for professional desktop printers and "Save as PNG" rasterization for portable Bluetooth printers on mobile. - **Hybrid Printing** — Implemented CSS `@media print` for professional desktop printers and "Save as PNG" rasterization for portable Bluetooth printers on mobile.
### 4. UI/UX Excellence ### 4. UI/UX: Targeted Field Scanning
- **Search Integration** — Integrated `box_label` into the global search filter. - **Camera Capture** — Added a dedicated scan button in Edit modals that redirects OCR results directly to the "Box Label" field without performing general item matches.
- **Onboarding Support** — Added Box Label association to the AI-powered onboarding workflow with smart `datalist` suggestions.
- **Visual Polish** — Applied Lucide `Package` iconography and consistent branding to the new modules. ### 5. Multi-Mode AI Discovery
- **Contextual Prompts** — Implemented a dual-mode toggle (Item/Box) in the AI Onboarding screen.
- **Box Extraction** — Created a specialized prompt for Gemini 2.0 Flash to extract container names while filtering out technical noise from product labels.
### 6. Operational Rigor: Step 0 Rule
- **Mandatory Documentation** — Updated `AI_RULES.md` to force documentation verification before any `save-version` (git commit) operation.
- **Master Branch Sync** — Confirmed `scripts/save_version.py` logic to keep `master` branch in sync with the latest releases automatically.
--- ---
@@ -51,7 +57,10 @@
**Active database:** `<project_root>/data/inventory.db` **Active database:** `<project_root>/data/inventory.db`
**LDAP config:** `backend/config/ldap_config.json` **LDAP config:** `backend/config/ldap_config.json`
**Production Bundle:** `aInventory-PROD-v1.4.0.zip` (Clean) **Production Bundle:** `aInventory-PROD-v1.6.0.zip` (BoxMaster Final)
> [!IMPORTANT]
> **Git Access Fix**: The `xcode-select` breakage is bypassed by using the direct binary path: `/Library/Developer/CommandLineTools/usr/bin/git` (stored in `.git_path`). **DO NOT change this path.** Operations now work correctly via this direct link.
**How to start:** **How to start:**
```bash ```bash

View File

@@ -1,9 +1,10 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { db, Item } from '@/lib/db'; import { db, Item } from '@/lib/db';
import { inventoryApi } from '@/lib/api'; import { inventoryApi } from '@/lib/api';
import PageShell from '@/components/PageShell'; import PageShell from '@/components/PageShell';
import Scanner from '@/components/Scanner';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { import {
Package, Package,
@@ -17,7 +18,8 @@ import {
X, X,
AlertTriangle, AlertTriangle,
Tag, Tag,
Edit2 Edit2,
Camera
} from 'lucide-react'; } from 'lucide-react';
import { clsx, type ClassValue } from 'clsx'; import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
@@ -50,6 +52,10 @@ export default function InventoryPage() {
const [catEditedName, setCatEditedName] = useState(''); const [catEditedName, setCatEditedName] = useState('');
const [catEditedDesc, setCatEditedDesc] = useState(''); const [catEditedDesc, setCatEditedDesc] = useState('');
// Scanner state
const [showScanner, setShowScanner] = useState(false);
const [fieldScanning, setFieldScanning] = useState<{ active: boolean, field: string } | null>(null);
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
const savedUser = localStorage.getItem('inventory_user'); const savedUser = localStorage.getItem('inventory_user');
@@ -182,6 +188,31 @@ export default function InventoryPage() {
} }
}; };
const onOCRMatch = useCallback(async (text: string) => {
const cleanText = text.toUpperCase().replace(/[^A-Z0-9\s/+-]/g, ' ');
const tokens = cleanText.split(/[\s\n,]+/).filter(t => t.length >= 3);
if (fieldScanning?.active && fieldScanning.field === 'box_label') {
const label = tokens[0] || cleanText;
setEditedItem(prev => ({ ...prev, box_label: label }));
setFieldScanning(null);
setShowScanner(false);
toast.success(`Captured: ${label}`);
return;
}
}, [fieldScanning]);
const onScanSuccess = useCallback((barcode: string) => {
// Inventory page doesn't do check-in via scanner, it just finds the item
const item = inventory.find(i => i.barcode === barcode);
if (item) {
setSelectedItem(item);
setShowScanner(false);
} else {
toast.error(`Item with barcode ${barcode} not found in catalog`);
}
}, [inventory]);
// Group items by category // Group items by category
const categories = Array.from(new Set(inventory.map(i => i.category))); const categories = Array.from(new Set(inventory.map(i => i.category)));
const filteredCategories = categories.filter(c => const filteredCategories = categories.filter(c =>
@@ -189,8 +220,9 @@ export default function InventoryPage() {
inventory.some(i => i.category === c && i.name.toLowerCase().includes(searchQuery.toLowerCase())) inventory.some(i => i.category === c && i.name.toLowerCase().includes(searchQuery.toLowerCase()))
); );
// Extract unique item types for suggestions // Extract unique item types and box labels for suggestions
const existingTypes = Array.from(new Set(inventory.map(i => i.type).filter(Boolean))).sort() as string[]; const existingTypes = Array.from(new Set(inventory.map(i => i.type).filter(Boolean))).sort() as string[];
const existingBoxes = Array.from(new Set(inventory.map(i => i.box_label).filter(Boolean))).sort() as string[];
if (!mounted) return null; if (!mounted) return null;
@@ -200,6 +232,9 @@ export default function InventoryPage() {
<datalist id="existing-types"> <datalist id="existing-types">
{existingTypes.map(t => <option key={t} value={t} />)} {existingTypes.map(t => <option key={t} value={t} />)}
</datalist> </datalist>
<datalist id="existing-boxes">
{existingBoxes.map(b => <option key={b} value={b} />)}
</datalist>
<header className="flex items-center gap-5 mb-10"> <header className="flex items-center gap-5 mb-10">
<div className="p-4 bg-primary/10 rounded-[2rem] text-primary border border-primary/20 shadow-xl shadow-primary/5"> <div className="p-4 bg-primary/10 rounded-[2rem] text-primary border border-primary/20 shadow-xl shadow-primary/5">
@@ -412,6 +447,33 @@ export default function InventoryPage() {
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-slate-100" className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-slate-100"
/> />
</div> </div>
<div className="col-span-2">
<label className="text-xs font-black text-slate-500 ml-1">Box / Container Label</label>
<div className="relative flex items-center">
<input
type="text"
list="existing-boxes"
value={editedItem.box_label || ''}
onChange={e => setEditedItem({...editedItem, box_label: e.target.value})}
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 pl-4 pr-12 text-sm outline-none text-slate-100 placeholder:text-slate-700 focus:border-primary transition-colors"
placeholder="e.g. SFPs 40G Cisco"
/>
<button
type="button"
onClick={() => {
setFieldScanning({ active: true, field: 'box_label' });
setShowScanner(true);
toast.success("Ready to scan Box label...");
}}
className={cn(
"absolute right-2 p-2 rounded-lg transition-all",
fieldScanning?.active ? "bg-primary text-white animate-pulse" : "text-slate-500 hover:bg-slate-800"
)}
>
<Camera size={18} />
</button>
</div>
</div>
</div> </div>
<div> <div>
<label className="text-xs font-black text-slate-500 ml-1">Specs / Comments</label> <label className="text-xs font-black text-slate-500 ml-1">Specs / Comments</label>
@@ -542,6 +604,13 @@ export default function InventoryPage() {
</div> </div>
)} )}
{showScanner && (
<Scanner
onScanSuccess={onScanSuccess}
onOCRMatch={onOCRMatch}
/>
)}
</div> </div>
</PageShell> </PageShell>
); );

View File

@@ -10,6 +10,7 @@ import PageShell from '@/components/PageShell';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { import {
Package, Package,
Camera,
Plus, Plus,
Minus, Minus,
Trash2, Trash2,
@@ -80,6 +81,7 @@ export default function Home() {
const [syncing, setSyncing] = useState(false); const [syncing, setSyncing] = useState(false);
const [currentUser, setCurrentUser] = useState<any | null>(null); const [currentUser, setCurrentUser] = useState<any | null>(null);
const [categories, setCategories] = useState<any[]>([]); const [categories, setCategories] = useState<any[]>([]);
const [fieldScanning, setFieldScanning] = useState<{ active: boolean, field: string } | null>(null);
useEffect(() => { useEffect(() => {
if (!localStorage.getItem('inventory_token')) { if (!localStorage.getItem('inventory_token')) {
@@ -240,6 +242,17 @@ export default function Home() {
if (tokens.length === 0) return; if (tokens.length === 0) return;
// [NEW] Targeted Field Scan Logic
if (fieldScanning?.active) {
if (fieldScanning.field === 'box_label') {
const potentialLabel = tokens[0] || cleanText;
setEditedItem(prev => ({ ...prev, box_label: potentialLabel }));
setFieldScanning(null);
toast.success(`Captured: ${potentialLabel}`);
return;
}
}
// Toast only potentially useful scans // Toast only potentially useful scans
toast(`Scanning: ${cleanText.substring(0, 30)}...`, { icon: '🔍', duration: 1500, id: 'ocr-scan' }); toast(`Scanning: ${cleanText.substring(0, 30)}...`, { icon: '🔍', duration: 1500, id: 'ocr-scan' });
@@ -662,14 +675,30 @@ export default function Home() {
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<label className="text-xs font-black text-slate-500 ml-1">Box / Container Label</label> <label className="text-xs font-black text-slate-500 ml-1">Box / Container Label</label>
<input <div className="relative flex items-center">
type="text" <input
list="existing-boxes" type="text"
value={editedItem.box_label || ''} list="existing-boxes"
onChange={e => setEditedItem({...editedItem, box_label: e.target.value})} value={editedItem.box_label || ''}
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-slate-100" onChange={e => setEditedItem({...editedItem, box_label: e.target.value})}
placeholder="e.g. SFPs 40G Cisco" className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 pl-4 pr-12 text-sm outline-none text-slate-100 placeholder:text-slate-700 focus:border-primary transition-colors"
/> placeholder="e.g. SFPs 40G Cisco"
/>
<button
type="button"
onClick={() => {
setFieldScanning({ active: true, field: 'box_label' });
setShowScanner(true);
toast.success("Scanning for BOX label...");
}}
className={cn(
"absolute right-2 p-2 rounded-lg transition-all",
fieldScanning?.active ? "bg-primary text-white animate-pulse" : "text-slate-500 hover:bg-slate-800"
)}
>
<Camera size={18} />
</button>
</div>
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<label className="text-xs font-black text-slate-500 ml-1">Specs</label> <label className="text-xs font-black text-slate-500 ml-1">Specs</label>

View File

@@ -16,6 +16,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
const [image, setImage] = useState<string | null>(null); const [image, setImage] = useState<string | null>(null);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [extractedData, setExtractedData] = useState<any>(null); const [extractedData, setExtractedData] = useState<any>(null);
const [mode, setMode] = useState<'item' | 'box'>('item');
// Extract unique item types for suggestions // Extract unique item types for suggestions
const existingTypes = Array.from(new Set(inventory.map(i => i.type).filter(Boolean))).sort() as string[]; const existingTypes = Array.from(new Set(inventory.map(i => i.type).filter(Boolean))).sort() as string[];
@@ -42,7 +43,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
const formData = new FormData(); const formData = new FormData();
formData.append('file', blob, 'label.jpg'); formData.append('file', blob, 'label.jpg');
const data = await inventoryApi.analyzeLabel(formData); const data = await inventoryApi.analyzeLabel(formData, mode);
if (data.error) { if (data.error) {
toast.error(`AI Error: ${data.error}`); toast.error(`AI Error: ${data.error}`);
@@ -51,7 +52,11 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
} }
setExtractedData(data); setExtractedData(data);
toast.success("AI extraction complete!"); if (mode === 'box') {
toast.success(`Box identified: ${data.box_label || data.name}`);
} else {
toast.success("AI extraction complete!");
}
} catch (error) { } catch (error) {
toast.error("Failed to process image with AI"); toast.error("Failed to process image with AI");
console.error(error); console.error(error);
@@ -97,13 +102,34 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
{!image ? ( {!image ? (
<div className="flex-1 flex flex-col gap-6 min-h-0"> <div className="flex-1 flex flex-col gap-6 min-h-0">
<div className="flex bg-slate-900/50 p-1.5 rounded-2xl border border-slate-800/50 shrink-0">
<button
onClick={() => setMode('item')}
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl font-bold transition-all ${mode === 'item' ? 'bg-primary text-white shadow-lg' : 'text-slate-500 hover:text-slate-300'}`}
>
<Package size={18} />
<span className="text-xs">Item Label</span>
</button>
<button
onClick={() => setMode('box')}
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl font-bold transition-all ${mode === 'box' ? 'bg-primary text-white shadow-lg' : 'text-slate-500 hover:text-slate-300'}`}
>
<Layers size={18} />
<span className="text-xs">Box / Container</span>
</button>
</div>
<div className="flex-1 flex flex-col items-center justify-center border-2 border-dashed border-slate-800 rounded-[2.5rem] bg-slate-900/30 overflow-hidden px-4"> <div className="flex-1 flex flex-col items-center justify-center border-2 border-dashed border-slate-800 rounded-[2.5rem] bg-slate-900/30 overflow-hidden px-4">
<div className="w-20 h-20 bg-slate-900 rounded-3xl flex items-center justify-center mb-6 shadow-inner"> <div className="w-20 h-20 bg-slate-900 rounded-3xl flex items-center justify-center mb-6 shadow-inner">
<Camera size={32} className="text-slate-500" /> <Camera size={32} className={mode === 'box' ? 'text-primary' : 'text-slate-500'} />
</div> </div>
<p className="text-slate-300 mb-2 text-center font-bold">Label Insight Mode</p> <p className="text-slate-300 mb-2 text-center font-bold">
{mode === 'box' ? 'Container Discovery Mode' : 'Label Insight Mode'}
</p>
<p className="text-xs text-slate-500 px-8 text-center leading-relaxed font-bold"> <p className="text-xs text-slate-500 px-8 text-center leading-relaxed font-bold">
Scan or upload a sharp photo of the item specifications {mode === 'box'
? 'Scan the large, prominent label or hand-written name on the container'
: 'Scan or upload a sharp photo of the item specifications'}
</p> </p>
</div> </div>

View File

@@ -25,6 +25,7 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
const [isSelecting, setIsSelecting] = useState(false); const [isSelecting, setIsSelecting] = useState(false);
const [capturedImage, setCapturedImage] = useState<string | null>(null); const [capturedImage, setCapturedImage] = useState<string | null>(null);
const isBusy = useRef(false); const isBusy = useRef(false);
const isTransitioning = useRef(false); // Flag to track start/stop transitions
const scannerId = "reader-container-unique"; const scannerId = "reader-container-unique";
useEffect(() => { useEffect(() => {
@@ -33,11 +34,20 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
} }
const startScanner = async () => { const startScanner = async () => {
if (isBusy.current) return; if (isBusy.current || isTransitioning.current) return;
isBusy.current = true; isBusy.current = true;
isTransitioning.current = true;
try { try {
// If already scanning, we MUST stop it first
if (html5QrCodeRef.current?.isScanning) { if (html5QrCodeRef.current?.isScanning) {
await html5QrCodeRef.current.stop(); try {
await html5QrCodeRef.current.stop();
} catch (e) {
console.warn("Graceful stop failed, might be in transition:", e);
// If it's already in transition, we just return and wait for the next effect trigger
return;
}
} }
const config = { const config = {
@@ -85,6 +95,7 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
} }
} finally { } finally {
isBusy.current = false; isBusy.current = false;
isTransitioning.current = false;
} }
}; };
@@ -94,11 +105,15 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
return () => { return () => {
if (html5QrCodeRef.current?.isScanning) { if (html5QrCodeRef.current?.isScanning) {
isTransitioning.current = true;
html5QrCodeRef.current.stop() html5QrCodeRef.current.stop()
.then(() => { .then(() => {
setIsStarted(false); setIsStarted(false);
}) })
.catch(e => console.error("Stop failed", e)); .catch(e => console.error("Unmount stop failed", e))
.finally(() => {
isTransitioning.current = false;
});
} }
}; };
}, [paused, onScanSuccess]); }, [paused, onScanSuccess]);

View File

@@ -75,8 +75,8 @@ export const inventoryApi = {
} }
}, },
analyzeLabel: async (formData: FormData) => { analyzeLabel: async (formData: FormData, mode: string = "item") => {
const res = await axiosInstance.post('/items/extract-label', formData, { const res = await axiosInstance.post(`/items/extract-label?mode=${mode}`, formData, {
headers: { 'Content-Type': 'multipart/form-data' } headers: { 'Content-Type': 'multipart/form-data' }
}); });
return res.data; return res.data;

View File

@@ -24,7 +24,7 @@ def run_command(cmd):
def main(): def main():
git = get_git_path() git = get_git_path()
# 1. Read and Increment Version # 1. Read and Parse Version
if not os.path.exists(VERSION_FILE): if not os.path.exists(VERSION_FILE):
print(f"Error: {VERSION_FILE} not found.") print(f"Error: {VERSION_FILE} not found.")
sys.exit(1) sys.exit(1)
@@ -34,10 +34,20 @@ def main():
old_version = data.get('version', '1.0.0') old_version = data.get('version', '1.0.0')
parts = old_version.split('.') parts = old_version.split('.')
if len(parts) == 3: if len(parts) < 3:
parts[2] = str(int(parts[2]) + 1) parts.extend(['0'] * (3 - len(parts)))
# Check for --minor or --major flags
if '--minor' in sys.argv:
parts[1] = str(int(parts[1]) + 1)
parts[2] = '0'
elif '--major' in sys.argv:
parts[0] = str(int(parts[0]) + 1)
parts[1] = '0'
parts[2] = '0'
else: else:
parts.append('1') # Default: patch increment
parts[2] = str(int(parts[2]) + 1)
new_version = '.'.join(parts) new_version = '.'.join(parts)
data['version'] = new_version data['version'] = new_version