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/<pk>/push-to-device, move-to-cache
- Add UI buttons with confirm dialogs in contacts.js

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-03-24 18:06:26 +01:00
parent 215515fe02
commit 9ee63188d2
3 changed files with 142 additions and 2 deletions
+46
View File
@@ -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:
+32
View File
@@ -2738,6 +2738,38 @@ def delete_cached_contact_api():
return jsonify({'success': False, 'error': str(e)}), 500
@api_bp.route('/contacts/<public_key>/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/<public_key>/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():
"""
+64 -2
View File
@@ -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 = '<i class="bi bi-cloud-arrow-down"></i> <span class="btn-label">To cache</span>';
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 = '<i class="bi bi-trash"></i> <span class="btn-label">Delete</span>';
@@ -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 = '<i class="bi bi-cpu"></i> <span class="btn-label">To device</span>';
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 = '<i class="bi bi-trash"></i> <span class="btn-label">Delete</span>';
@@ -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');
}
}