hh-shipments 0.3.21 → 0.3.23

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.21",
21
+ "version": "0.3.23",
22
22
  "bin": {
23
23
  "hh-shipments": "server.js"
24
24
  },
package/public/app.js CHANGED
@@ -120,12 +120,19 @@ async function runQuick(text, targetEl, preset) {
120
120
  new Promise((_, rej) => setTimeout(() => rej(new Error('extract timed out — check your connection and try again')), 45000)),
121
121
  ]);
122
122
  let html = '';
123
- r.added.forEach((a) => { html += `<div class="review">✅ Added <b>${esc(a.number)}</b> (${esc(a.carrier.toUpperCase())})${r.simulated ? ' <span class="sim-badge">SIMULATED</span>' : ''}</div>`; });
123
+ r.added.forEach((a) => { html += `<div class="review">✅ Added <b>${esc(a.number)}</b> (${esc(a.carrier.toUpperCase())})${a.ocr_fixed ? ' <span class="muted" title="The scan misread a character; the number was repaired against the strict carrier format">· OCR-corrected</span>' : ''}${r.simulated ? ' <span class="sim-badge">SIMULATED</span>' : ''}</div>`; });
124
124
  r.duplicates.forEach((n) => { html += `<div class="review">⛔ <b>${esc(n)}</b> is already tracked — skipped.</div>`; });
125
125
  (r.updated || []).forEach((u) => { html += `<div class="review">🔄 Updated <b>${esc(u.number)}</b> — seller: <b>${esc(u.seller_name || '—')}</b>${u.device ? ` · ${esc(u.device)}` : ''}</div>`; });
126
126
  r.needs_review.forEach((c, i) => {
127
- html += `<div class="review" id="rev-${i}"><span class="warn">Needs review</span> — <b>${esc(c.number)}</b> (${esc(c.carrier.toUpperCase())}, ${esc(c.confidence)} confidence)${c.error ? ' — ' + esc(c.error) : ''}
128
- <div class="row"><button class="secondary" data-add="${i}">Add anyway</button></div></div>`;
127
+ // An 'unknown'-carrier candidate can never pass server validation, so
128
+ // "Add anyway" on it is a dead button that only ever answers
129
+ // "invalid unknown tracking number format". Show the raw read for a
130
+ // manual fix instead.
131
+ const addable = c.carrier && c.carrier !== 'unknown';
132
+ html += `<div class="review" id="rev-${i}"><span class="warn">Needs review</span> — <b>${esc(c.number)}</b> (${esc(String(c.carrier).toUpperCase())}, ${esc(c.confidence)} confidence)${c.error ? ' — ' + esc(c.error) : ''}`;
133
+ html += addable
134
+ ? `<div class="row"><button class="secondary" data-add="${i}">Add anyway</button></div></div>`
135
+ : `<div class="muted">Couldn't match a carrier — the raw read is below; type the number into the box above.</div></div>`;
129
136
  });
