From 9ee63188d2ccd26ada7c3494f746bc76713f293b Mon Sep 17 00:00:00 2001 From: MarekWo Date: Tue, 24 Mar 2026 18:06:26 +0100 Subject: [PATCH] feat(contacts): add push-to-device and move-to-cache operations Enable moving contacts between device and cache directly from the Existing Contacts UI: - "To device" button on cache-only contacts (pushes to device) - "To cache" button on device contacts (removes from device, keeps in DB) This helps manage the 350-contact device limit by offloading inactive contacts to cache and restoring them when needed. - Add DeviceManager.push_to_device() and move_to_cache() methods - Add API endpoints: POST /contacts//push-to-device, move-to-cache - Add UI buttons with confirm dialogs in contacts.js Co-Authored-By: Claude Opus 4.6 --- app/device_manager.py | 46 +++++++++++++++++++++++++++ app/routes/api.py | 32 +++++++++++++++++++ app/static/js/contacts.js | 66 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 142 insertions(+), 2 deletions(-) diff --git a/app/device_manager.py b/app/device_manager.py index 385f4ee..9ff3942 100644 --- a/app/device_manager.py +++ b/app/device_manager.py @@ -1465,6 +1465,52 @@ class DeviceManager: logger.error(f"Failed to delete cached contact: {e}") return {'success': False, 'error': str(e)} + def push_to_device(self, pubkey: str) -> Dict: + """Push a cache-only contact to the device.""" + if not self.is_connected: + return {'success': False, 'error': 'Device not connected'} + + # Already on device? + if self.mc.contacts and pubkey in self.mc.contacts: + return {'success': False, 'error': 'Contact is already on device'} + + db_contact = self.db.get_contact(pubkey) + if not db_contact: + return {'success': False, 'error': 'Contact not found in cache'} + + name = db_contact.get('name', '') + contact_type = db_contact.get('type', 1) + if contact_type == 0: + contact_type = 1 # NONE → COM + + return self.add_contact_manual( + name=name, + public_key=pubkey, + contact_type=contact_type, + ) + + def move_to_cache(self, pubkey: str) -> Dict: + """Move a device contact to cache (remove from device, keep in DB).""" + if not self.is_connected: + return {'success': False, 'error': 'Device not connected'} + + if not self.mc.contacts or pubkey not in self.mc.contacts: + return {'success': False, 'error': 'Contact not on device'} + + contact = self.mc.contacts[pubkey] + name = contact.get('adv_name', contact.get('name', '')) + + try: + self.execute(self.mc.commands.remove_contact(pubkey)) + self.db.delete_contact(pubkey) # soft-delete: sets source='advert' + if self.mc.contacts and pubkey in self.mc.contacts: + del self.mc.contacts[pubkey] + logger.info(f"Moved to cache: {name} ({pubkey[:12]}...)") + return {'success': True, 'message': f'{name} moved to cache'} + except Exception as e: + logger.error(f"Failed to move contact to cache: {e}") + return {'success': False, 'error': str(e)} + def reset_path(self, pubkey: str) -> Dict: """Reset path to a contact.""" if not self.is_connected: diff --git a/app/routes/api.py b/app/routes/api.py index 4da26d2..a70f0c8 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -2738,6 +2738,38 @@ def delete_cached_contact_api(): 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.""" + try: + dm = _get_dm() + if not dm: + return jsonify({'success': False, 'error': 'Device manager unavailable'}), 500 + + result = dm.push_to_device(public_key.strip().lower()) + status = 200 if result['success'] else 400 + return jsonify(result), status + except Exception as e: + logger.error(f"Error pushing contact to device: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@api_bp.route('/contacts//move-to-cache', methods=['POST']) +def move_contact_to_cache(public_key): + """Move a device contact to cache (remove from device, keep in DB).""" + try: + dm = _get_dm() + if not dm: + return jsonify({'success': False, 'error': 'Device manager unavailable'}), 500 + + result = dm.move_to_cache(public_key.strip().lower()) + status = 200 if result['success'] else 400 + return jsonify(result), status + except Exception as e: + logger.error(f"Error moving contact to cache: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + @api_bp.route('/contacts/protected', methods=['GET']) def get_protected_contacts_api(): """ diff --git a/app/static/js/contacts.js b/app/static/js/contacts.js index 382b5cb..373d0b5 100644 --- a/app/static/js/contacts.js +++ b/app/static/js/contacts.js @@ -2290,7 +2290,7 @@ function createExistingContactCard(contact, index) { actionsDiv.appendChild(mapBtn); } - // Protect & Delete buttons (only for device contacts) + // Protect, Move to cache & Delete buttons (only for device contacts) if (contact.on_device !== false) { const protectBtn = document.createElement('button'); protectBtn.className = isProtected ? 'btn btn-sm btn-warning' : 'btn btn-sm btn-outline-warning'; @@ -2300,6 +2300,17 @@ function createExistingContactCard(contact, index) { protectBtn.onclick = () => toggleContactProtection(contact.public_key, protectBtn); actionsDiv.appendChild(protectBtn); + const moveToCacheBtn = document.createElement('button'); + moveToCacheBtn.className = 'btn btn-sm btn-outline-info'; + moveToCacheBtn.innerHTML = ' To cache'; + moveToCacheBtn.title = 'Remove from device, keep in cache'; + moveToCacheBtn.onclick = () => moveContactToCache(contact); + moveToCacheBtn.disabled = isProtected; + if (isProtected) { + moveToCacheBtn.title = 'Cannot move protected contact'; + } + actionsDiv.appendChild(moveToCacheBtn); + const deleteBtn = document.createElement('button'); deleteBtn.className = 'btn btn-sm btn-outline-danger'; deleteBtn.innerHTML = ' Delete'; @@ -2311,8 +2322,15 @@ function createExistingContactCard(contact, index) { actionsDiv.appendChild(deleteBtn); } - // Delete button for cache-only contacts + // Push to device & Delete buttons for cache-only contacts if (contact.on_device === false) { + const pushToDeviceBtn = document.createElement('button'); + pushToDeviceBtn.className = 'btn btn-sm btn-outline-success'; + pushToDeviceBtn.innerHTML = ' To device'; + pushToDeviceBtn.title = 'Add this contact to the device'; + pushToDeviceBtn.onclick = () => pushContactToDevice(contact); + actionsDiv.appendChild(pushToDeviceBtn); + const deleteCacheBtn = document.createElement('button'); deleteCacheBtn.className = 'btn btn-sm btn-outline-danger'; deleteCacheBtn.innerHTML = ' Delete'; @@ -2493,3 +2511,47 @@ async function confirmDelete() { contactToDelete = null; } } + +// ============================================================================= +// Push to Device / Move to Cache +// ============================================================================= + +async function pushContactToDevice(contact) { + if (!confirm(`Push "${contact.name}" to device?`)) return; + + try { + const response = await fetch(`/api/contacts/${contact.public_key}/push-to-device`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + const data = await response.json(); + if (data.success) { + showToast(data.message || `${contact.name} pushed to device`, 'success'); + setTimeout(() => loadExistingContacts(), 500); + } else { + showToast(data.error || 'Failed to push contact', 'danger'); + } + } catch (error) { + showToast('Network error: ' + error.message, 'danger'); + } +} + +async function moveContactToCache(contact) { + if (!confirm(`Move "${contact.name}" from device to cache?`)) return; + + try { + const response = await fetch(`/api/contacts/${contact.public_key}/move-to-cache`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + const data = await response.json(); + if (data.success) { + showToast(data.message || `${contact.name} moved to cache`, 'success'); + setTimeout(() => loadExistingContacts(), 500); + } else { + showToast(data.error || 'Failed to move contact', 'danger'); + } + } catch (error) { + showToast('Network error: ' + error.message, 'danger'); + } +}