Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
0e70f2f
added handling of LCSC barcode decoding and part loading on Label Sca…
swdee Jan 16, 2026
86a6342
when a part is scanned and not found, the scanner did not redraw so s…
swdee Jan 16, 2026
7900d30
added redirection to part page on successful scan of lcsc, digikey, a…
swdee Jan 16, 2026
1571e75
added augmented mode to label scanner to use vendor labels for part l…
swdee Jan 16, 2026
df68e3a
shrink camera height on mobile so augmented information can been view…
swdee Jan 16, 2026
c07d4ab
handle momentarily bad reads from qrcode library
swdee Jan 16, 2026
5885ac1
removed augmented checkbox and combined functionality into info mode …
swdee Jan 17, 2026
8f63a9f
fix scanning of part-db barcodes to redirect to storage location or p…
swdee Jan 17, 2026
1484cea
fix static analysis errors
swdee Jan 19, 2026
4881418
added unit tests for meeting code coverage report
swdee Jan 19, 2026
2d55b90
fix @MayNiklas reported bug: when manually submitting the form (from…
swdee Feb 18, 2026
a39eeb4
fix @d-buchmann bug: clear 'scan-augmented-result' field upon rescan…
swdee Feb 18, 2026
b31cbf8
fix @d-buchmann bug: after scanning in Info mode, if Info mode is tur…
swdee Feb 18, 2026
4865f07
fix @d-buchmann bug: make barcode decode table 100% width of page
swdee Feb 18, 2026
c5ea4d2
fix bug with manual form submission where a part does not exist but d…
swdee Feb 18, 2026
0010ee8
fixed translation messages
swdee Feb 19, 2026
5100469
Use symfony native functions to generate the routes for part creation
jbtronics Feb 21, 2026
76584c3
Use native request functions for request param parsing
jbtronics Feb 21, 2026
338c5eb
Refactored LCSCBarcocdeScanResult to be an value object like the othe…
jbtronics Feb 21, 2026
a8520b7
Added test for LCSCBarcodeScanResult
jbtronics Feb 21, 2026
851061a
Fixed exception when submitting form for info mode
jbtronics Feb 21, 2026
a9a1f1d
Made BarcodeSourceType a backed enum, so that it can be used in Reque…
jbtronics Feb 21, 2026
f77d201
Moved database queries from BarcodeRedirector to PartRepository
jbtronics Feb 21, 2026
f22bff7
Fixed modeEnum parsing
jbtronics Feb 21, 2026
e034507
Fixed test errors
jbtronics Feb 21, 2026
f45960e
Refactored BarcodeRedirector logic to be more universal
jbtronics Feb 22, 2026
35222f1
Fixed BarcodeScanResultHandler test
jbtronics Feb 22, 2026
caa71bb
Refactored BarcodeScanResultHandler to be able to resolve arbitary en…
jbtronics Feb 22, 2026
8dd972f
Moved barcode to info provider logic from Controller to BarcodeScanRe…
jbtronics Feb 22, 2026
bfa9b9e
Improved augmentented info styling and allow to use it with normal fo…
jbtronics Feb 22, 2026
05ee315
Correctly handle nullable infoURL in ScanController
jbtronics Feb 22, 2026
910ad93
Replaced the custom controller for fragment replacements with symfony…
jbtronics Feb 22, 2026
4fd78c0
Removed data-lookup-url attribute from scan read box
jbtronics Feb 22, 2026
0805a83
Removed unused translations
jbtronics Feb 22, 2026
be767fc
Added basic info block when an storage location was found for an barcode
jbtronics Feb 22, 2026
b1b66e8
Fixed phpstan issues
jbtronics Feb 22, 2026
68bcc39
Fixed tests
jbtronics Feb 22, 2026
38431e8
Fixed part image for mobile view
jbtronics Feb 22, 2026
62a44a4
Added more tests for BarcodeScanResultHandler service
jbtronics Feb 22, 2026
fc1367b
Fixed tests
jbtronics Feb 22, 2026
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
6 changes: 5 additions & 1 deletion assets/controllers/common/toast_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
import { Controller } from '@hotwired/stimulus';
import { Toast } from 'bootstrap';

/**
* The purpose of this controller, is to show all containers.
* They should already be added via turbo-streams, but have to be called for to show them.
*/
export default class extends Controller {
connect() {
//Move all toasts from the page into our toast container and show them
Expand All @@ -33,4 +37,4 @@ export default class extends Controller {
const toast = new Toast(this.element);
toast.show();
}
}
}
70 changes: 58 additions & 12 deletions assets/controllers/pages/barcode_scan_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,31 @@ import {Controller} from "@hotwired/stimulus";
//import * as ZXing from "@zxing/library";

import {Html5QrcodeScanner, Html5Qrcode} from "@part-db/html5-qrcode";
import { generateCsrfToken, generateCsrfHeaders } from "../csrf_protection_controller";

/* stimulusFetch: 'lazy' */

export default class extends Controller {
_scanner = null;
_submitting = false;
_lastDecodedText = "";
_onInfoChange = null;

//codeReader = null;
connect() {

_scanner = null;
// Prevent double init if connect fires twice
if (this._scanner) return;

// clear last decoded barcode when state changes on info box
const info = document.getElementById("scan_dialog_info_mode");
if (info) {
this._onInfoChange = () => {
this._lastDecodedText = "";
};
info.addEventListener("change", this._onInfoChange);
}

connect() {
console.log('Init Scanner');
const isMobile = window.matchMedia("(max-width: 768px)").matches;

//This function ensures, that the qrbox is 70% of the total viewport
let qrboxFunction = function(viewfinderWidth, viewfinderHeight) {
Expand All @@ -45,29 +59,61 @@ export default class extends Controller {
}

//Try to get the number of cameras. If the number is 0, then the promise will fail, and we show the warning dialog
Html5Qrcode.getCameras().catch((devices) => {
document.getElementById('scanner-warning').classList.remove('d-none');
Html5Qrcode.getCameras().catch(() => {
document.getElementById("scanner-warning")?.classList.remove("d-none");
});

this._scanner = new Html5QrcodeScanner(this.element.id, {
fps: 10,
qrbox: qrboxFunction,
// Key change: shrink preview height on mobile
...(isMobile ? { aspectRatio: 1.0 } : {}),
experimentalFeatures: {
//This option improves reading quality on android chrome
useBarCodeDetectorIfSupported: true
}
useBarCodeDetectorIfSupported: true,
},
}, false);

this._scanner.render(this.onScanSuccess.bind(this));
}

disconnect() {
this._scanner.pause();
this._scanner.clear();

// If we already stopped/cleared before submit, nothing to do.
const scanner = this._scanner;
this._scanner = null;
this._lastDecodedText = "";

// Unbind info-mode change handler (always do this, even if scanner is null)
const info = document.getElementById("scan_dialog_info_mode");
if (info && this._onInfoChange) {
info.removeEventListener("change", this._onInfoChange);
}
this._onInfoChange = null;

if (!scanner) return;

try {
const p = scanner.clear?.();
if (p && typeof p.then === "function") p.catch(() => {});
} catch (_) {
// ignore
}
}

onScanSuccess(decodedText, decodedResult) {
//Put our decoded Text into the input box

onScanSuccess(decodedText) {
if (!decodedText) return;

const normalized = String(decodedText).trim();
if (!normalized) return;

// scan once per barcode
if (normalized === this._lastDecodedText) return;

// Mark as handled immediately (prevents spam even if callback fires repeatedly)
this._lastDecodedText = normalized;

document.getElementById('scan_dialog_input').value = decodedText;
//Submit form
document.getElementById('scan_dialog_form').requestSubmit();
Expand Down
6 changes: 6 additions & 0 deletions assets/css/app/images.css
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@
object-fit: contain;
}

@media (max-width: 768px) {
.part-info-image {
max-height: 100px;
}
}

.object-fit-cover {
object-fit: cover;
}
123 changes: 103 additions & 20 deletions src/Controller/ScanController.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,28 +41,41 @@

namespace App\Controller;

use App\Exceptions\InfoProviderNotActiveException;
use App\Form\LabelSystem\ScanDialogType;
use App\Services\LabelSystem\BarcodeScanner\BarcodeRedirector;
use App\Services\InfoProviderSystem\Providers\LCSCProvider;
use App\Services\LabelSystem\BarcodeScanner\BarcodeScanResultHandler;
use App\Services\LabelSystem\BarcodeScanner\BarcodeScanHelper;
use App\Services\LabelSystem\BarcodeScanner\BarcodeScanResultInterface;
use App\Services\LabelSystem\BarcodeScanner\BarcodeSourceType;
use App\Services\LabelSystem\BarcodeScanner\LocalBarcodeScanResult;
use App\Services\LabelSystem\BarcodeScanner\LCSCBarcodeScanResult;
use App\Services\LabelSystem\BarcodeScanner\EIGP114BarcodeScanResult;
use Doctrine\ORM\EntityNotFoundException;
use InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
use Symfony\Component\Routing\Attribute\Route;
use App\Services\InfoProviderSystem\PartInfoRetriever;
use App\Services\InfoProviderSystem\ProviderRegistry;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use App\Entity\Parts\Part;
use \App\Entity\Parts\StorageLocation;
use Symfony\UX\Turbo\TurboBundle;

/**
* @see \App\Tests\Controller\ScanControllerTest
*/
#[Route(path: '/scan')]
class ScanController extends AbstractController
{
public function __construct(protected BarcodeRedirector $barcodeParser, protected BarcodeScanHelper $barcodeNormalizer)
{
}
public function __construct(
protected BarcodeScanResultHandler $resultHandler,
protected BarcodeScanHelper $barcodeNormalizer,
) {}

#[Route(path: '', name: 'scan_dialog')]
public function dialog(Request $request, #[MapQueryParameter] ?string $input = null): Response
Expand All @@ -72,35 +85,86 @@ public function dialog(Request $request, #[MapQueryParameter] ?string $input = n
$form = $this->createForm(ScanDialogType::class);
$form->handleRequest($request);

// If JS is working, scanning uses /scan/lookup and this action just renders the page.
// This fallback only runs if user submits the form manually or uses ?input=...
if ($input === null && $form->isSubmitted() && $form->isValid()) {
$input = $form['input']->getData();
$mode = $form['mode']->getData();
}

$infoModeData = null;

if ($input !== null) {
if ($input !== null && $input !== '') {
$mode = $form->isSubmitted() ? $form['mode']->getData() : null;
$infoMode = $form->isSubmitted() && $form['info_mode']->getData();

try {
$scan_result = $this->barcodeNormalizer->scanBarcodeContent($input, $mode ?? null);
//Perform a redirect if the info mode is not enabled
if (!$form['info_mode']->getData()) {
try {
return $this->redirect($this->barcodeParser->getRedirectURL($scan_result));
} catch (EntityNotFoundException) {
$this->addFlash('success', 'scan.qr_not_found');
$scan = $this->barcodeNormalizer->scanBarcodeContent($input, $mode ?? null);

// If not in info mode, mimic “normal scan” behavior: redirect if possible.
if (!$infoMode) {

// Try to get an Info URL if possible
$url = $this->resultHandler->getInfoURL($scan);
if ($url !== null) {
return $this->redirect($url);
}

//Try to get an creation URL if possible (only for vendor codes)
$createUrl = $this->buildCreateUrlForScanResult($scan);
if ($createUrl !== null) {
return $this->redirect($createUrl);
}

//// Otherwise: show “not found” (not “format unknown”)
$this->addFlash('warning', 'scan.qr_not_found');
} else { // Info mode
// Info mode fallback: render page with prefilled result
$decoded = $scan->getDecodedForInfoMode();

//Try to resolve to an entity, to enhance info mode with entity-specific data
$dbEntity = $this->resultHandler->resolveEntity($scan);
$resolvedPart = $this->resultHandler->resolvePart($scan);
$openUrl = $this->resultHandler->getInfoURL($scan);

//If no entity is found, try to create an URL for creating a new part (only for vendor codes)
$createUrl = null;
if ($dbEntity === null) {
$createUrl = $this->buildCreateUrlForScanResult($scan);
}

if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) {
$request->setRequestFormat(TurboBundle::STREAM_FORMAT);
return $this->renderBlock('label_system/scanner/scanner.html.twig', 'scan_results', [
'decoded' => $decoded,
'entity' => $dbEntity,
'part' => $resolvedPart,
'openUrl' => $openUrl,
'createUrl' => $createUrl,
]);
}
} else { //Otherwise retrieve infoModeData
$infoModeData = $scan_result->getDecodedForInfoMode();

}
} catch (InvalidArgumentException) {
$this->addFlash('error', 'scan.format_unknown');
} catch (\Throwable $e) {
// Keep fallback user-friendly; avoid 500
$this->addFlash('warning', 'scan.format_unknown');
}
}

//When we reach here, only the flash messages are relevant, so if it's a Turbo request, only send the flash message fragment, so the client can show it without a full page reload
if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) {
$request->setRequestFormat(TurboBundle::STREAM_FORMAT);
//Only send our flash message, so the client can show it without a full page reload
return $this->renderBlock('_turbo_control.html.twig', 'flashes');
}

return $this->render('label_system/scanner/scanner.html.twig', [
'form' => $form,
'infoModeData' => $infoModeData,

//Info mode
'decoded' => $decoded ?? null,
'entity' => $dbEntity ?? null,
'part' => $resolvedPart ?? null,
'openUrl' => $openUrl ?? null,
'createUrl' => $createUrl ?? null,
]);
}

Expand All @@ -125,11 +189,30 @@ public function scanQRCode(string $type, int $id): Response
source_type: BarcodeSourceType::INTERNAL
);

return $this->redirect($this->barcodeParser->getRedirectURL($scan_result));
return $this->redirect($this->resultHandler->getInfoURL($scan_result) ?? throw new EntityNotFoundException("Not found"));
} catch (EntityNotFoundException) {
$this->addFlash('success', 'scan.qr_not_found');

return $this->redirectToRoute('homepage');
}
}

/**
* Builds a URL for creating a new part based on the barcode data, handles exceptions and shows user-friendly error messages if the provider is not active or if there is an error during URL generation.
* @param BarcodeScanResultInterface $scanResult
* @return string|null
*/
private function buildCreateUrlForScanResult(BarcodeScanResultInterface $scanResult): ?string
{
try {
return $this->resultHandler->getCreationURL($scanResult);
} catch (InfoProviderNotActiveException $e) {
$this->addFlash('error', $e->getMessage());
} catch (\Throwable) {
// Don’t break scanning UX if provider lookup fails
$this->addFlash('error', 'An error occurred while looking up the provider for this barcode. Please try again later.');
}

return null;
}
}
48 changes: 48 additions & 0 deletions src/Exceptions/InfoProviderNotActiveException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

declare(strict_types=1);


namespace App\Exceptions;

use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;

/**
* An exception denoting that a required info provider is not active. This can be used to display a user-friendly error message,
* when a user tries to use an info provider that is not active.
*/
class InfoProviderNotActiveException extends \RuntimeException
{
public function __construct(public readonly string $providerKey, public readonly string $friendlyName)
{
parent::__construct(sprintf('The info provider "%s" (%s) is not active.', $this->friendlyName, $this->providerKey));
}

/**
* Creates an instance of this exception from an info provider instance
* @param InfoProviderInterface $provider
* @return self
*/
public static function fromProvider(InfoProviderInterface $provider): self
{
return new self($provider->getProviderKey(), $provider->getProviderInfo()['name'] ?? '???');
}
}
1 change: 1 addition & 0 deletions src/Form/LabelSystem/ScanDialogType.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void
BarcodeSourceType::USER_DEFINED => 'scan_dialog.mode.user',
BarcodeSourceType::EIGP114 => 'scan_dialog.mode.eigp',
BarcodeSourceType::GTIN => 'scan_dialog.mode.gtin',
BarcodeSourceType::LCSC => 'scan_dialog.mode.lcsc',
},
]);

Expand Down
Loading
Loading