hh-shipments 0.3.22 → 0.3.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -18,7 +18,7 @@
18
18
  "test": "node test/run-tests.js && node test/run-carriers-tests.js"
19
19
  },
20
20
  "type": "commonjs",
21
- "version": "0.3.22",
21
+ "version": "0.3.24",
22
22
  "bin": {
23
23
  "hh-shipments": "server.js"
24
24
  },
package/public/app.js CHANGED
@@ -233,7 +233,7 @@ async function handlePhotoFile(f) {
233
233
  target.innerHTML = `<div class="review"><img src="${url}" style="max-width:100%;border-radius:10px"><p class="muted" id="ocr-live-photo">📷 Scanning barcode…</p></div>`;
234
234
  const live = $('ocr-live-photo');
235
235
  const barcode = await decodeBarcode(f);
236
- let text, preset, topText = null;
236
+ let text, preset, topText = null, upIdPhoto = null;
237
237
  if (barcode) {
238
238
  text = barcode;
239
239
  if (live) live.textContent = '📷 Barcode read — reading sender…';
@@ -244,6 +244,7 @@ async function handlePhotoFile(f) {
244
244
  topText = await ocrTopStrip(orig, $('ocr-status'));
245
245
  const up = await upPromise;
246
246
  if (!up.ok) throw new Error('upload failed');
247
+ upIdPhoto = await up.json().then((j) => j.id).catch(() => null);
247
248
  preset = topText ? { labelText: topText } : undefined;
248
249
  } else {
249
250
  if (live) live.textContent = '📖 Reading screenshot…';
@@ -255,8 +256,11 @@ async function handlePhotoFile(f) {
255
256
  try { up = await fetchTimeout('/api/intake/image', { method: 'POST', body: fd }, 60000); }
256
257
  catch (e) { throw new Error('upload timed out — check your connection and try again'); }
257
258
  if (!up.ok) throw new Error('upload failed');
259
+ upIdPhoto = await up.json().then((j) => j.id).catch(() => null);
258
260
  }
259
261
  const r = await runQuick(text, $('photo-result'), preset);
262
+ // Photo flow already handled this image — don't leave it nagging in the review queue.
263
+ if (upIdPhoto) api.patch('/api/intake/' + upIdPhoto, { state: 'reviewed' }).catch(() => {});
260
264
  if (looksLikeAppScreenshot(topText || text)) $('photo-result').insertAdjacentHTML('afterbegin', '<p class="warn">⚠️ This looks like a screenshot of the app itself — choose the label photo directly for best results.</p>');
261
265
  if (barcode && r && r.added.length) $('photo-result').insertAdjacentHTML('afterbegin', '<p class="muted">📷 Read from the barcode — checksum verified.</p>');
262
266
  if (r && !r.added.length && !r.duplicates.length && !r.needs_review.some((c) => c.carrier && c.carrier !== 'unknown')) showOcrRead($('photo-result'), text);
@@ -378,24 +382,41 @@ async function decodeBarcode(file) {
378
382
  try { bmp = await createImageBitmap(file); } catch (e) { return null; }
379
383
  const hints = new Map([[ZXing.DecodeHintType.TRY_HARDER, true]]);
380
384
  const reader = new ZXing.BrowserMultiFormatReader(hints);
385
+ // Aggressive region/scale sweep: labels photographed at angles need multiple
386
+ // crops. The UPS 1Z barcode is typically in the lower half.
381
387
  const regions = [
382
- [0, 0, 1, 1], // full image
383
- [0, 0.35, 1, 0.35], // middle strip
384
- [0, 0.55, 1, 0.40], // lower strip (UPS 1Z barcode zone)
388
+ [0, 0, 1, 1],
389
+ [0, 0.30, 1, 0.40],
390
+ [0, 0.50, 1, 0.45],
391
+ [0, 0.60, 1, 0.35],
392
+ [0.05, 0.55, 0.90, 0.40],
385
393
  ];
386
394
  try {
387
395
  for (const [fx, fy, fw, fh] of regions) {
388
- const sw = Math.round(bmp.width * fw), sh = Math.round(bmp.height * fh);
389
- if (sw < 60 || sh < 60) continue;
390
- const scale = Math.max(1, 1200 / sw);
391
- const canvas = document.createElement('canvas');
392
- canvas.width = Math.round(sw * scale);
393
- canvas.height = Math.round(sh * scale);
394
- canvas.getContext('2d').drawImage(bmp, bmp.width * fx, bmp.height * fy, sw, sh, 0, 0, canvas.width, canvas.height);
395
- const url = canvas.toDataURL('image/png');
396
- const text = await decodeOne(reader, url);
397
- const num = strictTracking(text);
398
- if (num) return num;
396
+ for (const scale of [1, 1.5, 2]) {
397
+ const sw = Math.round(bmp.width * fw), sh = Math.round(bmp.height * fh);
398
+ if (sw < 80 || sh < 40) continue;
399
+ const canvas = document.createElement('canvas');
400
+ canvas.width = Math.round(sw * scale);
401
+ canvas.height = Math.round(sh * scale);
402
+ const ctx = canvas.getContext('2d');
403
+ ctx.drawImage(bmp, bmp.width * fx, bmp.height * fy, sw, sh, 0, 0, canvas.width, canvas.height);
404
+ // Boost contrast: barcodes need sharp black/white separation
405
+ try {
406
+ const img = ctx.getImageData(0, 0, canvas.width, canvas.height);
407
+ const d = img.data;
408
+ for (let i = 0; i < d.length; i += 4) {
409
+ const v = 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2];
410
+ const nv = v < 128 ? 0 : 255;
411
+ d[i] = d[i + 1] = d[i + 2] = nv;
412
+ }
413
+ ctx.putImageData(img, 0, 0);
414
+ } catch (e) {}
415
+ const url = canvas.toDataURL('image/png');
416
+ const text = await decodeOne(reader, url);
417
+ const num = strictTracking(text);
418
+ if (num) return num;
419
+ }
399
420
  }
400
421
  } finally { bmp.close(); }
401
422
  return null;
@@ -432,8 +453,11 @@ async function handlePastedImageFile(f) {
432
453
  const topText = await ocrTopStrip(orig, $('ocr-status-paste'));
433
454
  const up = await upPromise;
434
455
  if (!up.ok) throw new Error('upload failed');
456
+ const upId = await up.json().then((j) => j.id).catch(() => null);
435
457
  if (live) live.textContent = '🔍 Finding tracking number…';
436
458
  const r = await runQuick(text, target, topText ? { labelText: topText } : undefined);
459
+ // Paste flow already handled this image — don't leave it nagging in the review queue.
460
+ if (upId) api.patch('/api/intake/' + upId, { state: 'reviewed' }).catch(() => {});
437
461
  if (looksLikeAppScreenshot(topText)) target.insertAdjacentHTML('afterbegin', '<p class="warn">⚠️ This looks like a screenshot of the app itself — paste the label photo directly for best results.</p>');
438
462
  if (r && r.added.length) target.insertAdjacentHTML('afterbegin', '<p class="muted">📷 Read from the barcode — checksum verified.</p>');
439
463
  if (r && !r.added.length && !r.duplicates.length && !r.needs_review.some((c) => c.carrier && c.carrier !== 'unknown')) showOcrRead(target, text);
@@ -449,8 +473,11 @@ async function handlePastedImageFile(f) {
449
473
  try { up = await fetchTimeout('/api/intake/image', { method: 'POST', body: fd }, 60000); }
450
474
  catch (e) { throw new Error('upload timed out — check your connection and try again'); }
451
475
  if (!up.ok) throw new Error('upload failed');
476
+ const upId2 = await up.json().then((j) => j.id).catch(() => null);
452
477
  if (live) live.textContent = '🔍 Finding tracking number…';
453
478
  const r = await runQuick(text, target);
479
+ // Paste flow already handled this image — don't leave it nagging in the review queue.
480
+ if (upId2) api.patch('/api/intake/' + upId2, { state: 'reviewed' }).catch(() => {});
454
481
  if (looksLikeAppScreenshot(text)) target.insertAdjacentHTML('afterbegin', '<p class="warn">⚠️ This looks like a screenshot of the app itself — paste the label photo directly for best results.</p>');
455
482
  if (r && !r.added.length && !r.duplicates.length && !r.needs_review.some((c) => c.carrier && c.carrier !== 'unknown')) showOcrRead(target, text);
456
483
  } catch (err) { $('ocr-status-paste').textContent = ''; target.innerHTML = `<p class="error">${esc(err.message)}</p>`; }
package/public/index.html CHANGED
@@ -4,7 +4,7 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
6
  <meta name="theme-color" content="#0a84ff">
7
- <meta name="app-version" content="0.3.22">
7
+ <meta name="app-version" content="0.3.24">
8
8
  <title>HH Shipments</title>
9
9
  <link rel="manifest" href="/manifest.webmanifest">
10
10
  <link rel="apple-touch-icon" href="/icon-192.png">
package/src/extract.js CHANGED
@@ -152,20 +152,51 @@
152
152
  }
153
153
 
154
154
  // UPS number split by spaces/dashes: "1Z 9999 99 ..." (or "12 ..."/"IZ ..." misreads)
155
+ // Hot-zone: text right after "TRACKING" often has a dropped character
156
+ // (e.g. "17 1) 539 031342 3923" = 17 chars, missing the J in 1XJ).
157
+ // Try expanding punct confusions that may hide two chars.
158
+ function expandPunctHotZone(text, found, seen) {
159
+ var m = /TRACKING\s*#?\s*:?\s*([0-9A-Z)\]}(\[{|!\s-]{15,25})/i.exec(text);
160
+ if (!m) return;
161
+ var raw = m[1].replace(/[\s-]/g, '');
162
+ // Don't swallow trailing English words ("thanks", "you") as number chars
163
+ raw = raw.replace(/(THANK|THANKS|YOU|PLEASE)$/i, '');
164
+ if (/^(1Z|12|IZ|17)/i.test(raw) && (raw.length === 17 || raw.length === 16 || raw.length === 18)) {
165
+ var anchored = raw.replace(/^(12|IZ|17)/i, '1Z');
166
+ // ")" may be "XJ" collapsed (X+J look like ) when smudged)
167
+ var expanded = anchored.replace(/\)/g, 'XJ').replace(/\(/g, 'CC');
168
+ if (/^1Z[0-9A-Z]{16}$/.test(expanded)) {
169
+ var num = repairUpsTail(expanded.slice(2)) || expanded;
170
+ if (num && UPS_STRICT.test(num) && /[A-Z]/.test(num.slice(2, 8)) && !seen[num]) {
171
+ seen[num] = true;
172
+ found.push({ number: num, carrier: 'ups', confidence: 'high', ocr_fixed: true });
173
+ }
174
+ }
175
+ }
176
+ }
177
+
155
178
  function spacedUpsIn(text, found, seen, conf) {
156
- var m, re = /\b(?:1Z|12|IZ)[\s-]?(?:[0-9A-Z][\s-]?){16}\b/gi;
179
+ // Anchor covers common 1Z misreads: 12, IZ, 17 (7→Z confusion)
180
+ var m, re = /\b(?:1Z|12|IZ|17)[\s-]?(?:[0-9A-Z)\]}(\[{|!][\s-]?){16}\b/gi;
157
181
  while ((m = re.exec(text)) !== null) {
158
182
  var ups = normalize(m[0]);
159
183
  var num = null;
160
- if (/^1Z[0-9A-Z]{6}[0-9]{10}$/.test(ups)) num = ups; // fully strict
161
- else if (/^1Z[0-9A-Z]{16}$/.test(ups)) {
162
- // letters sitting in the digit zone are OCR confusions — repair them
163
- num = repairUpsTail(ups.slice(2)) || ups;
184
+ // Normalize the anchor: 12/IZ/17 → 1Z
185
+ var anchored = ups.replace(/^(12|IZ|17)/i, '1Z');
186
+ if (/^1Z[0-9A-Z]{6}[0-9]{10}$/.test(anchored)) num = anchored; // fully strict
187
+ else if (/^1Z[0-9A-Z]{16}$/.test(anchored)) {
188
+ // Repair tail; if repair fails the candidate is invalid — never accept raw
189
+ num = repairUpsTail(anchored.slice(2));
190
+ }
191
+ else num = repairUpsZ(anchored) || repairUpsTail(anchored.slice(2));
192
+ // ")" can be X or J — try X variant if J mapping failed validation
193
+ if (!num && /\)/.test(anchored)) {
194
+ var xVar = anchored.replace(/\)/g, 'X');
195
+ if (/^1Z[0-9A-Z]{16}$/.test(xVar)) num = repairUpsTail(xVar.slice(2));
164
196
  }
165
- else num = repairUpsZ(ups) || repairUpsTail(ups.slice(2));
166
197
  if (num && !seen[num]) {
167
198
  seen[num] = true;
168
- found.push({ number: num, carrier: 'ups', confidence: (num !== ups && conf === 'medium') ? 'high' : conf, ocr_fixed: num !== ups });
199
+ found.push({ number: num, carrier: 'ups', confidence: 'high', ocr_fixed: num !== ups });
169
200
  }
170
201
  }
171
202
  }
@@ -316,6 +347,7 @@
316
347
  // pass already tried the hot zones at high confidence). Repairs are
317
348
  // strict-structure-validated, so they stay high confidence.
318
349
  spacedUpsIn(text, found, seen, 'high');
350
+ expandPunctHotZone(text, found, seen);
319
351
  looseUpsIn(text, found, seen, 'high');
320
352
  // b) long digit-like runs with OCR confusions -> fix -> re-match digit formats
321
353
  var runRe = /[0-9OILSBoilszb]{10,24}/g, run;