130
137
  if (r.context && (r.context.seller_name || r.context.device)) {
131
138
  html = `<div class="review">📝 Detected: ${esc(r.context.seller_name || '—')} · ${esc(r.context.device || '—')}</div>` + html;
@@ -226,7 +233,7 @@ async function handlePhotoFile(f) {
226
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>`;
227
234
  const live = $('ocr-live-photo');
228
235
  const barcode = await decodeBarcode(f);
229
- let text, preset, topText = null;
236
+ let text, preset, topText = null, upIdPhoto = null;
230
237
  if (barcode) {
231
238
  text = barcode;
232
239
  if (live) live.textContent = '📷 Barcode read — reading sender…';
@@ -237,6 +244,7 @@ async function handlePhotoFile(f) {
237
244
  topText = await ocrTopStrip(orig, $('ocr-status'));
238
245
  const up = await upPromise;
239
246
  if (!up.ok) throw new Error('upload failed');
247
+ upIdPhoto = await up.json().then((j) => j.id).catch(() => null);
240
248
  preset = topText ? { labelText: topText } : undefined;
241
249
  } else {
242
250
  if (live) live.textContent = '📖 Reading screenshot…';
@@ -248,11 +256,14 @@ async function handlePhotoFile(f) {
248
256
  try { up = await fetchTimeout('/api/intake/image', { method: 'POST', body: fd }, 60000); }
249
257
  catch (e) { throw new Error('upload timed out — check your connection and try again'); }
250
258
  if (!up.ok) throw new Error('upload failed');
259
+ upIdPhoto = await up.json().then((j) => j.id).catch(() => null);
251
260
  }
252
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(() => {});
253
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>');
254
265
  if (barcode && r && r.added.length) $('photo-result').insertAdjacentHTML('afterbegin', '<p class="muted">📷 Read from the barcode — checksum verified.</p>');
255
- if (r && !r.added.length && !r.needs_review.length && !r.duplicates.length) showOcrRead($('photo-result'), text);
266
+ if (r && !r.added.length && !r.duplicates.length && !r.needs_review.some((c) => c.carrier && c.carrier !== 'unknown')) showOcrRead($('photo-result'), text);
256
267
  loadPending();
257
268
  } catch (err) { $('ocr-status').textContent = ''; $('photo-result').innerHTML = `<p class="error">${esc(err.message)}</p>`; }
258
269
  }
@@ -425,11 +436,14 @@ async function handlePastedImageFile(f) {
425
436
  const topText = await ocrTopStrip(orig, $('ocr-status-paste'));
426
437
  const up = await upPromise;
427
438
  if (!up.ok) throw new Error('upload failed');
439
+ const upId = await up.json().then((j) => j.id).catch(() => null);
428
440
  if (live) live.textContent = '🔍 Finding tracking number…';
429
441
  const r = await runQuick(text, target, topText ? { labelText: topText } : undefined);
442
+ // Paste flow already handled this image — don't leave it nagging in the review queue.
443
+ if (upId) api.patch('/api/intake/' + upId, { state: 'reviewed' }).catch(() => {});
430
444
  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>');
431
445
  if (r && r.added.length) target.insertAdjacentHTML('afterbegin', '<p class="muted">📷 Read from the barcode — checksum verified.</p>');
432
- if (r && !r.added.length && !r.needs_review.length && !r.duplicates.length) showOcrRead(target, text);
446
+ if (r && !r.added.length && !r.duplicates.length && !r.needs_review.some((c) => c.carrier && c.carrier !== 'unknown')) showOcrRead(target, text);
433
447
  return;
434
448
  }
435
449
  // 2) Fallback: OCR the printed text
@@ -442,10 +456,13 @@ async function handlePastedImageFile(f) {
442
456
  try { up = await fetchTimeout('/api/intake/image', { method: 'POST', body: fd }, 60000); }
443
457
  catch (e) { throw new Error('upload timed out — check your connection and try again'); }
444
458
  if (!up.ok) throw new Error('upload failed');
459
+ const upId2 = await up.json().then((j) => j.id).catch(() => null);
445
460
  if (live) live.textContent = '🔍 Finding tracking number…';
446
461
  const r = await runQuick(text, target);
462
+ // Paste flow already handled this image — don't leave it nagging in the review queue.
463
+ if (upId2) api.patch('/api/intake/' + upId2, { state: 'reviewed' }).catch(() => {});
447
464
  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>');
448
- if (r && !r.added.length && !r.needs_review.length && !r.duplicates.length) showOcrRead(target, text);
465
+ if (r && !r.added.length && !r.duplicates.length && !r.needs_review.some((c) => c.carrier && c.carrier !== 'unknown')) showOcrRead(target, text);
449
466
  } catch (err) { $('ocr-status-paste').textContent = ''; target.innerHTML = `<p class="error">${esc(err.message)}</p>`; }
450
467
  }
451
468
 
@@ -500,7 +517,10 @@ async function reviewPendingImage(id) {
500
517
  $('photo-result').innerHTML = `<img src="/api/intake/image/${id}" style="max-width:100%;border-radius:10px"><p class="muted">Extracting…</p>`;
501
518
  try {
502
519
  await loadTesseract();
503
- const { data } = await Tesseract.recognize(`/api/intake/image/${id}`, 'eng');
520
+ const resp = await fetch(`/api/intake/image/${id}`);
521
+ const blob = await resp.blob();
522
+ const file = await maybeInvertForOcr(new File([blob], 'pending.jpg', { type: blob.type || 'image/jpeg' }));
523
+ const { data } = await Tesseract.recognize(file, 'eng');
504
524
  await api.patch('/api/intake/' + id, { state: 'reviewed' });
505
525
  await runQuick(data.text || '', $('photo-result'));
506
526
  loadPending();
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.21">
7
+ <meta name="app-version" content="0.3.23">
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/server.js CHANGED
@@ -248,7 +248,7 @@ function quickAddText(text, opts = {}) {
248
248
  const r = addShipment({ number: c.number, carrier: c.carrier, seller_name: seller_name || autoCtx.seller_name, device: device || autoCtx.device, notes, source });
249
249
  if (r.error) needs_review.push({ ...c, error: r.error });
250
250
  else if (r.duplicate) duplicates.push(c.number);
251
- else { added.push({ id: r.id, number: r.number, carrier: r.carrier }); existing.add(c.number); }
251
+ else { added.push({ id: r.id, number: r.number, carrier: r.carrier, ocr_fixed: !!c.ocr_fixed, merged: !!c.merged }); existing.add(c.number); }
252
252
  } else {
253
253
  needs_review.push(c);
254
254
  }
package/src/extract.js CHANGED
@@ -165,7 +165,7 @@
165
165
  else num = repairUpsZ(ups) || repairUpsTail(ups.slice(2));
166
166
  if (num && !seen[num]) {
167
167
  seen[num] = true;
168
- found.push({ number: num, carrier: 'ups', confidence: conf, ocr_fixed: num !== ups });
168
+ found.push({ number: num, carrier: 'ups', confidence: (num !== ups && conf === 'medium') ? 'high' : conf, ocr_fixed: num !== ups });
169
169
  }
170
170
  }
171
171
  }
@@ -198,7 +198,8 @@
198
198
  var num = repairUpsTail(tail);
199
199
  if (num && !seen[num]) {
200
200
  seen[num] = true;
201
- found.push({ number: num, carrier: 'ups', confidence: conf, ocr_fixed: true });
201
+ // repairUpsTail only returns strict-validated numbers: trust them.
202
+ found.push({ number: num, carrier: 'ups', confidence: 'high', ocr_fixed: true });
202
203
  }
203
204
  }
204
205
  }
@@ -312,9 +313,10 @@
312
313
  found.push(c);
313
314
  }
314
315
  // a) UPS number split by spaces/dashes (whole-text fallback; the anchor
315
- // pass already tried the hot zones at high confidence)
316
- spacedUpsIn(text, found, seen, 'medium');
317
- looseUpsIn(text, found, seen, 'medium');
316
+ // pass already tried the hot zones at high confidence). Repairs are
317
+ // strict-structure-validated, so they stay high confidence.
318
+ spacedUpsIn(text, found, seen, 'high');
319
+ looseUpsIn(text, found, seen, 'high');
318
320
  // b) long digit-like runs with OCR confusions -> fix -> re-match digit formats
319
321
  var runRe = /[0-9OILSBoilszb]{10,24}/g, run;
320
322
  while ((run = runRe.exec(text)) !== null) {
@@ -337,7 +339,20 @@
337
339
  tokens.slice(0, 3).forEach(function (t) {
338
340
  // UPS "1Z" misread as "12"/"IZ" in a joined (space-free) token
339
341
  var fixed = repairUpsZ(t);
340
- if (fixed) { push(fixed, 'ups', 'medium'); return; }
342
+ if (fixed) { push(fixed, 'ups', 'high'); return; }
343
+ // Token merged with adjacent digits (e.g. the message timestamp right
344
+ // after the number in a chat bubble): try the 18-char prefix. Only
345
+ // accepted when the repaired result strictly validates — never invented.
346
+ if (/^(1Z|12|IZ)/i.test(t) && t.length > 18) {
347
+ var trunc = repairUpsZ(t.slice(0, 18));
348
+ if (!trunc && /^1Z[0-9A-Z]{16}$/.test(t.slice(0, 18))) {
349
+ // correct anchor, merged with trailing digits (chat timestamp):
350
+ // accept the 18-char prefix only if it strictly validates
351
+ var pre = t.slice(0, 18);
352
+ if (UPS_STRICT.test(pre) && /[A-Z]/.test(pre.slice(2, 8))) trunc = pre;
353
+ }
354
+ if (trunc) { push(trunc, 'ups', 'high', { merged: true }); return; }
355
+ }
341
356
  // OCR-fixed variant may match a carrier format (e.g. I->1 at the start)
342
357
  var digitFixed = ocrDigitFix(t);
343
358
  var gc = guessCarrier(digitFixed);