hh-shipments 0.3.10 → 0.3.12

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.10",
21
+ "version": "0.3.12",
22
22
  "bin": {
23
23
  "hh-shipments": "server.js"
24
24
  },
package/public/app.js CHANGED
@@ -56,6 +56,7 @@ function showApp() {
56
56
  $('tabbar').hidden = false;
57
57
  showView('view-dash');
58
58
  loadDash();
59
+ api.get('/api/version').then((v) => { if (v && v.version) $('app-version').textContent = v.version; }).catch(() => {});
59
60
  }
60
61
 
61
62
  /* ---------- auth ---------- */
@@ -176,8 +177,17 @@ async function handlePhotoFile(f) {
176
177
  try {
177
178
  f = await shrinkImage(f);
178
179
  const url = URL.createObjectURL(f);
179
- target.innerHTML = `<div class="review"><img src="${url}" style="max-width:100%;border-radius:10px"><p class="muted">📖 Reading screenshot…</p></div>`;
180
- const text = await ocrFile(f, $('ocr-status'));
180
+ 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>`;
181
+ const live = $('ocr-live-photo');
182
+ const barcode = await decodeBarcode(f);
183
+ let text;
184
+ if (barcode) {
185
+ text = barcode;
186
+ if (live) live.textContent = '📷 Barcode read — verifying…';
187
+ } else {
188
+ if (live) live.textContent = '📖 Reading screenshot…';
189
+ text = await ocrFile(f, $('ocr-status'));
190
+ }
181
191
  // Upload original for reference, then run the quick flow on OCR text
182
192
  const fd = new FormData();
183
193
  fd.append('image', f);
@@ -235,15 +245,96 @@ async function fetchTimeout(url, opts = {}, ms = 60000) {
235
245
  try { return await fetch(url, { ...opts, signal: c.signal }); }
236
246
  finally { clearTimeout(t); }
237
247
  }
248
+
249
+ // ---- Barcode-first decoding (ZXing) ----
250
+ // A decoded barcode is checksum-verified (Code128 mod-103), so it is
251
+ // essentially never wrong — far more reliable than OCR text for labels.
252
+ let zxingPromise = null;
253
+ function loadZxing() {
254
+ if (window.ZXing) return Promise.resolve();
255
+ if (!zxingPromise) {
256
+ zxingPromise = new Promise((res, rej) => {
257
+ const s = document.createElement('script');
258
+ s.src = 'https://cdn.jsdelivr.net/npm/@zxing/library@0.21.3/umd/index.min.js';
259
+ s.onload = res;
260
+ s.onerror = () => rej(new Error('barcode reader failed to load'));
261
+ document.head.appendChild(s);
262
+ });
263
+ }
264
+ return zxingPromise;
265
+ }
266
+
267
+ // Strict client-side check: only barcode reads that look exactly like a
268
+ // tracking number are trusted (the server re-validates anyway).
269
+ function strictTracking(s) {
270
+ s = String(s || '').toUpperCase().replace(/[\s-]/g, '');
271
+ if (/^1Z[0-9A-Z]{16}$/.test(s)) return s; // UPS
272
+ if (/^9\d{19,21}$/.test(s)) return s; // USPS
273
+ if (/^(\d{12}|\d{15}|\d{20})$/.test(s)) return s; // FedEx
274
+ if (/^\d{10,11}$/.test(s)) return s; // DHL
275
+ return null;
276
+ }
277
+
278
+ async function decodeOne(reader, url, ms = 9000) {
279
+ try {
280
+ const img = new Image();
281
+ img.src = url;
282
+ await img.decode();
283
+ const p = reader.decodeFromImageElement(img);
284
+ const r = await Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error('decode timeout')), ms))]);
285
+ return r.getText();
286
+ } catch (e) { return null; }
287
+ }
288
+
289
+ // Whole-label decode usually fails (barcodes too small in frame), so try
290
+ // horizontal region strips, upscaled — the 1Z barcode lives low on UPS labels.
291
+ async function decodeBarcode(file) {
292
+ try { await loadZxing(); } catch (e) { return null; }
293
+ let bmp;
294
+ try { bmp = await createImageBitmap(file); } catch (e) { return null; }
295
+ const hints = new Map([[ZXing.DecodeHintType.TRY_HARDER, true]]);
296
+ const reader = new ZXing.BrowserMultiFormatReader(hints);
297
+ const regions = [
298
+ [0, 0, 1, 1], // full image
299
+ [0, 0.35, 1, 0.35], // middle strip
300
+ [0, 0.55, 1, 0.40], // lower strip (UPS 1Z barcode zone)
301
+ ];
302
+ try {
303
+ for (const [fx, fy, fw, fh] of regions) {
304
+ const sw = Math.round(bmp.width * fw), sh = Math.round(bmp.height * fh);
305
+ if (sw < 60 || sh < 60) continue;
306
+ const scale = Math.max(1, 1200 / sw);
307
+ const canvas = document.createElement('canvas');
308
+ canvas.width = Math.round(sw * scale);
309
+ canvas.height = Math.round(sh * scale);
310
+ canvas.getContext('2d').drawImage(bmp, bmp.width * fx, bmp.height * fy, sw, sh, 0, 0, canvas.width, canvas.height);
311
+ const url = canvas.toDataURL('image/png');
312
+ const text = await decodeOne(reader, url);
313
+ const num = strictTracking(text);
314
+ if (num) return num;
315
+ }
316
+ } finally { bmp.close(); }
317
+ return null;
318
+ }
238
319
  async function handlePastedImageFile(f) {
239
320
  const target = $('quick-result');
240
321
  try {
241
322
  // Instant feedback: show a thumbnail + progress the moment the image lands
242
323
  f = await shrinkImage(f);
243
324
  const url = URL.createObjectURL(f);
244
- target.innerHTML = `<div class="review"><img src="${url}" style="max-width:100%;border-radius:10px"><p class="muted" id="ocr-live">📖 Reading screenshot…</p></div>`;
245
- const text = await ocrFile(f, $('ocr-status-paste'));
325
+ target.innerHTML = `<div class="review"><img src="${url}" style="max-width:100%;border-radius:10px"><p class="muted" id="ocr-live">📷 Scanning barcode…</p></div>`;
246
326
  const live = $('ocr-live');
327
+ // 1) Barcode first: checksum-verified, essentially never wrong
328
+ const barcode = await decodeBarcode(f);
329
+ let text;
330
+ if (barcode) {
331
+ text = barcode;
332
+ if (live) live.textContent = '📷 Barcode read — verifying…';
333
+ } else {
334
+ // 2) Fallback: OCR the printed text
335
+ if (live) live.textContent = '📖 Reading screenshot…';
336
+ text = await ocrFile(f, $('ocr-status-paste'));
337
+ }
247
338
  if (live) live.textContent = '⬆️ Uploading…';
248
339
  const fd = new FormData();
249
340
  fd.append('image', f);
@@ -253,6 +344,7 @@ async function handlePastedImageFile(f) {
253
344
  if (!up.ok) throw new Error('upload failed');
254
345
  if (live) live.textContent = '🔍 Finding tracking number…';
255
346
  const r = await runQuick(text, target);
347
+ if (barcode && r && r.added.length) target.insertAdjacentHTML('afterbegin', '<p class="muted">📷 Read from the barcode — checksum verified.</p>');
256
348
  if (r && !r.added.length && !r.needs_review.length && !r.duplicates.length) showOcrRead(target, text);
257
349
  } catch (err) { $('ocr-status-paste').textContent = ''; target.innerHTML = `<p class="error">${esc(err.message)}</p>`; }
258
350
  }
package/public/index.html CHANGED
@@ -152,6 +152,7 @@
152
152
  <div class="card">
153
153
  <button id="btn-logout" class="danger">Sign out</button>
154
154
  </div>
155
+ <p class="muted" style="text-align:center">HH Shipments v<span id="app-version">…</span></p>
155
156
  </section>
156
157
 
157
158
  <nav id="tabbar" hidden>
package/server.js CHANGED
@@ -60,6 +60,8 @@ function checkIntakeToken(req, res, next) {
60
60
  next();
61
61
  }
62
62
 
63
+ app.get('/api/version', (req, res) => res.json({ version: require('./package.json').version }));
64
+
63
65
  app.get('/api/setup-needed', (req, res) => {
64
66
  const n = db.prepare('SELECT COUNT(*) c FROM users').get().c;
65
67
  res.json({ needed: n === 0 });
package/src/extract.js CHANGED
@@ -54,12 +54,34 @@
54
54
  }
55
55
  // WhatsApp export style: "[10/12/26, 9:41 AM] John Smith: message"
56
56
  var m = text.match(/^\[?[^\]\n]*\]?\s*([^:\[\]\n]{2,40}):\s/m);
57
- if (m && !/^(you|me|http)/i.test(m[1].trim()) && looksLikeName(m[1])) out.seller_name = m[1].trim();
57
+ // ...plus a run of 3+ lowercase letters: real names have them ("Marcus
58
+ // Webb"), OCR fragments like "Eg NTT" don't.
59
+ if (m && !/^(you|me|http)/i.test(m[1].trim()) && looksLikeName(m[1]) && /[a-z]{3,}/.test(m[1])) out.seller_name = m[1].trim();
58
60
  // "from John", "seller: John"
59
61
  if (!out.seller_name) {
60
62
  var m2 = text.match(/(?:from|seller)\s*:\s*([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)/);
61
63
  if (m2) out.seller_name = m2[1].trim();
62
64
  }
65
+ // Shipping labels: the sender name is the first or second line
66
+ // ("MOHAMMED SALEM"). Only take it when it's clearly a name — never
67
+ // digits, never address fragments, never OCR garbage like "EE SS".
68
+ if (!out.seller_name) {
69
+ var labelWords = function (s) { return String(s).trim().split(/\s+/); };
70
+ var looksLikeLabelName = function (s) {
71
+ var words = labelWords(s);
72
+ if (words.length < 2 || words.length > 3) return false;
73
+ // every word 4+ letters: OCR garbage ("Eg NTT", "EE SS") never
74
+ // qualifies; real label names ("MOHAMMED SALEM") do.
75
+ for (var i = 0; i < words.length; i++) if (!/^[A-Za-z]{4,}$/.test(words[i])) return false;
76
+ if (/\b(UPS|FEDEX|USPS|DHL|GROUND|EXPRESS|PRIORITY|BILLING|SHIP\b|FROM|TRACKING|POSTAGE|LABEL)\b/i.test(s)) return false;
77
+ return true;
78
+ };
79
+ var lines = String(text).split(/\r?\n/);
80
+ for (var li = 0; li < Math.min(2, lines.length); li++) {
81
+ var cleaned = lines[li].replace(/[^A-Za-z ]/g, ' ').replace(/\s+/g, ' ').trim();
82
+ if (looksLikeLabelName(cleaned)) { out.seller_name = cleaned; break; }
83
+ }
84
+ }
63
85
  // Device: iPhone 15 Pro Max 256GB, iPad Pro 11, etc.
64
86
  var dm = text.match(/\b(iPhone\s+\d{1,2}\s*(?:Pro\s*Max|Pro|Plus|Mini|e)?|iPad\s*(?:Pro|Air|Mini)?\s*\d*(?:\.\d)?)\b/i);
65
87
  var sm = text.match(/\b(64|128|256|512)\s?GB\b/i) || text.match(/\b1\s?TB\b/i);