hh-shipments 0.3.16 → 0.3.18
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 +8 -4
- package/public/index.html +1 -0
- package/server.js +24 -7
- package/src/extract.js +23 -1
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -56,7 +56,10 @@ function showApp() {
|
|
|
56
56
|
$('tabbar').hidden = false;
|
|
57
57
|
showView('view-dash');
|
|
58
58
|
loadDash();
|
|
59
|
-
api.get('/api/version').then((v) => {
|
|
59
|
+
api.get('/api/version').then((v) => {
|
|
60
|
+
const cv = (document.querySelector('meta[name="app-version"]') || {}).content || '?';
|
|
61
|
+
if (v && v.version) $('app-version').textContent = 'app v' + cv + ' · server v' + v.version;
|
|
62
|
+
}).catch(() => {});
|
|
60
63
|
}
|
|
61
64
|
|
|
62
65
|
/* ---------- auth ---------- */
|
|
@@ -113,12 +116,13 @@ async function runQuick(text, targetEl, preset) {
|
|
|
113
116
|
targetEl.innerHTML = '<p class="muted">Extracting…</p>';
|
|
114
117
|
try {
|
|
115
118
|
const r = await Promise.race([
|
|
116
|
-
api.post('/api/intake/quick', { text, seller_name: preset && preset.seller, device: preset && preset.device, label_text: preset && preset.labelText }),
|
|
119
|
+
api.post('/api/intake/quick', { text, seller_name: preset && preset.seller, device: preset && preset.device, label_text: preset && preset.labelText, typed: preset && preset.typed }),
|
|
117
120
|
new Promise((_, rej) => setTimeout(() => rej(new Error('extract timed out — check your connection and try again')), 45000)),
|
|
118
121
|
]);
|
|
119
122
|
let html = '';
|
|
120
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>`; });
|
|
121
124
|
r.duplicates.forEach((n) => { html += `<div class="review">⛔ <b>${esc(n)}</b> is already tracked — skipped.</div>`; });
|
|
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>`; });
|
|
122
126
|
r.needs_review.forEach((c, i) => {
|
|
123
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) : ''}
|
|
124
128
|
<div class="row"><button class="secondary" data-add="${i}">Add anyway</button></div></div>`;
|
|
@@ -126,7 +130,7 @@ async function runQuick(text, targetEl, preset) {
|
|
|
126
130
|
if (r.context && (r.context.seller_name || r.context.device)) {
|
|
127
131
|
html = `<div class="review">📝 Detected: ${esc(r.context.seller_name || '—')} · ${esc(r.context.device || '—')}</div>` + html;
|
|
128
132
|
}
|
|
129
|
-
if (!r.added.length && !r.needs_review.length && !r.duplicates.length) html = '<div class="review">No tracking numbers found in that text.</div>';
|
|
133
|
+
if (!r.added.length && !r.needs_review.length && !r.duplicates.length && !(r.updated || []).length) html = '<div class="review">No tracking numbers found in that text.</div>';
|
|
130
134
|
targetEl.innerHTML = html;
|
|
131
135
|
targetEl.querySelectorAll('[data-add]').forEach((btn) => {
|
|
132
136
|
btn.onclick = async () => {
|
|
@@ -573,7 +577,7 @@ $('search-arch').addEventListener('input', () => { clearTimeout(searchAT); searc
|
|
|
573
577
|
$('btn-quick').onclick = () => {
|
|
574
578
|
const t = $('paste-text').value.trim();
|
|
575
579
|
if (!t) { $('quick-result').innerHTML = '<p class="muted">Paste the seller\'s text above first — or tap <b>📋 Paste screenshot</b>, screenshots extract on their own.</p>'; return; }
|
|
576
|
-
runQuick(t, $('quick-result'));
|
|
580
|
+
runQuick(t, $('quick-result'), { typed: true });
|
|
577
581
|
};
|
|
578
582
|
$('btn-manual').onclick = async () => {
|
|
579
583
|
$('manual-err').textContent = '';
|
package/public/index.html
CHANGED
|
@@ -4,6 +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.18">
|
|
7
8
|
<title>HH Shipments</title>
|
|
8
9
|
<link rel="manifest" href="/manifest.webmanifest">
|
|
9
10
|
<link rel="apple-touch-icon" href="/icon-192.png">
|
package/server.js
CHANGED
|
@@ -218,15 +218,32 @@ app.post('/api/intake/text', requireAuth, (req, res) => {
|
|
|
218
218
|
// high-confidence ones, return the rest for review. Used by paste intake
|
|
219
219
|
// and the WhatsApp webhook.
|
|
220
220
|
function quickAddText(text, opts = {}) {
|
|
221
|
-
const { seller_name, device, notes, source = 'paste', label_text } = opts;
|
|
221
|
+
const { seller_name, device, notes, source = 'paste', label_text, typed } = opts;
|
|
222
222
|
const candidates = extract.extractTrackingNumbers(text);
|
|
223
223
|
// When the number came from a barcode scan, text is just the number —
|
|
224
224
|
// use the label's top-strip OCR text (if provided) for seller context.
|
|
225
|
-
|
|
225
|
+
// Typed text (paste box) gets the lenient name rule; OCR never does.
|
|
226
|
+
const autoCtx = extract.extractContext(label_text || text, { lenient: !!typed && !label_text });
|
|
226
227
|
const existing = new Set(db.prepare('SELECT tracking_number FROM shipments').all().map((r) => r.tracking_number));
|
|
227
|
-
const added = [], needs_review = [], duplicates = [];
|
|
228
|
+
const added = [], needs_review = [], duplicates = [], updated = [];
|
|
228
229
|
for (const c of candidates) {
|
|
229
|
-
if (existing.has(c.number)) {
|
|
230
|
+
if (existing.has(c.number)) {
|
|
231
|
+
// Already tracked — but a re-paste carrying a name (Hunter's
|
|
232
|
+
// "number + name" workflow) fills a blank seller/device on it.
|
|
233
|
+
const newSeller = seller_name || autoCtx.seller_name;
|
|
234
|
+
const newDevice = device || autoCtx.device;
|
|
235
|
+
if (newSeller || newDevice) {
|
|
236
|
+
const cur = db.prepare('SELECT id, seller_name, device FROM shipments WHERE tracking_number = ?').get(c.number);
|
|
237
|
+
if (cur && ((!cur.seller_name && newSeller) || (!cur.device && newDevice))) {
|
|
238
|
+
db.prepare('UPDATE shipments SET seller_name = COALESCE(seller_name, ?), device = COALESCE(device, ?) WHERE id = ?')
|
|
239
|
+
.run(newSeller || null, newDevice || null, cur.id);
|
|
240
|
+
updated.push({ id: cur.id, number: c.number, seller_name: newSeller || cur.seller_name, device: newDevice || cur.device });
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
duplicates.push(c.number);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
230
247
|
if (c.confidence === 'high') {
|
|
231
248
|
const r = addShipment({ number: c.number, carrier: c.carrier, seller_name: seller_name || autoCtx.seller_name, device: device || autoCtx.device, notes, source });
|
|
232
249
|
if (r.error) needs_review.push({ ...c, error: r.error });
|
|
@@ -236,13 +253,13 @@ function quickAddText(text, opts = {}) {
|
|
|
236
253
|
needs_review.push(c);
|
|
237
254
|
}
|
|
238
255
|
}
|
|
239
|
-
return { added, needs_review, duplicates, context: autoCtx, simulated: provider.simulated };
|
|
256
|
+
return { added, needs_review, duplicates, updated, context: autoCtx, simulated: provider.simulated };
|
|
240
257
|
}
|
|
241
258
|
|
|
242
259
|
app.post('/api/intake/quick', requireAuth, (req, res) => {
|
|
243
|
-
const { text, seller_name, device, notes, label_text } = req.body || {};
|
|
260
|
+
const { text, seller_name, device, notes, label_text, typed } = req.body || {};
|
|
244
261
|
if (!text || !text.trim()) return res.status(400).json({ error: 'empty text' });
|
|
245
|
-
res.json(quickAddText(text, { seller_name, device, notes, source: 'paste', label_text }));
|
|
262
|
+
res.json(quickAddText(text, { seller_name, device, notes, source: 'paste', label_text, typed: !!typed }));
|
|
246
263
|
});
|
|
247
264
|
|
|
248
265
|
/* ---------------- intake: images (in-app upload + iOS Shortcut) ---------------- */
|
package/src/extract.js
CHANGED
|
@@ -38,8 +38,11 @@
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
// Light heuristics for seller name + device details from message text.
|
|
41
|
-
|
|
41
|
+
// opts.lenient: the text was typed by Hunter himself (paste-text box) —
|
|
42
|
+
// trust a name-looking line far more than OCR output.
|
|
43
|
+
function extractContext(text, opts) {
|
|
42
44
|
var out = { seller_name: null, device: null };
|
|
45
|
+
opts = opts || {};
|
|
43
46
|
if (!text) return out;
|
|
44
47
|
// A plausible person name: no digits (addresses have them), not ALL CAPS
|
|
45
48
|
// (shipping-label blocks are), proper name shape. Shipping labels like
|
|
@@ -86,6 +89,25 @@
|
|
|
86
89
|
if (looksLikeLabelName(cleaned)) { out.seller_name = cleaned; break; }
|
|
87
90
|
}
|
|
88
91
|
}
|
|
92
|
+
// Hunter's typed format: tracking number on one line, seller name on
|
|
93
|
+
// another ("1Z...\nAbdullah S"). Typed text is trusted — take the first
|
|
94
|
+
// plausible name line that isn't the number itself.
|
|
95
|
+
if (!out.seller_name && opts.lenient) {
|
|
96
|
+
var lblock = /\b(UPS|FEDEX|USPS|DHL|GROUND|EXPRESS|PRIORITY|BILLING|TRACKING|POSTAGE|LABEL|SHIPMENT|SHIP\b|SCREENSHOT|READING|FINDING|UPLOADING|EXTRACT|PASTE|DETAILS|SAVE|TAP|CLIPBOARD|NUMBER|BACK|HOME|ARCHIVE|SETTINGS)\b/i;
|
|
97
|
+
var lns = String(text).split(/\r?\n/);
|
|
98
|
+
for (var k = 0; k < lns.length; k++) {
|
|
99
|
+
var ln = lns[k].trim();
|
|
100
|
+
if (!ln || ln.length > 40) continue;
|
|
101
|
+
if (/\d{6,}/.test(ln)) continue; // tracking-number line
|
|
102
|
+
if (/\d/.test(ln)) continue; // addresses/order lines have digits
|
|
103
|
+
var w = ln.split(/\s+/);
|
|
104
|
+
if (w.length > 4) continue;
|
|
105
|
+
if (lblock.test(ln)) continue;
|
|
106
|
+
if (!/^[A-Za-z][A-Za-z .,'-]*$/.test(ln)) continue;
|
|
107
|
+
out.seller_name = ln;
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
89
111
|
// Device: iPhone 15 Pro Max 256GB, iPad Pro 11, etc.
|
|
90
112
|
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);
|
|
91
113
|
var sm = text.match(/\b(64|128|256|512)\s?GB\b/i) || text.match(/\b1\s?TB\b/i);
|