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
127 changes: 127 additions & 0 deletions incl/GS1Parser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<?php

class GS1Parser {
private $barcode;
private $gtin;
private $expirationDate;
private $batch;
private $serial;

public function __construct(string $barcode) {
$this->barcode = trim($barcode);
$this->parse();
}

private function parse() {
$code = $this->barcode;

// 1. Remove symbology identifier if present (e.g. ]d2, ]D2, ]e0)
$code = preg_replace('/^\][a-zA-Z0-9]{2}/', '', $code);

// 2. Normalize Human-Readable input by removing AI parentheses if present: "(01)...(17)..." -> "01...17..."
if (strpos($code, '(') !== false && strpos($code, ')') !== false) {
$code = str_replace(['(', ')'], '', $code);
}

$offset = 0;
$length = strlen($code);

while ($offset < $length) {
// Skip any leading Group Separator (ASCII 29) before reading the next AI
if (ord($code[$offset]) === 29) {
$offset++;
continue;
}

$ai2 = substr($code, $offset, 2);

if ($ai2 === '01') {
// GTIN: 14 digits fixed length
$this->gtin = substr($code, $offset + 2, 14);
$offset += 16;
} elseif ($ai2 === '17') {
// Expiration: 6 digits YYMMDD fixed length
$rawDate = substr($code, $offset + 2, 6);
$this->expirationDate = $this->parseDate($rawDate);
$offset += 8;
} elseif ($ai2 === '10') {
// Batch/Lot: Variable length (up to 20 chars)
$offset += 2;
$end = $this->findNextSeparator($code, $offset);
$this->batch = substr($code, $offset, $end - $offset);
$offset = $end;
} elseif ($ai2 === '21') {
// Serial: Variable length (up to 20 chars)
$offset += 2;
$end = $this->findNextSeparator($code, $offset);
$this->serial = substr($code, $offset, $end - $offset);
$offset = $end;
} else {
// Stop parsing if an unknown AI is encountered to prevent infinite loops
break;
}
}
}

private function findNextSeparator($code, $offset) {
// Look for ASCII 29 (GS)
$pos = strpos($code, chr(29), $offset);
if ($pos !== false) {
return $pos; // Returns index of GS separator so the caller extracts data up to it
}
return strlen($code);
}

private function parseDate($yymmdd) {
if (strlen($yymmdd) !== 6 || !is_numeric($yymmdd)) {
return null;
}

$yy = intval(substr($yymmdd, 0, 2));
$mm = intval(substr($yymmdd, 2, 2));
$dd = intval(substr($yymmdd, 4, 2));

if ($mm < 1 || $mm > 12) {
return null;
}

// GS1 Sliding Window Century Calculation:
// Rolling window: -51 years to +48 years relative to current year
$currentYear = intval(date('Y'));
$currentCentury = intval(floor($currentYear / 100) * 100);
$fullYear = $currentCentury + $yy;

if ($fullYear - $currentYear > 48) {
$fullYear -= 100;
} elseif ($fullYear - $currentYear < -51) {
$fullYear += 100;
}

// GS1 Date logic: DD = 00 means last day of specified month
if ($dd === 0) {
$dd = intval(date('t', strtotime(sprintf('%04d-%02d-01', $fullYear, $mm))));
}

return sprintf("%04d-%02d-%02d", $fullYear, $mm, $dd);
}

public function getGtin() {
return $this->gtin;
}

public function getExpirationDate() {
return $this->expirationDate;
}

public function getBatch() {
return $this->batch;
}

public function getSerial() {
return $this->serial;
}

public function isValid() {
return !empty($this->gtin) && strlen($this->gtin) === 14;
}
}
30 changes: 24 additions & 6 deletions incl/api.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,16 @@ public static function purchaseProduct(int $id, float $amount, ?string $bestbefo
else
$daysBestBefore = self::getDefaultBestBeforeDays($id);
}
$data['best_before_date'] = self::formatBestBeforeDays($daysBestBefore);

// Check if $daysBestBefore is a date string (YYYY-MM-DD)
if (preg_match("/^\d{4}-\d{2}-\d{2}$/", (string)$daysBestBefore)) {
$data['best_before_date'] = $daysBestBefore;
// We set this to a non-zero value to indicate success later
$daysBestBefore = 1;
} else {
$data['best_before_date'] = self::formatBestBeforeDays((int)$daysBestBefore);
}

$data_json = json_encode($data);
$url = API_STOCK . "/" . $id . "/add";

Expand Down Expand Up @@ -586,11 +595,20 @@ public static function getProductByBarcode(string $barcode, bool $ignoreCache =
return self::getProductInfo(checkIfNumeric($id));
}
$allBarcodes = self::getAllBarcodes($ignoreCache);
if (!isset($allBarcodes[$barcode])) {
return null;
} else {
return self::getProductInfo($allBarcodes[$barcode]["id"]);
}
// Look for the raw scanned barcode (e.g., full 14-digit GTIN)
if (isset($allBarcodes[$barcode])) {
return self::getProductInfo($allBarcodes[$barcode]["id"]);
}

