From 889b5e838725ee422588566f76c1ae93c28be1a4 Mon Sep 17 00:00:00 2001 From: Wiktor Jarka <1118683+wjarka@users.noreply.github.com> Date: Sun, 30 Nov 2025 22:26:06 +0100 Subject: [PATCH 1/6] feat: Add GS1 barcode parsing to extract GTIN and expiration dates, adjusting database and API date handling. --- incl/GS1Parser.php | 121 ++++++++++++++++++++++++++++++++++++++++ incl/api.inc.php | 11 +++- incl/db.inc.php | 6 +- incl/processing.inc.php | 16 ++++++ 4 files changed, 149 insertions(+), 5 deletions(-) create mode 100644 incl/GS1Parser.php diff --git a/incl/GS1Parser.php b/incl/GS1Parser.php new file mode 100644 index 00000000..5a794782 --- /dev/null +++ b/incl/GS1Parser.php @@ -0,0 +1,121 @@ +barcode = $barcode; + $this->parse(); + } + + private function parse() { + // Remove symbology identifier if present (e.g. ]d2 for GS1 DataMatrix) + $code = $this->barcode; + if (substr($code, 0, 3) === ']d2') { + $code = substr($code, 3); + } + + // Replace group separators (GS) with a common delimiter if needed, + // or just rely on regex. ASCII 29 is GS. + // Some scanners might map it to something else, but let's assume raw input or specific mapping. + // For now, we'll try to parse standard AIs. + + // AI 01: GTIN (14 digits) + // AI 17: Expiration Date (YYMMDD) + // AI 10: Batch/Lot (Variable length) - we might encounter this + // AI 21: Serial (Variable length) + + // Simple parsing strategy: + // 1. Look for 01 (GTIN) - fixed length 14 + // 2. Look for 17 (Exp) - fixed length 6 + + // We need to handle the stream. + $offset = 0; + $length = strlen($code); + + while ($offset < $length) { + $ai2 = substr($code, $offset, 2); + + if ($ai2 === '01') { + // GTIN: 14 digits + $this->gtin = substr($code, $offset + 2, 14); + $offset += 16; + } elseif ($ai2 === '17') { + // Expiration: 6 digits YYMMDD + $rawDate = substr($code, $offset + 2, 6); + $this->expirationDate = $this->parseDate($rawDate); + $offset += 8; + } elseif ($ai2 === '10') { + // Batch: Variable length, terminated by FNC1 or end of string + $offset += 2; + $end = $this->findNextSeparator($code, $offset); + $offset = $end; + } elseif ($ai2 === '21') { + // Serial: Variable length + $offset += 2; + $end = $this->findNextSeparator($code, $offset); + $offset = $end; + } else { + // Unknown AI or end of useful data for us. + // If we have what we need, we can stop. + // If we encounter something we don't know, we might get stuck if it's variable length. + // For now, let's just try to skip 1 char if we don't match known fixed AIs? + // No, that's dangerous. + // Let's assume standard ordering or just regex search if parsing fails. + + // Fallback: Regex search for 01 and 17 if strict parsing fails? + // Let's try to be robust. + break; + } + } + } + + private function findNextSeparator($code, $offset) { + // Check for ASCII 29 (GS) + $pos = strpos($code, chr(29), $offset); + if ($pos !== false) { + return $pos + 1; // Skip the GS + } + 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)); + + // GS1 Date logic: + // DD = 00 means last day of month + + // Century assumption: + // GS1 General Specifications say: + // 51-99 = 1951-1999 + // 00-50 = 2000-2050 + // (This is a sliding window, usually +/- 50 years from now, but let's stick to simple logic for now) + $fullYear = ($yy >= 50 ? 1900 : 2000) + $yy; + + if ($dd === 0) { + // Last day of month + $dd = date('t', strtotime("$fullYear-$mm-01")); + } + + return sprintf("%04d-%02d-%02d", $fullYear, $mm, $dd); + } + + public function getGtin() { + return $this->gtin; + } + + public function getExpirationDate() { + return $this->expirationDate; + } + + public function isValid() { + return !empty($this->gtin); + } +} diff --git a/incl/api.inc.php b/incl/api.inc.php index 45b1c56c..13f9dc82 100755 --- a/incl/api.inc.php +++ b/incl/api.inc.php @@ -356,7 +356,16 @@ public static function purchaseProduct(int $id, float $amount, string $bestbefor 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"; diff --git a/incl/db.inc.php b/incl/db.inc.php index c8500cb1..cb997832 100755 --- a/incl/db.inc.php +++ b/incl/db.inc.php @@ -450,8 +450,7 @@ public function setQuantityToUnknownBarcode(string $barcode, float $amount): voi * @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"; @@ -473,8 +472,7 @@ public function insertUnrecognizedBarcode(string $barcode, float $amount = 1, st * @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')"); diff --git a/incl/processing.inc.php b/incl/processing.inc.php index 393e1747..232b5ff1 100755 --- a/incl/processing.inc.php +++ b/incl/processing.inc.php @@ -36,6 +36,22 @@ function processNewBarcode(string $barcodeInput, ?string $bestBeforeInDays = nul $config = BBConfig::getInstance(); $barcode = strtoupper($barcodeInput); + + // Check for GS1 Datamatrix + // GS1 with AI 01 must be at least 16 chars (2 for AI + 14 for GTIN) + if (strpos($barcode, ']D2') === 0 || (strpos($barcode, '01') === 0 && strlen($barcode) >= 16)) { + // Try parsing as GS1 + require_once __DIR__ . "/GS1Parser.php"; + $parser = new GS1Parser($barcodeInput); // Use original input to preserve case/chars if needed + 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); From 7814e516aca895f5e596a3302ea59dff98ceff22 Mon Sep 17 00:00:00 2001 From: Wiktor Jarka <1118683+wjarka@users.noreply.github.com> Date: Mon, 1 Dec 2025 11:35:26 +0100 Subject: [PATCH 2/6] feat: Add a configurable option to enable or disable GS1/Datamatrix parsing. --- incl/db.inc.php | 1 + incl/processing.inc.php | 2 +- menu/settings.php | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/incl/db.inc.php b/incl/db.inc.php index cb997832..f72a9adc 100755 --- a/incl/db.inc.php +++ b/incl/db.inc.php @@ -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, diff --git a/incl/processing.inc.php b/incl/processing.inc.php index 232b5ff1..9a80bf8b 100755 --- a/incl/processing.inc.php +++ b/incl/processing.inc.php @@ -39,7 +39,7 @@ function processNewBarcode(string $barcodeInput, ?string $bestBeforeInDays = nul // Check for GS1 Datamatrix // GS1 with AI 01 must be at least 16 chars (2 for AI + 14 for GTIN) - if (strpos($barcode, ']D2') === 0 || (strpos($barcode, '01') === 0 && strlen($barcode) >= 16)) { + if ($config["GS1_PARSING_ENABLED"] && (strpos($barcode, ']D2') === 0 || (strpos($barcode, '01') === 0 && strlen($barcode) >= 16))) { // Try parsing as GS1 require_once __DIR__ . "/GS1Parser.php"; $parser = new GS1Parser($barcodeInput); // Use original input to preserve case/chars if needed diff --git a/menu/settings.php b/menu/settings.php index 501e443c..fb4f6d4b 100755 --- a/menu/settings.php +++ b/menu/settings.php @@ -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('Hint: You can find picture files of the default barcodes in the "example" folder or online'); From 75b817254f6848803a6fee24821bb9f0d66a0ac4 Mon Sep 17 00:00:00 2001 From: Marc Bulling Date: Thu, 3 Sep 2026 11:00:21 +0200 Subject: [PATCH 3/6] Better checking if GS1 barcode --- incl/processing.inc.php | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/incl/processing.inc.php b/incl/processing.inc.php index bc7dbff7..ae9fbb33 100755 --- a/incl/processing.inc.php +++ b/incl/processing.inc.php @@ -37,12 +37,16 @@ function processNewBarcode(string $barcodeInput, ?string $bestBeforeInDays = nul $barcode = strtoupper($barcodeInput); - // Check for GS1 Datamatrix - // GS1 with AI 01 must be at least 16 chars (2 for AI + 14 for GTIN) - if ($config["GS1_PARSING_ENABLED"] && (strpos($barcode, ']D2') === 0 || (strpos($barcode, '01') === 0 && strlen($barcode) >= 16))) { + // 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); // Use original input to preserve case/chars if needed + $parser = new GS1Parser($barcodeInput); if ($parser->isValid()) { $barcode = $parser->getGtin(); $expDate = $parser->getExpirationDate(); From 34a750877f347036e7885aa832a60554e5403c98 Mon Sep 17 00:00:00 2001 From: Marc Bulling Date: Thu, 3 Sep 2026 11:02:11 +0200 Subject: [PATCH 4/6] Better parsing of GS1 code --- incl/GS1Parser.php | 104 ++++++++++++++++++++++++--------------------- 1 file changed, 55 insertions(+), 49 deletions(-) diff --git a/incl/GS1Parser.php b/incl/GS1Parser.php index 5a794782..3911ca48 100644 --- a/incl/GS1Parser.php +++ b/incl/GS1Parser.php @@ -4,79 +4,70 @@ class GS1Parser { private $barcode; private $gtin; private $expirationDate; + private $batch; + private $serial; public function __construct(string $barcode) { - $this->barcode = $barcode; + $this->barcode = trim($barcode); $this->parse(); } private function parse() { - // Remove symbology identifier if present (e.g. ]d2 for GS1 DataMatrix) $code = $this->barcode; - if (substr($code, 0, 3) === ']d2') { - $code = substr($code, 3); + + // 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); } - // Replace group separators (GS) with a common delimiter if needed, - // or just rely on regex. ASCII 29 is GS. - // Some scanners might map it to something else, but let's assume raw input or specific mapping. - // For now, we'll try to parse standard AIs. - - // AI 01: GTIN (14 digits) - // AI 17: Expiration Date (YYMMDD) - // AI 10: Batch/Lot (Variable length) - we might encounter this - // AI 21: Serial (Variable length) - - // Simple parsing strategy: - // 1. Look for 01 (GTIN) - fixed length 14 - // 2. Look for 17 (Exp) - fixed length 6 - - // We need to handle the stream. $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 + // GTIN: 14 digits fixed length $this->gtin = substr($code, $offset + 2, 14); $offset += 16; } elseif ($ai2 === '17') { - // Expiration: 6 digits YYMMDD + // Expiration: 6 digits YYMMDD fixed length $rawDate = substr($code, $offset + 2, 6); $this->expirationDate = $this->parseDate($rawDate); $offset += 8; } elseif ($ai2 === '10') { - // Batch: Variable length, terminated by FNC1 or end of string + // 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 + // Serial: Variable length (up to 20 chars) $offset += 2; $end = $this->findNextSeparator($code, $offset); + $this->serial = substr($code, $offset, $end - $offset); $offset = $end; } else { - // Unknown AI or end of useful data for us. - // If we have what we need, we can stop. - // If we encounter something we don't know, we might get stuck if it's variable length. - // For now, let's just try to skip 1 char if we don't match known fixed AIs? - // No, that's dangerous. - // Let's assume standard ordering or just regex search if parsing fails. - - // Fallback: Regex search for 01 and 17 if strict parsing fails? - // Let's try to be robust. - break; + // Stop parsing if an unknown AI is encountered to prevent infinite loops + break; } } } private function findNextSeparator($code, $offset) { - // Check for ASCII 29 (GS) + // Look for ASCII 29 (GS) $pos = strpos($code, chr(29), $offset); if ($pos !== false) { - return $pos + 1; // Skip the GS + return $pos; // Returns index of GS separator so the caller extracts data up to it } return strlen($code); } @@ -85,23 +76,30 @@ 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)); - // GS1 Date logic: - // DD = 00 means last day of month - - // Century assumption: - // GS1 General Specifications say: - // 51-99 = 1951-1999 - // 00-50 = 2000-2050 - // (This is a sliding window, usually +/- 50 years from now, but let's stick to simple logic for now) - $fullYear = ($yy >= 50 ? 1900 : 2000) + $yy; + 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) { - // Last day of month - $dd = date('t', strtotime("$fullYear-$mm-01")); + $dd = intval(date('t', strtotime(sprintf('%04d-%02d-01', $fullYear, $mm)))); } return sprintf("%04d-%02d-%02d", $fullYear, $mm, $dd); @@ -114,8 +112,16 @@ public function getGtin() { 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); + return !empty($this->gtin) && strlen($this->gtin) === 14; } } From 218963784d54fac041faf02314692875dd37e6a2 Mon Sep 17 00:00:00 2001 From: Marc Bulling Date: Thu, 3 Sep 2026 11:20:41 +0200 Subject: [PATCH 5/6] Remove padding of barcode if not found --- incl/api.inc.php | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/incl/api.inc.php b/incl/api.inc.php index f6158363..51ffa043 100755 --- a/incl/api.inc.php +++ b/incl/api.inc.php @@ -595,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 = substr($barcode, 1); + if (isset($allBarcodes[$unpaddedBarcode])) { + return self::getProductInfo($allBarcodes[$unpaddedBarcode]["id"]); + } + } + + return null; } private static function getProductIdFromGrocyCode(string $barcode): ?int { From 395faeb7b390eba06ca22b3ecd0773515acc73cb Mon Sep 17 00:00:00 2001 From: Marc Bulling Date: Thu, 3 Sep 2026 11:24:14 +0200 Subject: [PATCH 6/6] Remove all padding 0s --- incl/api.inc.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/incl/api.inc.php b/incl/api.inc.php index 51ffa043..d30b5d06 100755 --- a/incl/api.inc.php +++ b/incl/api.inc.php @@ -602,10 +602,10 @@ public static function getProductByBarcode(string $barcode, bool $ignoreCache = // 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 = substr($barcode, 1); - if (isset($allBarcodes[$unpaddedBarcode])) { - return self::getProductInfo($allBarcodes[$unpaddedBarcode]["id"]); - } + $unpaddedBarcode = ltrim($barcode, '0'); + if (!empty($unpaddedBarcode) && isset($allBarcodes[$unpaddedBarcode])) { + return self::getProductInfo($allBarcodes[$unpaddedBarcode]["id"]); + } } return null;