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 +1 -1
- package/public/app.js +96 -4
- package/public/index.html +1 -0
- package/server.js +2 -0
- package/src/extract.js +23 -1
package/package.json
CHANGED
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"
|
|
180
|
-
const
|
|
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"
|
|
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
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
|
-
|
|
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);
|