From 878d489661f8bb9e56e14ac1a4d0e889fffabcbd Mon Sep 17 00:00:00 2001 From: MarekWo Date: Tue, 24 Mar 2026 20:54:41 +0100 Subject: [PATCH] feat(contacts): add contact UI with URI paste, QR scan, and manual entry Stage 2 of manual contact add feature: - POST /api/contacts/manual-add endpoint (URI or raw params) - New /contacts/add page with 3 input tabs (URI, QR code, Manual) - QR scanning via html5-qrcode (camera + image upload fallback) - Client-side URI parsing with preview before submission - Nav card in Contact Management above Pending Contacts Co-Authored-By: Claude Opus 4.6 --- app/routes/api.py | 42 +++++ app/routes/views.py | 11 ++ app/static/js/contacts.js | 255 +++++++++++++++++++++++++++++ app/templates/contacts-add.html | 128 +++++++++++++++ app/templates/contacts-manage.html | 9 + 5 files changed, 445 insertions(+) create mode 100644 app/templates/contacts-add.html diff --git a/app/routes/api.py b/app/routes/api.py index 27d6f08..6c445dd 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -2738,6 +2738,48 @@ def delete_cached_contact_api(): return jsonify({'success': False, 'error': str(e)}), 500 +@api_bp.route('/contacts/manual-add', methods=['POST']) +def manual_add_contact(): + """Add a contact manually via URI or raw parameters (name, public_key, type).""" + try: + dm = _get_dm() + if not dm: + return jsonify({'success': False, 'error': 'Device manager unavailable'}), 500 + + data = request.get_json() or {} + + # Mode 1: URI (meshcore://contact/add?... or hex blob) + uri = data.get('uri', '').strip() + if uri: + result = dm.import_contact_uri(uri) + if result['success']: + invalidate_contacts_cache() + status = 200 if result['success'] else 400 + return jsonify(result), status + + # Mode 2: Raw parameters + name = data.get('name', '').strip() + public_key = data.get('public_key', '').strip() + contact_type = data.get('type', 1) + + if not name or not public_key: + return jsonify({'success': False, 'error': 'Name and public_key are required'}), 400 + + try: + contact_type = int(contact_type) + except (ValueError, TypeError): + contact_type = 1 + + result = dm.add_contact_manual(name, public_key, contact_type) + if result['success']: + invalidate_contacts_cache() + status = 200 if result['success'] else 400 + return jsonify(result), status + except Exception as e: + logger.error(f"Error adding contact manually: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + @api_bp.route('/contacts//push-to-device', methods=['POST']) def push_contact_to_device(public_key): """Push a cache-only contact to the device.""" diff --git a/app/routes/views.py b/app/routes/views.py index e80716c..0e0daf2 100644 --- a/app/routes/views.py +++ b/app/routes/views.py @@ -50,6 +50,17 @@ def contact_management(): ) +@views_bp.route('/contacts/add') +def contact_add(): + """ + Add Contact page - URI paste, QR scan, manual fields. + """ + return render_template( + 'contacts-add.html', + device_name=runtime_config.get_device_name() + ) + + @views_bp.route('/contacts/pending') def contact_pending_list(): """ diff --git a/app/static/js/contacts.js b/app/static/js/contacts.js index 373d0b5..9bbd962 100644 --- a/app/static/js/contacts.js +++ b/app/static/js/contacts.js @@ -140,6 +140,8 @@ function detectCurrentPage() { currentPage = 'pending'; } else if (document.getElementById('existingPageContent')) { currentPage = 'existing'; + } else if (document.getElementById('addPageContent')) { + currentPage = 'add'; } console.log('Current page:', currentPage); } @@ -155,6 +157,9 @@ function initializePage() { case 'existing': initExistingPage(); break; + case 'add': + initAddPage(); + break; default: console.warn('Unknown page type'); } @@ -2555,3 +2560,253 @@ async function moveContactToCache(contact) { showToast('Network error: ' + error.message, 'danger'); } } + +// ============================================================================= +// Add Contact Page +// ============================================================================= + +const TYPE_LABELS = {1: 'COM', 2: 'REP', 3: 'ROOM', 4: 'SENS'}; + +let html5QrCode = null; +let qrScannedUri = null; + +function initAddPage() { + console.log('Initializing Add Contact page...'); + + // URI tab listeners + const uriInput = document.getElementById('uriInput'); + uriInput.addEventListener('input', handleUriInput); + document.getElementById('addFromUriBtn').addEventListener('click', () => submitContact('uri')); + + // QR tab listeners + document.getElementById('startCameraBtn').addEventListener('click', startQrCamera); + document.getElementById('stopCameraBtn').addEventListener('click', stopQrCamera); + document.getElementById('qrFileInput').addEventListener('change', handleQrFile); + document.getElementById('addFromQrBtn').addEventListener('click', () => submitContact('qr')); + + // Manual tab listeners + const manualKey = document.getElementById('manualKey'); + const manualName = document.getElementById('manualName'); + manualKey.addEventListener('input', handleManualKeyInput); + manualName.addEventListener('input', validateManualForm); + document.getElementById('addManualBtn').addEventListener('click', () => submitContact('manual')); + + // Stop camera when switching away from QR tab + document.getElementById('tab-qr').addEventListener('hidden.bs.tab', stopQrCamera); +} + +/** + * Parse a meshcore:// mobile app URI client-side for preview. + * Returns {name, public_key, type} or null. + */ +function parseMeshcoreUri(uri) { + if (!uri || !uri.startsWith('meshcore://')) return null; + try { + const url = new URL(uri); + if (url.hostname !== 'contact' || url.pathname !== '/add') return null; + const name = url.searchParams.get('name'); + const publicKey = url.searchParams.get('public_key'); + if (!name || !publicKey) return null; + const key = publicKey.trim().toLowerCase(); + if (key.length !== 64 || !/^[0-9a-f]{64}$/.test(key)) return null; + let type = parseInt(url.searchParams.get('type') || '1', 10); + if (![1,2,3,4].includes(type)) type = 1; + return { name: name.trim(), public_key: key, type }; + } catch { + return null; + } +} + +// --- URI Tab --- + +function handleUriInput() { + const uri = document.getElementById('uriInput').value.trim(); + const preview = document.getElementById('uriPreview'); + const btn = document.getElementById('addFromUriBtn'); + + // Try mobile app format first + const parsed = parseMeshcoreUri(uri); + if (parsed) { + document.getElementById('uriPreviewName').textContent = parsed.name; + document.getElementById('uriPreviewKey').textContent = parsed.public_key; + document.getElementById('uriPreviewType').textContent = TYPE_LABELS[parsed.type] || 'COM'; + preview.classList.remove('d-none'); + btn.disabled = false; + return; + } + + // Hex blob format — can't preview but still valid + if (uri.startsWith('meshcore://') && uri.length > 20) { + preview.classList.add('d-none'); + btn.disabled = false; + return; + } + + preview.classList.add('d-none'); + btn.disabled = true; +} + +// --- QR Tab --- + +function startQrCamera() { + const readerEl = document.getElementById('qrReader'); + if (!readerEl) return; + + html5QrCode = new Html5Qrcode('qrReader'); + html5QrCode.start( + { facingMode: 'environment' }, + { fps: 10, qrbox: { width: 250, height: 250 } }, + onQrCodeSuccess, + () => {} // ignore scan failures + ).then(() => { + document.getElementById('startCameraBtn').classList.add('d-none'); + document.getElementById('stopCameraBtn').classList.remove('d-none'); + }).catch(err => { + showQrError('Camera access denied or not available. Try uploading an image instead.'); + console.error('QR camera error:', err); + }); +} + +function stopQrCamera() { + if (html5QrCode && html5QrCode.isScanning) { + html5QrCode.stop().catch(() => {}); + } + document.getElementById('startCameraBtn').classList.remove('d-none'); + document.getElementById('stopCameraBtn').classList.add('d-none'); +} + +function handleQrFile(event) { + const file = event.target.files[0]; + if (!file) return; + + const scanner = new Html5Qrcode('qrReader'); + scanner.scanFile(file, true) + .then(decodedText => { + onQrCodeSuccess(decodedText); + scanner.clear(); + }) + .catch(err => { + showQrError('Could not read QR code from image. Make sure the image contains a valid QR code.'); + console.error('QR file scan error:', err); + }); +} + +function onQrCodeSuccess(decodedText) { + const resultDiv = document.getElementById('qrResult'); + const errorDiv = document.getElementById('qrError'); + const addBtn = document.getElementById('addFromQrBtn'); + + errorDiv.classList.add('d-none'); + + const parsed = parseMeshcoreUri(decodedText); + if (parsed) { + document.getElementById('qrResultName').textContent = parsed.name; + document.getElementById('qrResultKey').textContent = parsed.public_key; + document.getElementById('qrResultType').textContent = TYPE_LABELS[parsed.type] || 'COM'; + resultDiv.classList.remove('d-none'); + addBtn.classList.remove('d-none'); + qrScannedUri = decodedText; + stopQrCamera(); + return; + } + + // Hex blob format + if (decodedText.startsWith('meshcore://') && decodedText.length > 20) { + resultDiv.innerHTML = 'Scanned: ' + + decodedText.substring(0, 60) + '...'; + resultDiv.classList.remove('d-none'); + addBtn.classList.remove('d-none'); + qrScannedUri = decodedText; + stopQrCamera(); + return; + } + + showQrError('QR code does not contain a valid meshcore:// URI.'); +} + +function showQrError(msg) { + const errorDiv = document.getElementById('qrError'); + errorDiv.textContent = msg; + errorDiv.classList.remove('d-none'); + document.getElementById('qrResult').classList.add('d-none'); + document.getElementById('addFromQrBtn').classList.add('d-none'); +} + +// --- Manual Tab --- + +function handleManualKeyInput() { + const input = document.getElementById('manualKey'); + // Allow only hex characters + input.value = input.value.replace(/[^0-9a-fA-F]/g, '').toLowerCase(); + document.getElementById('manualKeyCount').textContent = `${input.value.length} / 64 characters`; + validateManualForm(); +} + +function validateManualForm() { + const name = document.getElementById('manualName').value.trim(); + const key = document.getElementById('manualKey').value.trim(); + const btn = document.getElementById('addManualBtn'); + btn.disabled = !(name.length > 0 && key.length === 64 && /^[0-9a-f]{64}$/.test(key)); +} + +// --- Submit --- + +async function submitContact(mode) { + const statusDiv = document.getElementById('addStatus'); + let body = {}; + + if (mode === 'uri') { + body.uri = document.getElementById('uriInput').value.trim(); + } else if (mode === 'qr') { + body.uri = qrScannedUri; + } else if (mode === 'manual') { + body.name = document.getElementById('manualName').value.trim(); + body.public_key = document.getElementById('manualKey').value.trim(); + body.type = parseInt(document.getElementById('manualType').value, 10); + } + + // Show loading + statusDiv.className = 'mt-3 alert alert-info'; + statusDiv.innerHTML = 'Adding contact...'; + statusDiv.classList.remove('d-none'); + + try { + const response = await fetch('/api/contacts/manual-add', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + const data = await response.json(); + + if (data.success) { + statusDiv.className = 'mt-3 alert alert-success'; + statusDiv.textContent = data.message || 'Contact added successfully!'; + // Reset form + resetAddForm(mode); + } else { + statusDiv.className = 'mt-3 alert alert-danger'; + statusDiv.textContent = data.error || 'Failed to add contact.'; + } + } catch (error) { + statusDiv.className = 'mt-3 alert alert-danger'; + statusDiv.textContent = 'Network error: ' + error.message; + } +} + +function resetAddForm(mode) { + if (mode === 'uri') { + document.getElementById('uriInput').value = ''; + document.getElementById('uriPreview').classList.add('d-none'); + document.getElementById('addFromUriBtn').disabled = true; + } else if (mode === 'qr') { + qrScannedUri = null; + document.getElementById('qrResult').classList.add('d-none'); + document.getElementById('addFromQrBtn').classList.add('d-none'); + document.getElementById('qrFileInput').value = ''; + } else if (mode === 'manual') { + document.getElementById('manualName').value = ''; + document.getElementById('manualKey').value = ''; + document.getElementById('manualKeyCount').textContent = '0 / 64 characters'; + document.getElementById('addManualBtn').disabled = true; + } +} diff --git a/app/templates/contacts-add.html b/app/templates/contacts-add.html new file mode 100644 index 0000000..763d471 --- /dev/null +++ b/app/templates/contacts-add.html @@ -0,0 +1,128 @@ +{% extends "contacts_base.html" %} + +{% block title %}Add Contact - mc-webui{% endblock %} + +{% block extra_head %} + + +{% endblock %} + +{% block page_content %} +
+ +
+

+ Add Contact +

+
+ + +
+ +
+ + + + +
+ +
+
+ + + Paste a meshcore:// URI from the MeshCore mobile app +
+ +
+ Preview: +
Name:
+
Key:
+
Type:
+
+ +
+ + +
+ +
+
+
+ + +
+
+ +
+ + +
+ +
+ Scanned: +
Name:
+
Key:
+
Type:
+
+
+ +
+ + +
+
+ + +
+
+ + + 0 / 64 characters +
+
+ + +
+ +
+
+ + +
+
+{% endblock %} diff --git a/app/templates/contacts-manage.html b/app/templates/contacts-manage.html index 6bf83d7..5e529a9 100644 --- a/app/templates/contacts-manage.html +++ b/app/templates/contacts-manage.html @@ -32,6 +32,15 @@ Manage Contacts + + +