// Fallback check: If not found, strip leading '0' padding from GTIN-14 and retry (e.g., convert to EAN-13)
if (strlen($barcode) === 14 && $barcode[0] === '0') {
$unpaddedBarcode = ltrim($barcode, '0');
if (!empty($unpaddedBarcode) && isset($allBarcodes[$unpaddedBarcode])) {
return self::getProductInfo($allBarcodes[$unpaddedBarcode]["id"]);
}
}

return null;
}

private static function getProductIdFromGrocyCode(string $barcode): ?int {
Expand Down
8 changes: 4 additions & 4 deletions incl/db.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ class DatabaseConnection {
"LOOKUP_USE_UPC_DATABASE" => "0",
"LOOKUP_USE_OPEN_GTIN_DATABASE" => "0",
"LOOKUP_USE_DISCOGS" => "0",
"GS1_PARSING_ENABLED" => "1",
"LOOKUP_USE_BBUDDY_SERVER" => "0",
"LOOKUP_UPC_DATABASE_KEY" => null,
"LOOKUP_OPENGTIN_KEY" => null,
Expand Down Expand Up @@ -449,9 +450,9 @@ public function setQuantityToUnknownBarcode(string $barcode, float $amount): voi
* @param array|null $productname
* @return void
*/

public function insertUnrecognizedBarcode(string $barcode, float $amount = 1, ?string $bestBeforeInDays = null, ?string $price = null, ?array $productname = null): void {
if ($bestBeforeInDays == null)
$bestBeforeInDays = "NULL";
$bestBeforeInDays = ($bestBeforeInDays === null) ? "NULL" : "'" . trim($bestBeforeInDays, "'") . "'";

if ($productname == null) {
$name = "N/A";
Expand All @@ -473,8 +474,7 @@ public function insertUnrecognizedBarcode(string $barcode, float $amount = 1, ?s
* @param null|string $price
*/
public function insertActionRequiredBarcode(string $barcode, ?string $bestBeforeInDays = null, ?string $price = null): void {
if ($bestBeforeInDays == null)
$bestBeforeInDays = "NULL";
$bestBeforeInDays = ($bestBeforeInDays === null) ? "NULL" : "'" . trim($bestBeforeInDays, "'") . "'";

$this->db->exec("INSERT INTO Barcodes(barcode, name, amount, possibleMatch, requireWeight, bestBeforeInDays, price)
VALUES('$barcode', 'N/A', 1, 0, 1, $bestBeforeInDays, '$price')");
Expand Down
20 changes: 20 additions & 0 deletions incl/processing.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,26 @@ function processNewBarcode(string $barcodeInput, ?string $bestBeforeInDays = nul
$config = BBConfig::getInstance();

$barcode = strtoupper($barcodeInput);

// Check if code has GS1 DataMatrix identifier, human-readable brackets, or GS1 control char (ASCII 29)
if ($config["GS1_PARSING_ENABLED"] && (
(strpos($barcodeInput, ']d2') === 0 || strpos($barcodeInput, ']D2') === 0)
|| (strpos($barcodeInput, '(01)') === 0)
|| (strpos($barcodeInput, '01') === 0 && strpos($barcodeInput, chr(29)) !== false)
|| (strpos($barcodeInput, '01') === 0 && strlen($barcodeInput) >= 16)
) {
// Try parsing as GS1
require_once __DIR__ . "/GS1Parser.php";
$parser = new GS1Parser($barcodeInput);
if ($parser->isValid()) {
$barcode = $parser->getGtin();
$expDate = $parser->getExpirationDate();
if ($expDate != null) {
$bestBeforeInDays = $expDate;
}
}
}

if ($barcode == $config["BARCODE_C"]) {
$db->setTransactionState(STATE_CONSUME);
return createLogModeChange(STATE_CONSUME);
Expand Down
1 change: 1 addition & 0 deletions menu/settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ function getHtmlSettingsGeneral(): string {
$html->addCheckbox("USE_GENERIC_NAME", "Use generic names for lookup", $config["USE_GENERIC_NAME"], false, false);
$html->addCheckbox("SHOW_STOCK_ON_SCAN", "Show stock amount on scan", $config["SHOW_STOCK_ON_SCAN"], false, false);
$html->addCheckbox("SAVE_BARCODE_NAME", "Save name from lookup to barcode", $config["SAVE_BARCODE_NAME"], false, false);
$html->addCheckbox("GS1_PARSING_ENABLED", "Enable GS1/Datamatrix parsing", $config["GS1_PARSING_ENABLED"], false, false);
$html->addCheckbox("MORE_VERBOSE", "More verbose logs", $config["MORE_VERBOSE"], false, false);
$html->addLineBreak(2);
$html->addHtml('<small><i>Hint: You can find picture files of the default barcodes in the &quot;example&quot; folder or <a style="color: inherit;" href="https://github.com/Forceu/barcodebuddy/tree/master/example/defaultBarcodes">online</a></i></small>');
Expand Down