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;