hh-shipments 0.3.20 → 0.3.22
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 +1 -1
- package/public/app.js +58 -7
- package/public/index.html +1 -1
- package/server.js +1 -1
- package/src/extract.js +21 -6
package/package.json
CHANGED
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
|
-
|
|
128
|
-
|
|
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;
|
|
@@ -168,9 +175,50 @@ function loadTesseract() {
|
|
|
168
175
|
return tesseractLoading;
|
|
169
176
|
}
|
|
170
177
|
|
|
178
|
+
// Tesseract reads dark-text-on-light far better than the reverse. Chat
|
|
179
|
+
// screenshots (dark mode) come out as white-on-black, so detect dark images
|
|
180
|
+
// and invert them before OCR. Light images (labels) pass through untouched.
|
|
181
|
+
async function maybeInvertForOcr(file) {
|
|
182
|
+
try {
|
|
183
|
+
const bmp = await createImageBitmap(file);
|
|
184
|
+
const w = bmp.width, h = bmp.height;
|
|
185
|
+
const canvas = document.createElement('canvas');
|
|
186
|
+
// sample at reduced size for speed
|
|
187
|
+
const sw = Math.min(200, w), sh = Math.min(200, h);
|
|
188
|
+
canvas.width = sw; canvas.height = sh;
|
|
189
|
+
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
|
190
|
+
ctx.drawImage(bmp, 0, 0, sw, sh);
|
|
191
|
+
const px = ctx.getImageData(0, 0, sw, sh).data;
|
|
192
|
+
let sum = 0, n = 0;
|
|
193
|
+
for (let i = 0; i < px.length; i += 16) {
|
|
194
|
+
sum += 0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2];
|
|
195
|
+
n++;
|
|
196
|
+
}
|
|
197
|
+
const avg = sum / n;
|
|
198
|
+
bmp.close();
|
|
199
|
+
if (avg > 110) return file; // already light — leave it alone
|
|
200
|
+
// dark image: invert to dark-on-light
|
|
201
|
+
const c2 = document.createElement('canvas');
|
|
202
|
+
c2.width = w; c2.height = h;
|
|
203
|
+
const x2 = c2.getContext('2d');
|
|
204
|
+
const full = await createImageBitmap(file);
|
|
205
|
+
x2.drawImage(full, 0, 0);
|
|
206
|
+
full.close();
|
|
207
|
+
const img = x2.getImageData(0, 0, w, h);
|
|
208
|
+
const d = img.data;
|
|
209
|
+
for (let i = 0; i < d.length; i += 4) {
|
|
210
|
+
d[i] = 255 - d[i]; d[i + 1] = 255 - d[i + 1]; d[i + 2] = 255 - d[i + 2];
|
|
211
|
+
}
|
|
212
|
+
x2.putImageData(img, 0, 0);
|
|
213
|
+
const blob = await new Promise((res) => c2.toBlob(res, 'image/jpeg', 0.92));
|
|
214
|
+
return blob ? new File([blob], 'inv.jpg', { type: 'image/jpeg' }) : file;
|
|
215
|
+
} catch (e) { return file; }
|
|
216
|
+
}
|
|
217
|
+
|
|
171
218
|
async function ocrFile(file, statusEl) {
|
|
172
219
|
await loadTesseract();
|
|
173
220
|
statusEl.textContent = 'Reading screenshot…';
|
|
221
|
+
file = await maybeInvertForOcr(file);
|
|
174
222
|
const { data } = await Tesseract.recognize(file, 'eng', { logger: (m) => { if (m.status === 'recognizing text') statusEl.textContent = `Reading… ${Math.round(m.progress * 100)}%`; } });
|
|
175
223
|
statusEl.textContent = '';
|
|
176
224
|
return data.text || '';
|
|
@@ -211,7 +259,7 @@ async function handlePhotoFile(f) {
|
|
|
211
259
|
const r = await runQuick(text, $('photo-result'), preset);
|
|
212
260
|
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>');
|
|
213
261
|
if (barcode && r && r.added.length) $('photo-result').insertAdjacentHTML('afterbegin', '<p class="muted">📷 Read from the barcode — checksum verified.</p>');
|
|
214
|
-
if (r && !r.added.length && !r.
|
|
262
|
+
if (r && !r.added.length && !r.duplicates.length && !r.needs_review.some((c) => c.carrier && c.carrier !== 'unknown')) showOcrRead($('photo-result'), text);
|
|
215
263
|
loadPending();
|
|
216
264
|
} catch (err) { $('ocr-status').textContent = ''; $('photo-result').innerHTML = `<p class="error">${esc(err.message)}</p>`; }
|
|
217
265
|
}
|
|
@@ -388,7 +436,7 @@ async function handlePastedImageFile(f) {
|
|
|
388
436
|
const r = await runQuick(text, target, topText ? { labelText: topText } : undefined);
|
|
389
437
|
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>');
|
|
390
438
|
if (r && r.added.length) target.insertAdjacentHTML('afterbegin', '<p class="muted">📷 Read from the barcode — checksum verified.</p>');
|
|
391
|
-
if (r && !r.added.length && !r.
|
|
439
|
+
if (r && !r.added.length && !r.duplicates.length && !r.needs_review.some((c) => c.carrier && c.carrier !== 'unknown')) showOcrRead(target, text);
|
|
392
440
|
return;
|
|
393
441
|
}
|
|
394
442
|
// 2) Fallback: OCR the printed text
|
|
@@ -404,7 +452,7 @@ async function handlePastedImageFile(f) {
|
|
|
404
452
|
if (live) live.textContent = '🔍 Finding tracking number…';
|
|
405
453
|
const r = await runQuick(text, target);
|
|
406
454
|
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>');
|
|
407
|
-
if (r && !r.added.length && !r.
|
|
455
|
+
if (r && !r.added.length && !r.duplicates.length && !r.needs_review.some((c) => c.carrier && c.carrier !== 'unknown')) showOcrRead(target, text);
|
|
408
456
|
} catch (err) { $('ocr-status-paste').textContent = ''; target.innerHTML = `<p class="error">${esc(err.message)}</p>`; }
|
|
409
457
|
}
|
|
410
458
|
|
|
@@ -459,7 +507,10 @@ async function reviewPendingImage(id) {
|
|
|
459
507
|
$('photo-result').innerHTML = `<img src="/api/intake/image/${id}" style="max-width:100%;border-radius:10px"><p class="muted">Extracting…</p>`;
|
|
460
508
|
try {
|
|
461
509
|
await loadTesseract();
|
|
462
|
-
const
|
|
510
|
+
const resp = await fetch(`/api/intake/image/${id}`);
|
|
511
|
+
const blob = await resp.blob();
|
|
512
|
+
const file = await maybeInvertForOcr(new File([blob], 'pending.jpg', { type: blob.type || 'image/jpeg' }));
|
|
513
|
+
const { data } = await Tesseract.recognize(file, 'eng');
|
|
463
514
|
await api.patch('/api/intake/' + id, { state: 'reviewed' });
|
|
464
515
|
await runQuick(data.text || '', $('photo-result'));
|
|
465
516
|
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.
|
|
7
|
+
<meta name="app-version" content="0.3.22">
|
|
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
|
-
|
|
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
|
-
|
|
317
|
-
|
|
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', '
|
|
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);
|